Five months after the manual-to-MCP pivot, I noticed the MCP server was doing two different jobs. One was a tool surface for an agent — the task for which I'd designed and built the MCP server. The other was the hosting environment for the daemon itself: long-running, stateful, with a job queue and a state machine that wanted to live longer than any single Claude Desktop session.
For most of those five months, the two jobs sharing one process was fine. Then it wasn't.
The state before
Concretely, the friction looked like this: every tool call that touched the pipeline ran the work in-process. Long jobs blocked the MCP host. If I restarted Claude Desktop — which I did regularly, for unrelated reasons — the in-flight pipeline state went with it. And I was increasingly trying to hit the pipeline from outside Claude: from cron, from a CLI, from my phone.
The realization came in pieces, but it landed all at once: MCP was the wrong host for the daemon. What I needed wasn't more plumbing in front of MCP. It was for the daemon to own its own front door.
The cut
I drafted the SPEC on the morning of February 28. By the end of the day, the cut was done. Six commits, the first titled Add pipeline daemon — extract agent from MCP server. The agent and state machine moved out of the MCP process into pipeline_daemon.py: a standalone process supervised by launchd, persisting state across restarts, and exposing a small JSON-over-HTTP API gated by a single private header.
The diff was small for what it accomplished: about 630 insertions, 160 deletions across 7 files — mostly the new daemon, its launchd plist, and the install script. What's notable is what didn't change: scripts/agent/runner.py, the module that holds the agent state machine and the actual pipeline orchestration, wasn't touched. Before the cut, the MCP server imported and executed runner.py in-process. After the cut, the new daemon imports and executes the same module. The work itself didn't move — only its host did.
That's the lesson I'd most want a future me to remember. The manual-pipeline week from post one wasn't just MVP scaffolding — it produced a spec clean enough that the runner had no assumptions baked in about who was calling it, when, or from where. So when the host changed, the runner didn't need to. If I'd built the agent directly inside the MCP server first, the cut would have meant untangling those assumptions before anything else could happen. Instead, the cut took a day.
The new boundary is intentionally narrow: /api/status, /api/run, /api/halt, /api/reset, /api/jobs/{id}, plus the housekeeping endpoints. One header, one secret, no proxy stack. The daemon itself is a small FastAPI app — chose it over Flask for native asyncio, so pipeline calls drop directly into asyncio.create_task() with no threading bridge.
Once the daemon owned its own front door, the MCP server collapsed into a thin client. Sixteen tools, each one a small httpx call into the daemon API. No business logic in MCP at all. The daemon_control tool is genuinely embarrassingly thin — one line of real logic per action. Cron, MCP, and the operator CLI all hit the same surface; the daemon doesn't care which one is calling.
This is the same single-day pattern as post one. When the spec is clear, the cut is fast. Twice now.
The launchd hardening cluster
Four of those six commits weren't the daemon extraction itself. They were the post-extraction fixes that always come when you move a process out from under your interactive shell. Two of them generalize beyond the specifics of any one project:
- API keys. launchd doesn't inherit your shell environment. Pipeline tools that worked fine when the daemon was running under
python pipeline_daemon.pyfrom a terminal silently failed when the same code ran under launchd. Fix: pass every secret the daemon needs through the plist'sEnvironmentVariablesblock. Anything your interactive shell sourced from a profile, the daemon won't see. - Same-day re-run state collision. The first time I re-ran the pipeline on the same day after extraction, the agent state machine got confused about whose job ID owned what. Fix: explicit
job_idtracking with same-day disambiguation. Same commit addedThrottleInterval=10andKeepAlive=trueto the plist, capping launchd's auto-restart to once every ten seconds. That detail tells you I'd hit the crash loop.
There's one more piece of friction-removal worth pulling out, because it's the kind of thing you only add after you've debugged it once at 11pm: when the daemon is unreachable, the MCP tool returns an error message that contains the exact recovery command —
Pipeline daemon is not running. Restart it with:
launchctl kickstart -k gui/$(id -u)/dev.snowake.rcd-pipeline-daemon
The system that can tell you how to fix it is the one you'll actually trust to run unattended.
All of this reinforces the post's underlying point: the protocol choice (MCP) wasn't the hard part. The operating-system seam was. The boundary where your code stops being yours and starts being the operating system's is harder to anticipate than the part you actually wrote.
A side benefit: git was already the gate
One of the commits in the same sprint did something I wasn't planning to do: it removed the manual approval gate the agent had been carrying around since the original pipeline. Pre-extraction, "approve this episode" was a programmatic call against the agent — agent_control(action="approve") — that flipped a state machine field. Post-extraction, the daemon publishes the episode to the dev environment and opens a pull request automatically, and merging the PR is the approval. The daemon stopped needing to know about it.
That's not a daemon-extraction lesson per se. It's a remove-the-bespoke-thing-once-the-standard-thing-fits cleanup, and it's the kind of move that's only earned in retrospect — once the architecture is right, the redundant scaffolding becomes obvious.
What I'd tell someone considering this
If you've built something inside an MCP server and you're starting to feel friction — long jobs blocking, state evaporating on restart, the urge to wrap a bunch of plumbing in front so you can reach it from elsewhere — the question worth asking is whether MCP is actually the right host for the thing you've built, or just the right interface. They are different jobs. They can live in different processes.
The cost of separating them is small if your interface boundary is already well-defined. The cost of leaving them entangled grows quietly until it doesn't.
Where this leaves things
The daemon now runs as its own first-class process. Cron triggers it. MCP talks to it. So does my phone — the backyard-to-desk access I'd wanted for months turned out to fall out of the architecture for free, once the architecture was right.
The architecture has held. In the months since the cut, both sides of the boundary have grown. The MCP server has nearly doubled (it was around 1,300 lines post-extraction; today it's about 1,800) as new tools landed: episode publishing, special editions, scrub and replace operations. The daemon has grown roughly four-fold as new endpoints appeared behind its own front door. The thin-client pattern didn't just hold — both halves became their own growth lanes. That's the strongest evidence I have that the boundary I drew on February 28 was the right one.
The next post turns away from architecture and toward content. Thirty-plus episodes in, I started noticing that the weekly briefings were converging on the same handful of through-lines no matter what was actually in the news. That's a different kind of problem — more about prompts, query rotation, and listening to your own podcast back — and it deserves its own post.