A four-agent AI blog co-author running on my Raspberry Pi
· ai-agents, crewai, self-hosting, raspberry-pi, automation, homelab
I write this blog as Markdown files in a git repo, and a Cloudflare Tunnel +
Watchtower pipeline ships them the moment a change
lands on main. The missing piece was the writing itself. So I built a
co-author: a small crew of AI agents that researches a topic, drafts it in my
voice, takes my edit notes, and opens a pull request — running entirely on the
second Raspberry Pi in my home.
The important word is co-author, not author. It does the typing; I keep the final say. This post is about the shape of that system and the decisions that keep it safe to leave running on a box in my house.
The core idea
The crew can write and propose, but it can never publish. A pull request is the only door to the live site, and only I open it.
Everything below follows from that one rule. The agents are free to be wrong,
creative, or confused — because nothing they produce reaches luckyganesh.in
until I read the diff and click merge.
Four agents, one job each
It’s a CrewAI service with four narrowly-scoped agents. Each does one thing, which makes each one easy to reason about and easy to swap.
| Agent | Does | Tools | Triggered by |
|---|---|---|---|
| Researcher | Gathers current facts for the topic | web search + page reader (DuckDuckGo, free) | Research & write |
| Writer | Drafts the post in my voice | — (reads my style guide + recent posts) | Research & write |
| Editor | Applies my review notes, keeps the rest intact | — | Apply suggestions (loop) |
| Publisher | Opens a pull request to main | a deterministic git + GitHub tool | Publish |
The Researcher and Writer run back-to-back when I start a post: the Researcher produces a brief — facts with their sources, plus an honest list of what it couldn’t verify — and the Writer turns that brief, plus my notes, into a draft. The Editor is a loop: I read the draft, type suggestions in plain English, and it revises precisely what I asked for without silently rewriting the parts that already worked. The Publisher only runs when I’m happy.
High-level flow
me (phone or laptop, over Tailscale)
│
▼
┌──────────────────────────────────────────────────────┐
│ lucky-node02 — blog-coauthor (Flask + CrewAI) │
│ │
│ topic ─▶ Researcher ─▶ Writer ─▶ draft (SQLite) │
│ │ │
│ my notes ─▶ Editor ─┘ (revise loop) │
│ │ │
│ Publisher │
└──────────────────────────────────│─────────────────────┘
│ git push + GitHub API
▼
Pull request ──▶ I review & merge
│
▼
CI builds image ─▶ Watchtower ─▶ live
The model calls go out to OpenRouter; the Pi only orchestrates the crew and holds the state. That split is deliberate — a Raspberry Pi has no business running an LLM, but it’s perfectly happy shuttling prompts and parsing JSON. I point the Writer at a strong model and the cheaper Researcher and Publisher at a small one, because research and PR-wording don’t need the good model and I’d rather not pay for it on every call.
Where the safety actually lives
A model writing to a git repo is the kind of thing that sounds reckless until you look at where the boundaries are drawn. Three of them matter:
- Frontmatter is assembled in code, never by the LLM. The Writer returns a
structured object — title, description, tags, body — and nothing else. The
pubDate, thedraft: falseflag, the YAML itself are stamped in Python. The model literally cannot emit a malformed frontmatter block that breaks the Astro schema, because it never touches the frontmatter. - The git tool is deterministic. The route has already written the exact
file to a branch before the Publisher agent runs. The agent’s only power is to
supply a commit message and PR text and call the tool once. It can’t alter
the file, push to
main, or run arbitrary git — those aren’t options it has. - The PR is the gate, for deletes too. Removing a live post also goes through a pull request that deletes the file by path — built in code, no LLM deciding what to remove. A post is gone only once I merge that PR.
The net effect: the worst a confused agent can do is open a pull request I’ll reject. That’s a failure mode I’m completely comfortable leaving unattended.
Managing posts in flight
The dashboard tracks each post in a local SQLite database, and every post gets its own branch and its own PR — so I can have several going at once. The interesting part is reconciliation: the dashboard checks the open PRs against GitHub on every load and keeps itself honest.
| What I did on GitHub | What the dashboard does |
|---|---|
| Merged a post’s PR | Drops it from the working set, deletes the branch |
| Closed a PR unmerged | Returns the post to writing so I can revise and re-publish |
| Opened an edit of a live post | Re-publishing updates the same PR, preserves the original pubDate, stamps updatedDate |
So the source of truth is the repo, not the app’s memory. If I merge on my phone in the GitHub app, the dashboard catches up the next time I open it. Nothing is left dangling.
Keeping the voice mine
The Writer reads two things before it drafts: a STYLE.md that describes my
voice in detail — first person, lead with the idea, em-dashes, honest about
trade-offs, no marketing-speak — and my two most recently edited posts,
pulled live from the repo. The second part matters more than the first: the
style guide is what I say I sound like; the recent posts are what I actually
sound like right now. As my writing drifts, the examples drift with it, for free.
How it’s deployed
Same pattern as the website it feeds. GitHub Actions builds an arm64 image and
pushes it to Docker Hub; the Pi only ever pulls. On lucky-node02 it’s one
docker-compose.yml and a .env full of secrets — the OpenRouter key, a GitHub
PAT scoped to only the my-website repo, and an optional password to gate the
UI even on the LAN. The cloned repo and the draft database live in named volumes,
so restarts don’t lose state.
Crucially, it is not on the Cloudflare tunnel. It’s an admin tool, so it’s
private by default — reachable only over my Tailscale
mesh at http://lucky-node02:5000. That means I can start a post from my phone
on the train, and the internet has no idea the service exists. The whole thing
lives on the helper Pi precisely because the public site host should stay
boring and untouched.
Why I like this shape
The reflex with AI writing tools is to let them publish and hope. I went the other way: give the agents real autonomy inside a box whose only exit is a pull request I control. It turns an unpredictable model into something I’m happy to leave running on hardware in my house, because the blast radius of a bad generation is a diff I won’t merge.
It also composes cleanly with everything already running — same image-based
deploy, same VPN-only admin boundary, same git push → CI → Watchtower → live
pipeline. The co-author just adds one more producer at the front of a chain I
already trust.
What’s next
A few things I’d still like to add: image suggestions during drafting, a way to queue topics in advance, and a cheap eval that flags when a draft has drifted off my voice before I even read it. For now, though, the loop is complete — I give it a topic and some notes, read what comes back, nudge it, and merge. Including, honestly, posts a lot like this one.