Daily newsletters on AI/ML/DevOps topics from k8s to Agentic Workflows, fully generated by a self-hosted agentic system running on Ollama.
Available to read on my website: https://sean-michael.dev/digest
Inspired by a comment Addy Osmani made in his recent chat with Tim O'Reilly on agentic AI systems, where he proposed one could create an AI Agent to stay on top of all the latest innovations in the space. I thought this was very apt because I wanted to experiment with agentic systems more AND I struggle with FOMO whenever new technologies come around.
Three agents collaborate in a loop that mirrors how a small editorial team would work. Each agent run goes through chat_with_ollama() in agents.py which wraps the Ollama Python SDK chat() call and instruments it with OpenTelemetry spans.
Researcher (agents.py:researcher)
- Input: Raw RSS entries from all 26 feeds (dict of source → entries)
- Tools:
fetch_article()viaThreadPoolExecutor— for thin RSS entries (<200 chars), fetches the full page and parses with BeautifulSoup - Prompts:
RESEARCHER_SYSTEM_PROMPT+RESEARCHER_USER_PROMPT— tells it to pick the top 10 articles matching my interests and return just a JSON array of links - Then: For each curated article, runs
summarize_article()withSUMMARY_SYSTEM_PROMPT+SUMMARY_USER_PROMPT - Output:
list[dict]— each withsource,title,summary,link
Writer (agents.py:writer)
- Input: Curated articles from researcher, plus any previous draft and editor feedback
- Prompts:
WRITER_SYSTEM_PROMPT+WRITER_USER_PROMPT— template vars:$date_str,$articles,$feedback,$draft - Output: Markdown newsletter with "Story of the Day" deep dive + "Quick Hits" section
Editor (agents.py:editor)
- Input: The writer's draft
- Prompts:
EDITOR_SYSTEM_PROMPT+EDITOR_USER_PROMPT— template vars:$date_str,$draft - Output: Either
"LGTM"or specific actionable feedback (never both)
The writer and editor loop up to MAX_REVISIONS times. The final edition gets written to digests/ as markdown with YAML frontmatter, and optionally published to S3 for my website.
All prompts live in prompts.py as Pydantic BaseModel instances:
class Prompt(BaseModel):
agent: str # which agent owns this prompt
prompt_type: str # "system" or "user"
template: str # python string.Template syntax ($var)
version: str # semver, e.g. "v1.0.0"
def render(self, **kwargs) -> str:
return Template(self.template).substitute(**kwargs)This gives each prompt a structured identity (agent, type, version) that gets carried into tracing. When an agent runs, the prompt version and template are set as span attributes so I can correlate model behavior with specific prompt versions in Phoenix.
Every agent call is wrapped with openinference.instrumentation.using_prompt_template() which attaches the prompt template and version to the current OpenTelemetry span. Combined with the llm.chat spans from chat_with_ollama(), each run captures:
- Prompt template text + version
- System and user prompt content
- Token counts (prompt, completion, total)
- Latency breakdown (total, prompt eval, generation, model load) in ms
- Token throughput (tokens/sec)
- Session ID via
using_session()so all spans from one run are grouped
All of this lands in Arize Phoenix at localhost:6006.
| Runtime | Python 3.13, uv |
| LLM | Ollama (currently gemma4:e4b) |
| Feeds | feedparser, BeautifulSoup for content enrichment |
| Observability | OpenTelemetry → Arize Phoenix (token counts, latencies, prompts) |
| Publishing | boto3 (S3), markdown with YAML frontmatter |
| Prompts | Versioned templates with Pydantic models (prompts.py) |
All in config.py — models, context window, revision limits, interests list, etc.
| Variable | Default | |
|---|---|---|
RESEARCHER_MODEL / WRITER_MODEL / EDITOR_MODEL |
gemma4:e4b |
Ollama model for each agent |
NUM_CTX |
65536 |
Context window size |
MAX_REVISIONS |
3 |
Editorial loop cap |
TIMEFRAME_HOURS |
24 |
How far back to look for articles |
26 feeds across official blogs (Docker, HuggingFace, AWS AI, Kubernetes, CNCF), Hacker News filters, Substacks (Addy Osmani, Pragmatic Engineer, Byte Byte Go, etc.), and independent blogs (Simon Willison, Eugene Yan, SemiAnalysis). Full list in feeds.json.
Prerequisites: Ollama running locally with your model pulled (e.g. ollama pull gpt-oss:20b)
Start Phoenix for trace collection and the app:
docker compose upOr run them separately:
# Phoenix
docker run -p 6006:6006 -p 4317:4317 -it arizephoenix/phoenix:latest
# Agents
uv run main.pyPhoenix UI is at http://localhost:6006 — you can see every LLM call, token counts, and latencies there.
Ollama runs on port 11434. The Dockerfile and docker-compose already handle this — OLLAMA_HOST is set to http://host.docker.internal:11434 so the container talks to Ollama on your host machine. Just make sure Ollama is running before you docker compose up.
digests/— Final published newsletters (markdown + YAML frontmatter)drafts/{date}/— Every draft and edit from the editorial loop, saved per-revisionlogs/{date}/— Structured JSON logs
See notes/ for the full learning journal and TODO list.