I run 5 Claude Code CLIs from one control plane. Here's the plumbing.
Source: dev.to · Published: 2026-08-01
<p>Claude Code is great at being one agent in one terminal on one repo. The trouble starts at<br> agent number three. I had five projects going, five terminal windows, and every morning I'd<br> sit down and spend ten minutes just working out which agent was mid-task, which was blocked<br> waiting on me, and which had quietly finished an hour ago.</p>
<p>The thing nobody tells you about running multiple agents is that <strong>you become the message<br> bus.</strong> Every agent routes through your attention. Scale that to five and the agents aren't<br> the bottleneck — you are. So I built a small control plane to be the message bus instead of<br> me. This is how it's wired, including the parts that fought back.</p>
<h3> Constraint that shaped everything: drive the CLI, not the API </h3>
<p>The obvious move is to hit Anthropic's API directly and build your own agent loop. I<br> deliberately didn't. Two reasons.</p>
<p>First, cost and trust: driving the <code>claude</code> CLI means the work runs on your existing Claude<br> subscription, not a metered API key. It costs exactly what Claude Code costs you today, and<br> nothing routes through a server I run. For a tool people install on their own machine, "your<br> subscription, your keys, your data" isn't a feature — it's the whole deal.</p>
<p>Second, the CLI <em>already is</em> the agent. It has the tool-use loop, the permission model, the<br> file editing, the MCP support. Re-implementing that against the raw API is throwing away the<br> best part. So the design flipped: instead of <em>being</em> the agent, the control plane <strong>spawns<br> and supervises <code>claude</code> CLI processes</strong> and gets out of the way.</p>
<p>That one decision — supervise a subprocess instead of call an API — is what made the rest of<br> this interesting.</p>
<h3> The shape: a Flask control plane, one subprocess per project, SSE for the live feed </h3>
<p>The core is a Flask app. Each project maps to a <code>claude</code> process. When you dispatch a task,<br> the server spawns (or reuses) that project's process, streams its stdout, and pushes the<br> tokens to the browser over Server-Sent Events. The dashboard is just N of those live feeds<br> tiled on a grid, each with a status: <strong>working, waiting on you, or idle.</strong></p>
<p>Sounds simple. Here's where it got complicated - </p>
<h3> Hurdle 1: the browser only gives you 6 SSE connections per origin </h3>
<p>The dashboard wants a live stream per project. Open six projects and the seventh tile just…<br> hangs. That's not a bug in your code — it's the browser. Chromium caps concurrent<br> connections to a single origin at six, and a long-lived SSE stream holds one open the entire<br> time. Five streaming agents plus one stray reconnect and you've hit the ceiling.</p>
<p>The fix is to treat streams as scarce: open an SSE connection only for a project that's<br> actively <code>running</code>, and <strong>close it the moment the turn completes</strong> rather than holding it<br> open "just in case." Idle projects don't get a stream at all — they get polled cheaply. Once<br> streams are tied to activity instead of to tiles, the cap stops mattering.</p>
<h3> Hurdle 2: Windows has an 8,191-character command line </h3>
<p>To give an agent context, the natural instinct is to pass it as a CLI argument. On Windows,<br> <code>cmd.exe</code> truncates the whole command at 8,191 characters, silently. A decent system prompt<br> plus a project's memory blows past that instantly, and you get a corrupted invocation with<br> no error — the agent just starts wrong.</p>
<p>The fix: never pass context inline. Write it to a file and hand the CLI<br> <code>--append-system-prompt-file</code>. Same trick solves a second problem — the shell mangling quotes<br> and newlines in a big prompt. File in, path as the argument, done. Obvious in hindsight;<br> cost me an evening.</p>
<h3> The persistent-process decision (and its sting) </h3>
<p>You can spawn a fresh <code>claude</code> per turn, or keep one process alive across turns. Fresh-per-turn<br> is clean but slow and forgetful. A persistent process is fast and keeps its working state — so<br> that's the default. The sting: session-teardown logic that you assumed ran per-turn now only<br> runs when the process actually exits. Anything you hung off "end of turn" — writing memory,<br> releasing a stream, updating status — has to be re-anchored to the real lifecycle events, or<br> it silently stops firing. Persistent processes are worth it, but they move where "done"<br> happens, and every assumption downstream of "done" has to move with it.</p>
<h3> Memory is the actual product; the process supervisor is just how you reach it </h3>
<p>The plumbing above makes many agents <em>runnable</em>. It doesn't make them <em>good</em>. The real pain with<br> long-lived projects is that every session starts from zero — the agent re-reads the repo to work<br> out where you left off, re-derives decisions you already made, and burns turns rebuilding context<br> you both had yesterday. Solving <em>that</em>, not the tiling, is what makes running many agents<br> sustainable instead of just possible. It's the piece I've iterated on the most, and the piece<br> that took the longest to get right. Here's the pipeline.</p>
<p><strong>Write — summarize from the real transcript, not the screen.</strong> At the end of a session (and,<br> because the process is persistent, at checkpoints <em>during</em> it — otherwise a hard kill loses the<br> thread), a cheap model condenses what happened into a few lines: decisions made, what's done,<br> what's next. The detail that matters is the <em>source</em>. The stdout you see scrolling in the<br> terminal has already dropped tool results and the model's own reasoning. The full-fidelity<br> record is the CLI's on-disk transcript (<code>.jsonl</code>), so the summarizer reads <em>that</em> and only falls<br> back to the stdout tail if it has to. Summarize from the screen and your memory is missing<br> exactly the parts that mattered.</p>
<p><strong>Store — two regions, different owners.</strong> The per-project <code>MEMORY.md</code> is deliberately split. On<br> top, a small human-curated index — byte-preserved, the machinery never rewrites it. Below, a<br> sentinel-delimited <em>managed</em> region that only the write pipeline touches. Keeping the trustworthy<br> hand-written notes physically separate from the auto-captured churn is what lets you actually<br> rely on the file instead of babysitting it. Writes are per-project locked and atomic, so five<br> agents checkpointing at once never corrupt it.</p>
<p><strong>Read — deterministic, not model-chosen.</strong> At the next dispatch a fixed slice of that memory is<br> injected as a read-<em>floor</em> before the agent does anything — it doesn't get to decide whether to<br> look. So it opens already knowing the state of the project instead of spelunking for it.</p>
<p><strong>Trim — less is actually more.</strong> This is the counter-intuitive one. There's a hard byte budget<br> before the model effectively stops absorbing the injected context; push past it and recall gets<br> <em>worse</em>, not better. So the trim is ruthless and line-keyed — a lossless mechanical floor with a<br> model-driven condense above it — and everything it evicts goes to a permanent, searchable<br> archive that is <strong>never</strong> deleted. Cold storage, not a trash can: the working memory stays small<br> and sharp, and nothing is actually lost.</p>
<p>Two things I got wrong first, and they share a root cause. One: letting the agent freely rewrite<br> its own memory — it drifts, bloats, and within a week you can't trust a line of it. Two: assuming<br> more memory is better — see the trim. Both mistakes come from treating memory as a scratchpad the<br> <em>agent</em> controls, instead of a managed store the <em>control plane</em> keeps on the agent's behalf.<br> Once I flipped that ownership, the whole thing got trustworthy.</p>
<h3> Standing agents: work that outlives the chat window </h3>
<p>Once the control plane supervises processes and remembers state, a new thing becomes possible:<br> agents that keep working after you close the tab. A scheduler fires tasks on a cron; a<br> "charter" agent gets a standing goal and works it unattended in reversible steps, asking before<br> anything irreversible. That's the payoff of not being the message bus anymore — the work keeps<br> moving while you're away from the keyboard, and you check in from your phone to answer the one<br> thing that actually needs you.</p>
<h3> The harder one: agents that are each other's blind spot </h3>
<p>The question people ask next is "can two agents work on the same project at once?" The shallow<br> version is about file conflicts, and it has a boring answer: give each agent its own git worktree<br> and branch, and merge at the end.</p>
<p>But the interesting version isn't conflict — it's <strong>coordination</strong>. Two agents working on<br> <em>interdependent</em> features, not the same files. Agent B rebuilds something Agent A just finished.<br> Agent B picks an approach that A's just-landed change made obsolete. They never touch the same<br> line; they're just blind to each other.</p>
<p>And here's the trap: <strong>worktree isolation makes that worse.</strong> Isolation buys edit-safety by<br> putting each agent in its own checkout, so a sibling's landed commit is <em>less</em> visible, not more.<br> Safety and awareness pull in opposite directions, which means you can't design one without the<br> other.</p>
<p>I sat down to design this properly and the useful surprise was that the missing piece wasn't<br> infrastructure — it was <em>delivery</em>. The substrate was already there: a durable append-only<br> message bus, a shared knowledge store, SSE fan-out, a reconciler daemon pattern, and the same<br> read-floor injection the memory system uses. What didn't exist was the semantics on top: agents<br> publishing <strong>intentions</strong> ("about to touch the SSE slot manager") and <strong>completions</strong> ("landed<br> it, sha abc123, these paths"), and — the hard part — those events being <em>pushed</em> into a sibling's<br> context while it's mid-work, instead of sitting in a bus nobody polls.</p>
<p>So I built it. Two pieces, and they have to exist together.</p>
<p><strong>Awareness.</strong> Agents publish intentions and completions to a per-project bus, and the server<br> does the publishing <em>on their behalf</em> wherever it can — deriving a completion from a commit's<br> touched paths rather than trusting an agent to remember a protocol. Siblings receive it as a<br> <code>SIBLING ACTIVITY</code> block in the read-floor, ranked by <strong>target-overlap</strong>: how much what you're<br> touching intersects what they're touching. The gate matters more than the mechanism — an<br> unfiltered firehose just teaches agents to ignore the section, exactly like a too-chatty<br> notification. Top-3, overlap-ranked, live agents only.</p>
<p><strong>Isolation.</strong> Each concurrent agent gets its own git worktree and branch, with merge-back on<br> completion. The scoping detail I like most: <strong>only the 2nd+ concurrent agent is isolated.</strong> A<br> project with one agent — the overwhelmingly common case — pays nothing at all. Complexity that<br> only materializes when you actually need it.</p>
<p>Two rules ended up load-bearing, both learned the boring way: <strong>conflicts escalate and are never<br> auto-resolved</strong>, and <strong>automatic cleanup must never destroy an agent's output.</strong> An orchestrator<br> that silently eats work it can't reconcile is worse than no orchestrator, because you stop being<br> able to trust any of it.</p>
<p>The thing I got wrong first was a race in the coordination state file. Two threads — the<br> awareness daemon and a dispatch — both did read-modify-write on the same JSON, and dedup records<br> silently vanished. Classic, and worth saying out loud because "agents coordinating" sounds like<br> an AI problem and the actual bug was a missing lock and a non-atomic write. Most of this work is<br> ordinary systems engineering wearing a novel hat.</p>
<p>What's still off: the third delivery rung. A sibling landing a change on paths you declared<br> intent to modify <em>could</em> interrupt your turn — the mechanism is built, but it ships <strong>default-off</strong><br> until I've watched the quieter rungs behave. Interrupting a working agent is the kind of feature<br> that's very easy to regret, and I'd rather earn it with evidence than assume it.</p>
<h3> What's still open: agents that actually finish </h3>
<p>Here's the part I <em>haven't</em> solved yet, and it might matter most. Right now an agent hands the<br> turn back to you too eagerly — it hits the first fork or the first thing it's unsure about and<br> bounces the decision up. When you're supervising five of them, that's the message-bus problem<br> sneaking back in through the side door: you're no longer routing tasks, but you <em>are</em> the<br> completion-checker for every half-finished turn.</p>
<p>The direction I'm building toward is an agent that pushes to genuinely <em>complete</em> a task —<br> exhausts the reversible, obvious steps on its own and only comes back when it hits something that<br> truly needs a human: an irreversible action, a real ambiguity, a missing decision. Not reckless<br> autonomy — <em>bounded</em> autonomy, with the same "ask before anything irreversible" rule the standing<br> agents already follow. A finished turn you can review beats a half-finished turn you have to<br> unblock. It's not shipped yet; it's the next thing on the list, and I think it's the difference<br> between "many agents you babysit" and "many agents that actually carry work."</p>
<h3> The honest limitations </h3>
<p>It runs on your machine, so if the machine sleeps, the agent sleeps — and it tells you it did.<br> There's no always-on hosted tier, on purpose: I didn't want to sit in the middle of your API<br> traffic or your data. It's local-first, and that cuts both ways.</p>
<h3> Where this lives </h3>
<p>I bundled all of the above into an open-source tool called <strong>Clayrune</strong> (MIT). If you want to<br> see the shape without installing anything, there's a click-through demo:<br> 👉 <a href="https://clayrune.io/demo" rel="noopener noreferrer">https://clayrune.io/demo</a> — and the source (Flask, the subprocess supervisor, the memory<br> pipeline) is at <a href="https://github.com/ronle/clayrune" rel="noopener noreferrer">https://github.com/ronle/clayrune</a>.</p>
<p>I started this by saying that with many agents you become the message bus. The control plane<br> fixed the obvious half — I'm no longer the thing routing tasks and remembering state. The<br> coordination layer took a real bite out of the harder half: the agents aren't wholly blind to<br> each other anymore, so cross-agent context doesn't have to route through my head.</p>
<p>But I want to be careful not to overclaim, because the interesting part is what <em>didn't</em> get<br> solved. Agents now know what their siblings are doing. They're still not very good at deciding<br> what to <em>do</em> about it — "defer, adapt, or ask" is a judgment call, and right now that judgment is<br> a paragraph of context and a hope. The awareness plumbing turned out to be the tractable half;<br> the reasoning on top of it is wide open. And the related problem I mentioned earlier — agents<br> that push a task to genuine completion instead of bouncing every fork back at you — is still<br> ahead of me.</p>
<p>So the direction I'd point at isn't "run more agents." It's that the bus should terminate at<br> other <strong>agents</strong>, not always at a human — and then those agents should be good enough at reading<br> it that you don't have to referee. The first half is buildable today. The second half is the<br> actual frontier.</p>
<p>If you're running more than a couple of agents, I'd genuinely like to hear how you're handling<br> it — because I don't think one dashboard is the last word on this.</p>