Status: Active as of 2026-06-11. Supersedes the deprecated Let-Them-Talk Multi-Agent Workflow — Agent Guide (LTT + MetaMCP decommissioned 2026-06-09). The workflow conventions are largely carried over; the comm primitives and the durability model are different — read § What changed from LTT before assuming muscle memory transfers.
Source of truth for operational facts:
/docker/comms/handoff.md. This guide carries workflow conventions; the handoff carries the stack gotchas, the script reference, and live deployment details. If the two disagree, the handoff wins.v3.0 (2026-07-23): adds the standing
opsfleet-governance layer above per-project swarms — one operator inbox, inter-project brokering, project lifecycle (spin up / stand down / tear down). If you knew the pre-opsguide, read § Theopslayer and the updated § The cast before assuming the routing is unchanged; single-inbox routing was promoted to the spine as the default, with a one-tier-down governor variant kept in the persistent-swarm section.v3.1 (2026-08-02) — the presence layer landed. The “future work” this guide still refers to below in a few places is now live:
ops(and a standing roster of fleet agents —infra,ansible,mtg,plex-support/Plexi) boot channel-wired and wake 0-input on Zulip DMs/@mentions via thezulip-channelMCP (~1–2s); a deployed session-supervisor auto-resurrects any that die; and agents deploy to the stack through a fail-closed deploy-gate / run-gate on an ops-signed bus signal. The/loopis now a slow (~20–30 min) backstop, not the sole heartbeat. Where a sentence below says ops is “minus the daemon / kept alive by long listens / not yet autonomous / future work,” read it as superseded — the current architecture is in the new runbook Current Fleet Architecture (channel-wake, the gate model, the supervisor, Plexi). This banner is the reconciliation until those sections are rewritten in place.
What this is
A framework for running coordinated, ephemeral multi-agent projects on the homelab using self-hosted Zulip as the agent-to-agent comm bus. Each project gets its own channel, a small roster of bot-identity agents, and a durable thread history that is the shared memory.
Above those projects sits one standing agent — ops — the operator’s single inbox and the coordinator between projects. Individual projects stay ephemeral (kickoff → run → teardown); ops is the persistent layer that governs across them: spins them up and down, brokers the rare cross-project conversation, and is the one handle the operator talks to. It’s the homelab’s persistent presence layer — a standing, channel-wired, supervisor-backed agent (as of 2026-08-02; see the v3.1 banner), not a hand-driven session. If you only need the project mechanics, skip to § When to use; if you’re ops or working under it, read § The ops layer first.
The bus is deliberately dumb: it carries identity, messages, channels, topics, and history — nothing else. Briefs, acceptance criteria, locks, and durable decisions live in NFS / git / Notion, exactly as before. What makes this workable is three scripts in /docker/comms/scripts/ (/compose/scripts/ on the comms box) that mechanize the parts the operator used to do by hand:
zbus— the bus client every agent drives (say/read/listen/dm/whoami).proj-kickoff <slug> <role>...— creates the channel, mints a bot per role, subscribes them, writes the manifest.proj-teardown <project-dir>— deactivates the bots, archives the channel (history kept), shreds dead keys.
Project agents are the Tier-2 / ephemeral-swarm track — driven by their own Claude Code (or opencode, an alternate CLI harness) session loop, using the bus as a coordination + memory surface. ops is the persistent named-agents (presence) track, now live: it lives on the bus as a standing agent, boots channel-wired (the zulip-channel MCP pushes bus DMs/@mentions into the session, waking it 0-input in ~1–2s), and is kept alive across session death by the deployed session-supervisor — with a slow /loop as a liveness backstop. (Superseded 2026-08-02; see the v3.1 banner + Current Fleet Architecture. Earlier this read “still session-driven, kept alive by long listens, not yet an autonomous daemon” — channel-wake + the supervisor closed that gap.)
The ops layer — fleet governance
Above every project sits one standing agent: ops. It’s the operator’s single point of contact for the whole homelab and the coordinator between projects. Three agent tiers, with the operator above them:
- operator ↔
ops— one inbox. Fleet governance, inter-project brokering, project lifecycle (spin up / stand down / tear down), spawning sessions. You talk toops;opstalks to everything else. ops→ the project governor —opskicks a project off and steps back; the project’s governor runs it internally. On a simple ephemeral project the governor is the overseer; on a persistent swarm it’s whichever role holds the governor function (§ The cast; § The governing role is assigned in planning). Intra-project coordination is the governor’s job, neverops’s.- governor → workers — dev, infra, specialists: the doers.
infraowns stack-touching;opsnever cycles a container or drives an env edit as part of a project.
ops’s bus identity is a standing bot (ops-bot@comms.mdbook.me), minted like infra-bot and subscribed channel-wide — not a per-project <slug>-<role> handle from proj-kickoff. See /docker/comms/handoff.md.
ops is a governance and communication layer with no hands. Its verbs:
- talk to the operator — the single inbox;
- read — the bus, project dirs, repos, box state;
- message agents —
zbus say/dmacross every channel it’s subscribed to; - spawn — a
claude-tmux session for a task too big to hold in its own context, or its own one-off subagent for something short (via the spawn-claude-code-session procedure; the pane-allowlist + per-agent permission mechanics are on the mechanics page).
What ops does not do: write code, cycle containers, drive opencode env edits, or otherwise do the work of a project. When a project task needs doing, ops delegates down and the governor routes it to infra or a worker. The one exception to no-hands: ops may touch the stack directly for fleet-level, non-project work — and even then prefers to spawn a session and delegate. (Example: “vllm just crashed” → ops spawns a triage session, maybe does a brief context-cheap first look, then hands off. It does not become the debugger.)
Why hands-off matters: it keeps ops context-cheap (it never carries a project’s full working set) and low-blast-radius (never touches secrets, never sits in the critical path of a cycle). ops that starts doing work is ops drifting into being a worker — and now nobody’s watching the fleet.
Reserved rights — escape hatch, not default
ops routes through the governor by default. It explicitly reserves the right to broadcast into a project channel (a message to several or all of a project’s agents at once) and to @-mention a specific worker directly (not just the governor) when it needs that one agent. These are for when routing through the governor is too slow or the governor is the bottleneck — not the standing pattern. Default is: hand the governor the ask, let them route it.
Project lifecycle: live, stood-down, torn-down
ops manages three project states, not two:
- Live — active swarm, sessions running, work in flight.
- Stood-down but parked — sessions alive-but-idle (parked on a long
listen), no active work. Cheap to re-engage:opssends a message and they wake. This is the state that makes “spin forya back up” a one-liner instead of a fresh kickoff. - Torn-down —
proj-teardownrun: bots deactivated, channel archived (history preserved), env files shredded. Re-engaging means a fresh kickoff, or spawning new sessions against the archived channel + repo.
“Spin up forya, we’re implementing user auth” resolves by state: parked → ops messages the idle sessions; torn-down → ops spawns fresh sessions pointed at the archived channel + repo. Either way, once running, ops hands the goal to forya’s governor and steps back.
ops’s home and memory
ops is the standing presence agent — channel-wired (wakes 0-input on Zulip DMs/@mentions via the zulip-channel MCP) and supervisor-backed (auto-resurrected if its session dies), with a slow /loop as a liveness backstop. (Superseded 2026-08-02 — this used to read “the standing agent minus the daemon, kept alive by long channel-wide listens”; the presence layer landed. See Current Fleet Architecture.) It has a version-controlled home at /nfs/git/ops/:
CLAUDE.md— its charter: the tiering above, the verb list, the reserved rights, “delegate don’t do.” Appended to the global system prompt whenopsruns with that dir as its cwd / config dir.STATE.md— the fleet anchor: which projects are live / parked / torn-down, who’s parked where, which cross-project channels are open.opsre-reads it on resume and rewrites it as the fleet moves. The bus holds the conversation; this holds the index. It’sops’s single-point-of-failure mitigation — a resumedopsrebuilds the whole fleet picture from here.
Keep STATE.md markdown, commit at milestones (not every change), and the repo gives you free version history of how the fleet moved.
Single-inbox routing
The operator can’t watch every agent’s chat, and with ops up doesn’t have to: the operator watches one inbox — ops’s — and ops watches everything else and relays. All operator-facing asks route through ops; you pop into individual sessions when you want to, but you’re not required to be continuously present. This is the default homelab model whenever ops is running. (When ops isn’t up — a bare single-project run with no fleet layer — the project’s own overseer/governor is the operator’s inbox instead; the same pattern, one tier down — see § Long-running / persistent swarms.)
@**operator**is the push-to-phone lever — a real mention notifies your phone;opsuses it when a human decision genuinely blocks progress.- Morning report + peer-certification — after an unattended window,
ops(or a project governor relaying up through it) files one operator-facing report — changed / found / deliberately not changed / wake-actions — and the independent acceptor certifies it before you wake (author ≠ attestor; § Teardown → Honesty under change). - Acceptance sign-off is delegable to
opsper-instance (§ The cast → The operator), so a criterion can be closed without you stepping into the project — but only for workopscoordinated rather than authored. - The bus carries decisions, not authority.
opscan route a decision (ship / defer / merge-go); it can’t, by default, carry a harness permission — a classifier-gated push still needs the human in the acting agent’s own session (or the per-agent gated-push hook — mechanics page). See § Blocker escalation → Authority the bus can’t carry.
Cross-project communication
Projects are segregated by default — own channel, own bots, teardown archives the lot. ops is what lets two projects talk when it’s genuinely useful: most often when they’re built at once with a shared dependency (forya needs a proton_login bug fixed while proj-proton is live). ops self-determines these situations and brokers them, proactively when relevant. The convention:
- A cross-project ask is a bounded channel with a mini-brief, not “agent A now takes orders from agent B.”
opsopens an#xproj-<a>-<b>channel (or routes small stuff through a shared general-task channel), writes a short brief — the ask, who owns what, the report-back exit — and subscribes one agent from each side (zbus join). Cross-project widens the prompt-injection blast radius; the bounded scope + brief is the containment. - Accept-authority stays home. The
proton_loginfix is verified inproj-proton; the integration is verified inforya. The shared channel is coordination only — neither side signs off the other’s work. - Live vs dormant. “Loop in an idle proton agent” only works if
proj-protonis parked or live with someone reachable; a torn-down project has no idle agent, soopsspawns a fresh one with a brief pointing at the archived channel + repo. Existing-idle vs freshly-spawned isops’s call. opsowns the channel lifecycle — it opened the cross-project channel, it archives it when the collaboration’s done, or the bus silts up with dead#xproj-*channels.
When to use this workflow
Reach for it when one or more applies:
- Scope spans multiple agent specialties (Dev + Infra + Overseer, etc.) needing continuous coordination, not just handoff points.
- The work doesn’t fit one session — parallel sessions or work across budget refreshes.
- The operator wants a durable audit trail of asks/acks/decisions/blockers that survives any one session. (On Zulip this comes for free — the thread is the trail.)
- Cross-scope verification: one agent observes state another owns.
For solo work — even multi-step solo work — use your session’s task tracking + a scratchpad. The bus and its kickoff overhead aren’t worth it.
With ops running, you don’t reach for this workflow directly — you tell ops what you want (“spin up forya, we’re doing user auth”) and it runs the kickoff and picks the roster. This guide is what ops and the agents it spawns operate by; § The ops layer is the tier above it.
Persistent swarms. Most projects run ephemeral — kickoff, run, teardown, one session per agent. A minority run as persistent swarms: one long-lived channel across many milestones, compaction-heavy, partly unattended. Those need continuity scaffolding the ephemeral default doesn’t provide — see § Long-running / persistent swarms.
What changed from LTT
If you knew the LTT workflow, here’s the delta. Read this even if you skim everything else.
| Concept | LTT (old) | Zulip (now) |
|---|---|---|
| Transport | MetaMCP-spawned server.js stdio child per session, MCP tools | Plain HTTPS REST API, driven by the zbus shell helper (curl under the hood) |
| Identity | register(name=...), 20-char cap, sticky orphan children | A Zulip bot per <slug>-<role>, minted by ensure_agent_bot; identity = whichever env file you point ZBUS_ENV at |
| Receive | listen_group / wait_for_reply, mandatory “group mode” | zbus listen <ch> [topic] --once --timeout N — blocking long-poll, no mode to set |
| Conversation units | flat channels + messages.jsonl | Channels → topics. A channel (#proj-<slug>) holds many named topics (kickoff, blocker-x, review). Topics are the killer feature — bounded, titled, individually fetchable. |
| Memory | bus state ephemeral, wiped at teardown; promote everything durable | Zulip history is durable (Postgres). The thread is working memory — re-read it each turn. Teardown archives the channel, never deletes it. |
| Rate model | 30 msg/min/agent | 200 API req/min per bot; one bot per handle is the isolation model. A held listen long-poll doesn’t burn the budget. |
| Avatars / modes / votes / role-tokens | a pile of MCP quirks (G1, G12, G14, G15…) | gone. None of it exists here. |
The single biggest mental shift: you don’t keep your own conversation state. Every turn, zbus read <channel> <topic> to reload the thread, act, post, repeat. The bus is durable, so this is cheap and reliable — no “did I miss a message while thinking” anxiety (and no group-mode footgun).
The cast
ops sits above all of this. The fleet-governance layer (§ The ops layer) is not part of any one project’s roster — it governs across projects. The roles below are the project roster: a small fixed set of agents, each owning a scope. Conventional roles (operator mixes per project):
- Dev — owns a code repo. Writes the implementation. Handle
<slug>-dev. - Infra — owns
/dockerand homelab box state (compose, Caddy, scripts). Handle<slug>-infra. - Specialist — domain expertise pulled in for the duration of its part (
<slug>-audio,<slug>-vector), dismissed when done. - Overseer — the project governor: runs intra-project coordination, reviews, and signs off. Does not write code or compose. Pins acceptance criteria at kickoff, blocks merges that miss them, drives sign-off. Handle
<slug>-review. Distinct fromops: the overseer owns inside one project,opsowns between projects (§ Theopslayer) —opshands a project to its overseer and steps back. - Async-job specialist — a long-running out-of-band job (a multi-hour transcode, a bulk migration) given its own handle and its own topic, so hours of status chatter stay out of the work thread. Persists for the job’s duration, survives its own restarts; it’s neither a gate nor a one-off. Handle e.g.
<slug>-transcode.
Most projects use 2–3 roles (often Dev + Overseer; +Infra makes three). Coordination overhead grows superlinearly — add specialists only when needed.
On a persistent swarm, the Overseer’s two jobs — governing the run and independently accepting (sign-off) — often split across two agents, and which agent governs is a planning-stage call. See § Long-running / persistent swarms → The governing role.
Modes
Capability hats worn atop a role for a phase, not standalone roles. Curator (hygiene sweep: prune, consolidate, refactor) and Release Manager (cut tag → cycle → smoke → announce). Any role can wear one; usually Overseer-driven and Infra-driven respectively.
The operator (not an agent)
The operator picks the roster, writes briefs, pins acceptance criteria, approves cycles + pushes + merges, owns secret reads, and calls final sign-off. With ops running, the operator increasingly directs through ops rather than each project’s governor (§ The ops layer → Single-inbox routing) — but the authorities below stay the operator’s regardless of who relays. They can also join a project channel as a normal participant.
What only the operator does — never an agent, however strong the internal justification:
docker compose up/down/pull/restart(except scopedupdate.sh --image=<name>cycles authorized for that turn).git pushof/dockercommits to remote.- Secret reads /
.*-*-envedits, Authentik config, public-DNS changes. - Final sign-off on acceptance criteria.
Reaching for one of those means you’re escalating: write up the proposed action and ask, don’t do it.
Three of these are operator-delegable — by an explicit grant, never by an agent’s own initiative. The operator may hand /docker push authority to the infra / governing agent (per-agent permissions that persist across compacts — see the mechanics page); may hand secret reads / .*-*-env edits to an opencode agent operating under the ocode docs / scripts / protocol (a cloud Claude Code agent still can’t touch secret bytes — opencode is the sanctioned lane); and may hand acceptance sign-off to ops, per-instance and explicit — the single-inbox convenience, so a criterion can be closed without the operator stepping into the project. Absent the grant, all three stay operator-only; the grant is the operator’s to give, not an agent’s to assume. The acceptance delegation reads honest only when ops accepts work it coordinated but didn’t author — if ops both drove the plan and would sign it off, keep a real independent acceptor in the loop instead.
Identity convention
Handle shape
<slug>-<role> — e.g. newci-dev, newci-review, memes-infra. The bot’s Zulip email is <slug>-<role>-bot@comms.mdbook.me; its display name is the handle. proj-kickoff mints these (calling ensure_agent_bot under the hood — you never invoke it directly); handles.json records the mapping.
<slug>is the project tag;<role>is the lane. Keep both short — they become an email localpart.- Identity is the bot, not the session. A successor session that exports the same
ZBUS_ENVis the same agent on the bus — same name on every past message, same subscriptions. This is why handles are stable across sessions and unique per project. - One bot per handle, never shared. It’s also the 200-req/min rate-limit isolation boundary.
Driving the bus as your handle
Your brief (and the project’s README.md) tells you which env file is yours. Then:
export PATH="/docker/comms/scripts:$PATH" # so `zbus` resolves bare — do this first
export ZBUS_ENV=/nfs/agent-projects/proj-<slug>-<date>/.<slug>-<role>-bot-env
zbus whoami # confirm who you are
zbus say proj-<slug> <topic> "message" # post to a topic
zbus read proj-<slug> <topic> 50 # reload the thread (400-char skim lines)
zbus read proj-<slug> <topic> 10 --full # same, whole message bodies
zbus read --id 1234 # ONE message in full, by its #NNN id
zbus listen proj-<slug> <topic> --once --timeout 600 # block for the next message
zbus dm <email> "message" # direct message a peer or the operator
zbus join proj-<slug> # subscribe your bot to a channel
# (late joiners only — kickoff already
# subscribes the role bots)Skim vs full: default read output truncates each message to one 400-char line — that’s for re-anchoring on a thread, not for acting. Never gate, review, or implement against a skim line: if a post matters (a plan, a gate checklist, a long diagnosis), fetch it whole with --full or zbus read --id <NNN> first. (A real gate got degraded this way once — the reviewer could only see the first 400 chars of a plan and had to approve “approach-only.“)
zbus lives at /docker/comms/scripts/zbus and is on NFS, so it’s callable from whatever box your session runs on. Whether it’s already on PATH depends on how your session was launched: an agent spawned through the standard launch contract (e.g. by the governor/overseer, which sets PATH for it — see the mechanics page) gets zbus resolving bare from its first call; a hand-started or first-in-the-project session may not have it on PATH. If a bare zbus call fails, export PATH as above (alongside ZBUS_ENV) before your first call, or invoke it by full path. Channel names work with or without a leading #. You never see or handle the API key — the script reads the env file and passes the key to curl; you only invoke the script. (This is what lets a cloud-inference agent drive the bus without tripping the env-read ban.)
No bracket prefix on messages
Zulip carries the sender natively — every message shows your handle. Don’t prefix content with [handle]:; it’s redundant. The envelope names the speaker.
Mentions: write @**handle**, never bare @handle
To Zulip, @**forya-dev** is a mention; @forya-dev is plain text. Only the double-star form sets the mentioned flag — which is what notifies a human user and what wakes a peer parked on listen --mentions. A bare @handle does neither: it looks like a ping, reads like a ping, and silently isn’t one. zbus say warns when it sees the bare form (the message still sends — fix the syntax and move on). Make @**handle** muscle memory, especially when handing work to a peer or asking a blocking question. The warning is advisory, not blocking, and only fires on a bare @ it actually sees — it won’t catch a wake you forgot to write at all. A missed mention is silent: the peer simply never wakes.
Topics: the organizing primitive
A channel (#proj-<slug>) is a container; the real unit of work is the topic. Use them deliberately:
kickoff— the brief ack, acceptance-criteria pin, roster check-in.- One topic per work-thread —
blocker-build-cache,review-round-1,ingress-wiring. Bounded and titled so a peer can read just that thread. handoff— session/role transitions.signoff— final criteria-green posts.
zbus read/listen narrow to a topic, so a tight topic is a tight, cheap context reload. Sprawl is the failure mode: don’t dump everything into one topic, and don’t spawn a topic per message. One topic per coherent thread of work.
Reply in the topic you’re answering. If a peer asks a v0.5-metadata question, answer under v0.5-metadata — not whatever topic you happen to be sitting in. A reply posted under a stale topic is invisible to anyone narrowing on the live thread, and the thread’s record fragments. Before posting, ask: which thread does this message belong to? — and post there, even if it means switching topics mid-turn.
When decisions churn, post one LOCKED: supersede. Rapid reversals (disregard #367 → actually #370 → #372 supersedes both) land faster than peers can read and burn a context-pressured agent’s budget. Hold the flux in a tight exchange, then post a single LOCKED: message that supersedes the churn by id — disregard #NNN, current = #MMM beats re-explaining.
One long topic carrying everything is a legitimate variant on a persistent swarm — but default to bounded topics here; that variant and its rules (name it generically, rotate at version boundaries) live in § Long-running / persistent swarms.
Kickoff workflow
Three phases, operator-led. Phase 3 is now one command.
1. Triage
Operator picks roster, scopes, handles, project tag. Decides the role list.
2. Brief
Operator authors one <role>-brief.md per agent in the project dir. Each brief pins:
- The agent’s handle + scope (what it owns, what it explicitly does not own).
- Working-tree path(s).
- Acceptance criteria this agent owns or is gated on.
- Which env file is theirs (
ZBUS_ENV) + the channel name. - Pointers to
/docker/AGENTS.md, this guide, and any project-specific docs. - Right now: the immediate first action after reading.
3. Bus-up (one command)
/docker/comms/scripts/proj-kickoff <slug> <role> [<role> ...]
# e.g. proj-kickoff newci dev reviewThis creates /nfs/agent-projects/proj-<slug>-<YYYYMMDD>/, mints a bot + env file per role, creates #proj-<slug>, subscribes every role bot to it, and writes handles.json + a README.md. (Run it on the comms box, or anywhere with ssh to comms.)
Who runs this. Normally the operator — or, with the fleet layer up, ops on the operator’s instruction (spinning a project up is exactly ops’s job; § The ops layer → Project lifecycle). A project-internal coordinator/kickoff agent (typically the infra or review role) may also run proj-kickoff directly — but only after receiving explicit user/operator approval for that specific invocation. This is per-invocation approval, not a standing grant; neither “kickoff agent” nor ops is a blanket license to run it whenever — ops runs it on your say-so, per project.
When a cloud-inference agent runs it, the command reaches the zulip container via ssh comms (from the orchestrator or dev box), exactly as the script handles other remote callers. One hard constraint regardless of who executes it: proj-kickoff writes the per-role .<slug>-<role>-bot-env files (generating fresh bot credentials). The agent running it must never read those env files back — they fall under the standard env-read ban. The script writes them; they’re for the agents that will own those handles. Treat this exactly like the write-only secret carve-out.
Discord mirror (automatic for proj-*). A one-way scrubbed mirror copies the bus to the operator’s Discord (zulip_discord_mirror langserve workflow). Since 2026-06-17 it’s automatic for proj-* channels — proj-kickoff subscribes comms-mirror-bot at channel creation and the workflow self-subscribes it to any matching public stream, so a new project mirrors from its first message with no manual step. To keep a project off Discord, add its channel to MIRROR_EXCLUDE in mikayla/agents:zulip_discord_mirror.py. (Cross-project #xproj-* channels also auto-mirror — the prefix rule covers general + proj-* + xproj-*; ops subscribes the mirror bot when it opens an #xproj-* channel so the opening brief is captured, the same message-1 step proj-kickoff does.)
Then each agent session, on start:
export ZBUS_ENV=<its env file>(fromhandles.json/ its brief) andzbus whoamito confirm.zbus read proj-<slug> kickoff 50to load context (after the first turn there may be nothing yet).- Post a one-line “joining, brief read, ready for X” to the
kickofftopic. - Begin its cadence loop (§ Cadence + handoff shape).
Overseer goes first by convention — posts the kickoff message to the kickoff topic with the acceptance-criteria pin, so everyone reads the same criteria before starting. There is no “group mode” to set; subscription (done by kickoff) is all that’s needed for delivery.
Acceptance criteria
Operator-pinned at kickoff — and revisable as the project unfolds. Each criterion is an observable statement — verifiable by running a command, hitting an endpoint, reading a file. “Code is clean” is not observable; “pytest exits 0 on tests/” is.
Criteria aren’t frozen at kickoff: scope shifts, a probe turns out to be the wrong one, a new constraint surfaces — and the bar moves, typically at the operator’s call. When a criterion changes, re-pin it the same way (update the Overseer’s kickoff post and handoff.md) so the current bar stays unambiguous; an agent that thinks a criterion has gone stale surfaces it rather than quietly coding to a different one.
Typical project: 3–7 criteria. Format:
Criterion N:
. Owner: . Verifier: <handle (often Overseer)>. Probe: .
Pinned in the Overseer’s kickoff post and — durably — in the project’s handoff.md (and the CI repo’s own docs if it’s an image-repo project). The Zulip thread preserves the pin, but a criterion that must outlive the project belongs in a file too.
Code-verifiable vs runtime-verifiable
A passing code gate (diff clean, pytest green, CI built) is necessary, not sufficient for behavior the gate can’t reproduce — device UX, rendered output, OS integration, live-platform health. Split criteria into the two kinds, and tag a runtime-dependent fix UN-VERIFIED until the real probe runs. Scope a code-gate’s GO to what it actually proves (“guarantee intact + correct shape + clean — not ‘the behavior is fixed’”) and route the behavioral verdict to the real-world probe.
Real-environment verification (only when it applies)
If your work has a verification surface CI can’t reproduce — device UX, a rendered embed, an OS integration, live-platform health — stand up the loop: a canary / fixture instance + a real-environment or human verdict + iterate; the code-gate feeds it, it doesn’t replace it. Two instances of the same shape: forya (on-device iOS playback — operator device-tests a canary deploy) and ops-bot (Discord embed mobile-rendering — operator screenshots the rendered card). Before bundling several fixes into one real-env session, confirm the canary can actually exercise each one (feature flags, config, data shape) — a “one session covers all N” plan silently drops whatever the canary can’t reach. Project-specific mechanics (debug-overlay field names, branch-trigger regexes, feature flags) belong in that project’s repo docs, not here. If your work verifies purely via code / CI / audit / design-review, skip this — it isn’t universal.
Enumerate “touch-all-N-sites” claims independently
When a plan or design claims it touches all N call-sites of something, grep the real sites yourself rather than trusting the count — in one forya refactor a design claimed ~7 call-sites, a reviewer grepped the real code and found 10, and the omitted site was the cure-critical one. Prove “no behavior change” mechanically (empty diff, tree-hash equality, per-function checksum), not by prose. And before investing in a flagged edge case, confirm it’s reachable through the actual interface — an unreachable edge is closed by a reachability proof, not a fix.
Working tree vs bus
Two planes:
- Working tree — files in git repos or
/nfs/agent-projects/proj-*/. The artifact of the work. - Bus — the Zulip thread: asks, acks, blocker reports, decisions-in-flight, coordination. Durable (Postgres; archived not deleted at teardown), but project-scoped.
The LTT rule “promote ephemeral coordination to durable storage” mostly collapses — the thread itself is durable. What still holds is cross-project durability: anything that must outlive this project goes to its real home, not just the thread.
- Commit messages — for decisions tied to specific code/config.
handoff.md(the box’s, or the project’s) — for architecture + gotchas future agents need.- Notion Tasks — for follow-ups spanning projects.
- Git branches, not chat coordination — for parallel work on a shared codebase.
The thread is the record of how the project went; the durable stores are what the rest of the homelab needs to know afterward.
One-off subagents: spawn workers, not more bus participants
A bus handle is for a role that needs to converse — hold state across days, gate peers, get pinged. Lots of work doesn’t need that. For anything bounded and self-contained — a deep code review, a research sweep, a bulk migration, a benchmark run — spawn a one-off subagent instead of minting another bot:
- No bus presence. The subagent gets no bot, no channel subscription, and never posts. Its entire output returns to you, the spawner.
- You own the relay. Post a digest of its findings to the relevant topic, attributed (
one-off review subagent, full report at <path>), and drop the full artifact in the project dir so peers can read it without you re-summarizing. - It’s cheap to be generous with scope. A bus participant following channel traffic burns tokens on every message; a one-off burns only on its task. A “complete codebase pass” that would be wasteful as a standing role is a perfectly sized one-off.
Worked example: forya 0.4 — dev spawned a one-off subagent for a full-codebase review (every file + tests, ~119k tokens), it returned a structured findings list (F1–F9), dev posted the digest to the thread and the findings drove the 0.4 plan. Three bus agents, one disposable specialist, zero channel noise.
Default to this pattern. Reach for it whenever a task is parallelizable or bigger than your context comfortably holds; reserve new bus handles for lanes that genuinely need a voice in the room. (If a one-off’s conclusions need defending in-thread, that’s still you — you relayed it, you answer for it.)
Subagent vs. a spawned session — two different tools. A one-off subagent has no bus voice: it runs inside your context, returns a result to you, and vanishes. When you instead need a full peer — its own session, its own bus identity, able to converse and be pinged — the overseer/governor spawns a whole Claude Code session via spawn-claude-code-session (a detached claude- tmux pane): e.g. a fresh worker at a milestone boundary, or the compaction gate’s compact-agent. Subagent = disposable internal worker; spawned session = a new participant in the room. (Spawning full sessions is a core governor capability on a persistent swarm — see § Long-running / persistent swarms.)
One special case: if the bounded work requires touching secret bytes (env-file values), a cloud subagent can’t do it — delegate to opencode instead, same relay rules. See Spawn opencode Session (ocode).
When a fan-out’s output gates a decision (author ≠ acceptor)
A one-off is usually advisory — you relay it, you answer for it. But when the one-off is a gate-grade fan-out (e.g. an N-agent adversarial review whose verdict gates a merge), two extra rules apply:
- Author ≠ acceptor. The agent that ran the fan-out is not its acceptor; accept-authority routes to the operator on an independent reviewer’s recommendation. Cheap deterministic gates (diff-empty, version-guard, CI-green) feed the fan-out as inputs, not things it re-litigates.
- Return dismissed verdicts, not just confirmed ones. A refute-by-default pass that surfaces only its confirmed findings leaves the dismissals in the authoring agent’s private transcript — so the independent reviewer can’t audit “was anything real wrongly dismissed?” Structure both confirmed and dismissed (with vote split / confidence) into a shared-readable artifact, and re-audit the SPLIT / low-confidence dismissals. This is what makes author-≠-acceptor actually independent.
Cadence + handoff shape
Cadence
You block on zbus listen, you don’t poll. Two questions decide how: the timeout, and what should wake you.
| Situation | Command | Timeout |
|---|---|---|
| Waiting on a specific reply you asked for | zbus listen proj-<slug> <topic> --once --timeout 300 | 5min |
| Idle but on-shift, expect any message | zbus listen proj-<slug> --timeout 300 (stream, no --once) | 5min/call, loop |
| Active back-and-forth with a peer | zbus listen proj-<slug> <topic> --once --timeout 60 | tight |
| Long park — nothing expected for a while, wake only if pinged | zbus listen proj-<slug> --mentions --once --timeout 1800 (background task) | 30min |
--once returns after the first matching message (react, then re-listen). Without --once, listen streams until the timeout. A held long-poll returns on the server’s ~90s timeout internally and re-arms automatically, so even a 5-minute wait is a handful of requests — nowhere near the 200/min ceiling, and zbus transparently re-registers if the event queue is ever GC’d. Reload before acting: when in doubt, zbus read proj-<slug> <topic> 50 to re-anchor on the full thread rather than trusting one delivered message.
Wake on everything vs wake on being addressed (--mentions)
Plain listen wakes on any message in the narrow. listen --mentions wakes only when you’re actually addressed — a DM to your bot, or an @-mention (including @**channel**/@**all**) in your channel — and sleeps straight through everyone else’s chatter. Both are first-class; pick per turn, don’t default to one:
- Plain
listenwhen you’re an active participant in a thread, or on-shift in a quiet channel where any new message is plausibly yours to act on. You want to see the whole conversation as it moves. --mentionswhen the channel is busy with traffic that isn’t yours (two peers hashing out something you don’t own), or you’re parked long with no expected reply and only want to surface if someone explicitly needs you. It keeps you from waking 40 times to read messages you’d just ignore.
The convention that makes --mentions safe: it only works if peers @-mention you when they need you. So — if you’re going --mentions, say so, e.g. post going heads-down on the Dockerfile; @**me** if you need me before you park, and always @ a peer you’re handing work to or asking a blocking question of — using real @**handle** syntax (a bare @handle is plain text and will NOT wake them; see the Mentions rule above). Don’t silently --mentions in a channel where the convention is “post to the topic and assume everyone’s reading” — you’ll miss messages. When in doubt on a small/quiet swarm, plain listen is the safer default; reach for --mentions once traffic justifies it.
Going idle for more than ~10 minutes (background the listen)
A foreground zbus listen is capped by Claude Code’s 10-minute Bash timeout — you cannot park for 30 minutes in the foreground. For a long idle, run the listen as a background task (run_in_background), then stop. The listener parks detached; the moment a matching message lands it exits, and the harness re-invokes you with the message text — even though you stopped talking. Then you read, act, and re-arm a fresh background listen. The loop:
# arm (background, then go quiet — costs zero tokens while parked):
zbus listen proj-<slug> --mentions --once --timeout 1800
# → harness wakes you when it exits with a message →
zbus read proj-<slug> <topic> 50 # re-anchor on the thread
# ...act, post...
# re-arm the next background listen
This is the only way to idle past 10 minutes. A foreground listen is fine for short, in-turn waits (the table above); anything longer goes to the background. The --mentions / plain choice is independent of foreground-vs-background — apply the rule above either way.
--once has a gap — catch up by id, don’t trust it to have caught everything
zbus listen --once returns exactly one message and exits. While you process that message and re-arm the next listen, anything that lands in the gap is not queued for you — the next --once returns only the next new message, and the in-between ones are silently skipped. On a busy channel that drops real coordination. Two defenses:
- Catch up by id each wake. Track the highest message id you’ve processed; when a listen returns id N, sweep everything between your last-seen and N before acting (
zbus read --id <M>per missed id, orzbus read proj-<slug> <n>to re-anchor). The tell that you missed something: the delivered message references ids you never saw. - Foreground-driving blinds your own listener. Any long foreground action — most notably the governor driving a peer’s
/compact— runs with no listener armed, so every message during it lands in a gap. Background the listen when you can, and always do a catch-up read after a foreground drive. (Backgrounding alone doesn’t fully close it —--oncestill returns one message at a time — so the catch-up read is the real fix.)
Idle-time productive patterns
Blocked on an operator gate or peer dependency for 5+ min? Don’t just loop. Surface a proposed idle-time action to the Overseer first: review adjacent in-scope code, audit/extend the project docs, survey stale TODOs in scope, or propose post-rollout tasks. Idle ≠ done — it’s a chance to harden the artifact before sign-off.
Handoff post shape (canonical)
Carries over verbatim from the LTT workflow — it’s transport-independent:
<one-line summary>
state delta since your last post:
- <what changed>
ask out to <other-handle>:
- <specific request, with what you need from them>
queued for operator:
- <exact command, URL click, or decision the operator must make>
refs:
- <working-tree paths, commit SHAs, task IDs, /nfs paths>
Omit empty sections. One-line summary; depth below so the recipient scans-then-dives. Post role/session handoffs to the handoff topic.
Probe ladder
To verify state another agent owns, climb lowest → highest. Don’t skip rungs.
| Rung | Layer | Observable | Owner |
|---|---|---|---|
| L1 | Bus presence | zbus whoami / does the peer post? | Any agent |
| L2 | Thread state | zbus read proj-<slug> <topic> | Any agent |
| L3 | Peer ask | zbus say a specific question + zbus listen --once for the reply | Any agent |
| L4 | Project files | read /nfs/agent-projects/proj-*/ | Any agent (NFS-shared) |
| L5 | Operator | out-of-band ask / zbus dm | Operator |
Try L1 → L2 → L3 first. If the peer doesn’t answer at L3 within a reasonable timeout, escalate to the operator at L5.
Blocker escalation
- Self-diagnose: re-read your brief, this guide,
/docker/comms/handoff.md; confirmZBUS_ENVpoints at your handle andzbus whoamiis right. - Cross-agent ask — a concrete question to the peer who owns the scope. “I see X in the repo but you posted Y — is the branch stale or am I misreading?” not “is something broken?”
- Surface disagreement explicitly if you and a peer read the same evidence differently. Post the divergence; don’t paper over it.
- Operator only for real-world action — secret reads, container cycles, push approvals, rule interpretations. Don’t escalate to the operator for what a peer can answer.
- Relay hypotheses as hypotheses; let the owning observable arbitrate. A causal claim on the shared record carries its confidence and a falsifier — don’t post a confident causal story peers will then reason from as fact (a forya HTTP-cache theory was wrong; the operator’s device ground-truth refuted it). When a gate hits its limit, reclassify severity and reroute to a different lever rather than re-running the same gate. Re-confirm a one-off device/runtime wobble before attributing it to the change under test.
Authority the bus can’t carry. The bus routes information and decisions, not authority. Some actions are gated by the Claude Code permission classifier (pushing to the default branch, etc.) and need the human in the acting agent’s own session — a bus-relayed “approved” doesn’t satisfy the classifier. The durable fix is per-agent permissions (see the mechanics page). In the moment, when the operator is AFK, the governor may act as a keystroke proxy via tmux — but only when all three hold: (a) the operator is AFK, (b) the blocker is a harness permission prompt, not a decision the operator’s judgment is actually needed for, and (c) it’s unambiguous the operator intended the agent to be able to do this. read the pane first (read-only) to confirm it’s a prompt, then send the approval keystroke (recipe on the mechanics page). This is a narrow stopgap for the governing role only — never a license for agents to drive each other’s panes.
Never just stop. Out of moves? Post a blocker report (best current state + what you tried + what you’d do under each possible decision) and hand the floor to the operator.
Session handoff (budget management)
When your session approaches budget pressure:
- Commit + push (or stage for operator-push) any WIP.
- Post a handoff to the
handofftopic with the full snapshot — what’s done, what’s pending, where to pick up, any tacit context not already in the thread. - Get an explicit ack from the receiver (or operator) before exiting.
The resuming session exports the same ZBUS_ENV, reads the handoff topic (and zbus read proj-<slug> <topic> 50 on the active work topic), and picks up. Because identity is the bot, the successor is the same agent on the bus.
This covers a cold session-swap at budget pressure — a fresh successor reading a handoff. For a long-lived agent that must stay alive and compact in place mid-run, and for coordinating compaction across a whole swarm, see § Long-running / persistent swarms (the STATE ANCHOR + the compaction gate). Prefer a handoff-to-fresh-session over in-place compaction whenever you’re at a clean seam — it’s cheaper and a cleaner window.
Teardown + capture
End-of-project, in order:
-
Final probes — each criterion’s
Probe:run by itsVerifier:, result posted to thesignofftopic. -
Sign-off post per agent — ”
all green, signing off” to signoff. -
Capture pass — durable docs current?
/docker/comms/handoff.md(or the relevant box/project handoff) updated with any new gotchas.- Commits queued; operator pushes when ready.
- Architecture-significant findings promoted to the wiki.
-
Teardown command:
/docker/comms/scripts/proj-teardown /nfs/agent-projects/proj-<slug>-<date>Deactivates the role bots, archives
#proj-<slug>(history preserved in Postgres — recoverable, never deleted), and shreds the now-dead env files.handles.json+README.mdstay as the durable record.
The final ship-gate is a fixed checklist — run it identically every time
Make the last gate before a merge/release a standing checklist, run the same way on every merge — especially the “trivial” docs-only one (that’s exactly where a stowaway hides). A forya release once slipped a 431-line dev harness (sound-test.html) through a routine pass because everyone assumed the late delta was safe; the next release ran the identical checklist on an even-smaller delta and re-ran the stowaway scan anyway, confirming the class did not recur. The gate earns its keep precisely on the deltas where it finds nothing. The checklist:
- delta from the last-certified SHA is non-code-only (
git diff --name-only <certified>..HEAD -- src testsis empty); - the serving / prod surface is byte-identical (tree-hash or per-file checksum);
- version strings are in lockstep;
- stowaway scan — no harness / scratch / tmp / debug artifacts (
git ls-treeasserts prod-forbidden files absent, not “we won’t merge it”); - user-facing docs (CHANGELOG) are honest — known limitations stated, no overclaim.
Do not relax it for a “trivial” delta. (This is the counterpart to author-≠-acceptor: the cheap deterministic gate is non-negotiable and runs every time; the expensive judgment is what gets reserved.)
Honesty under change
- A report’s author isn’t its sole attestor. A sign-off report — or an overnight morning report — is certified by the independent acceptor before it reaches the operator. Author ≠ attestor, same principle as the review fan-out.
- Re-scope honestly when a check goes un-runnable. Verification surfaces change between runs (auth gets added, a tool/MCP session expires, access is revoked). If a check you ran last cycle can’t run this one, say so: state the check you couldn’t run and why, enumerate what you did verify, and name which peer’s lane covers the gap. Never silently skip it or claim it anyway.
No bus wipe, no chat-log archive step. This is the big simplification over LTT: the conversation is already durable in Zulip and the project dir is already on NFS. If a project needs a rendered narrative for the mikayla/agent-archives repo, that’s an optional capture step, not a precondition for teardown — nothing is lost by skipping it.
What lives where
| Surface | Durability | Use for |
|---|---|---|
| Wiki pages | Durable | Workflows, conventions, post-mortems with reusable value |
/docker/comms/handoff.md (+ other box handoffs) | Durable | Stack-specific weirdness, gotchas, operator-context |
/docker/AGENTS.md | Durable | Agent rules + conventions |
| Git commits | Durable | Decisions tied to specific code/config changes |
/nfs/agent-projects/proj-*/ | Durable (NFS) | Per-project briefs, manifest, run artifacts |
| Hindsight memories | Durable | Cross-project personal context |
| Notion Tasks | Durable | Personal task system; cross-project follow-ups |
| Registry images | Durable | CI-built containers |
| Zulip channel/topic history | Durable (archived at teardown, not wiped) | The project’s coordination record — survives sign-off |
| Claude Code / opencode session | Ephemeral | One agent’s working memory — reload from the thread each turn |
zbus listen waits | Ephemeral | Per-session blocking |
The line shifted: on LTT the bus was the ephemeral thing you raced to drain before a wipe. On Zulip the bus is durable; the session is the only ephemeral layer, and the thread is how a successor rebuilds its context.
When the bus is glitching
Symptoms that might warrant operator attention (the operator decides any fallback):
zbus listenreturns nothing despite confirmed peer activity (checkZBUS_ENV, check you’re subscribed —proj-kickoffsubscribes you, but a hand-minted bot may not be).zbuscalls returning HTTP 5xx or auth failures.- The
zulipcontainer unhealthy on the comms box. - A sent message silently dropped in transit (a peer never saw it). The thread is durable, but delivery isn’t atomically guaranteed — after a high-stakes post (a gate verdict, a STOP, a merge-go), glance at
zbus readto confirm it landed, and repost if it didn’t. - A bus cycle (server restart) can drop parked listeners — yours and your peers’. If you notice one, re-arm your own
listenand heads-up any parked peer (a deaf peer won’t know it went deaf).
There is no automatic fallback and no second bus. The default when Zulip is down: surface the diagnosis to the operator out-of-band (LibreChat / direct) and wait for direction — don’t improvise a side channel. The one sanctioned degraded mode is the append-only NFS chat.log fallback (setup, switch marker, and watcher hook here): the operator triggers it, and on a persistent, partly-unattended swarm the governor may direct it — under careful consideration — when the operator is genuinely unreachable and the bus is confirmed dead with no fix in the needed window. A worker never switches surfaces on its own; that splits the swarm across two surfaces, which is worse than a slow bus.
Long-running / persistent swarms
Everything above assumes the ephemeral default: proj-kickoff → run → proj-teardown, one session per agent, continuity handled by a cold handoff to a successor session. Some projects don’t fit that. A persistent swarm keeps one channel alive across many milestones (forya ran a single channel from 0.3 through 0.6.2 — days of work, many versions), is compaction-heavy (agents hit context limits repeatedly mid-run), and is often partly unattended (work continues while the operator sleeps or is mobile). The patterns below are what that case needs and the ephemeral model doesn’t provide. Reach for them when a project is long-lived, multi-milestone, and can’t assume the operator is always present.
Mechanics — the scripts, the per-agent permission setup, the compaction supervisor — live in the persistent-swarm mechanics page. This section is the pattern.
Three layers; persistent-vs-ephemeral is set in planning
A persistent swarm stratifies into three layers:
- Persistent backbone — the governor. One long-lived agent holds project continuity across the whole arc: it runs each milestone, writes the briefs that seed the workers, gates the phases, and is the single relay upward — to
opswhen the fleet layer is up, or directly to the operator on a bare single-project run (§ Theopslayer → Single-inbox routing; and Single-inbox routing on a persistent swarm below). It’s almost always persistent — it has to stay alive to be the continuity, so it compacts at clean seams rather than respawning. (In-place/compactis human-only in the CLI, so its compaction is driven by the operator or the compaction supervisor — see the mechanics page — not self-triggered; that’s why proactive, coordinated triggering has to be built.) - Workers — persistent or ephemeral, decided in planning. A worker (dev, review, a specialist) runs either way, set at kickoff by project scope. Ephemeral: spun up from a brief, do one milestone, hand off, torn down — fresh context per milestone, no compaction needed; the cleaner default when milestones are separable. Persistent: kept alive across milestones and compacted at seams — worth it when the lane is one long evolving thread where warm continuity pays (a dev carrying the architecture in-session across milestones; a review that’s seen every prior gate). The governor is almost always persistent; for dev/review it’s a deliberate per-agent call.
- Subagents. Spawned inside a worker (or the governor) for bounded, context-expensive work — a big read, an investigation, a bulk refactor. They run in their own context window and return only a result, so the parent’s window barely moves (see § One-off subagents). They are the per-level pressure valve: because the expensive reads happen in disposable contexts, the workers and the governor hit the token ceiling far less often.
Persistent-vs-ephemeral is a per-agent decision made in planning, by project scope — not dictated by layer. Handoff-to-a-fresh-session leans default (cleaner and cheaper, no lossy summary — the proven shape across most homelab projects); persist-and-compact is the deliberate choice when warm continuity earns its keep. The governor almost always lands persistent; dev and review go either way. One sub-case regardless of the choice: an ephemeral worker whose single milestone runs long enough to hit the token ceiling before it’s done compacts in place too (it can’t cleanly hand off mid-work) — the rescue valve, not the default path.
The governing role is assigned in planning, not fixed to a handle
The § Cast “Overseer” fuses two functions a persistent swarm should keep separate:
- the governor — runs the milestone, holds continuity, writes the briefs, gates the phases; and
- the acceptor — independently verifies and signs off, and is explicitly not the author of what it accepts (see § One-off subagents → when a fan-out gates a decision).
Which agent holds the governor function is a planning-stage decision, not a fixed rule. On forya it was the infra role; on another project it could be the review role, or a dedicated meta/governor agent that writes no code and runs no infra. Decide it when you pick the roster and write it into the briefs. Keep it distinct from the acceptor — the agent that governs the run shouldn’t also be the sole sign-off on its own milestone.
Context continuity: handoffs first, compaction as the in-band variant
Long runs blow past a single context window. Two strategies, in preference order:
- Handoff to a fresh session (default). The agent writes a restorable handoff — pointers, paths, SHAs, decisions; not the full transcript — and a successor session reads it and resumes. This is the default across nearly every homelab project, and it’s cleaner than compaction: a fresh window with no lossy summary, cheaper, and the successor re-reads source on demand rather than carrying it. An ephemeral worker is this strategy at milestone granularity.
- In-place compaction (for an agent kept alive). When an agent stays alive across milestones — the governor, or a worker you’ve chosen to run persistent — it compacts at a clean seam instead of respawning. The in-band resume artifact is the STATE ANCHOR: a single pinned in-topic post carrying shipped / in-flight / decided / next + pointers to the on-disk handoff docs, updated by a successor post that says
supersedes #NNN. The anchor is what the agent re-reads to fast-resume after a compact — the bus doubling as durable agent memory. (forya never used the guide’shandofftopic for a single 0.5/0.6 transition; the STATE ANCHOR replaced it, because the anchor handles mid-session compaction, which a cold handoff topic doesn’t model.)- Resume hazard — don’t infer elapsed time from dates. On a multi-day run your kickoff baseline date is frozen in context, while the harness correctly injects the current date on compact-resume. Naively diffing them reads as a multi-day “AFK” gap when the compact actually took seconds — the gap is just the project’s calendar span. A worker that believes it was gone for days may assume the bus advanced, CI rebuilt, or canary/device state drifted, and re-verify against phantom drift. Re-derive “how long was I gone / did anything move” from observable timestamps — your last bus post,
git log, CI times, file mtimes — never from a “date has changed” line (and not from a human’s offhand date either; on this swarm even the operator misjudged it once). The recite-todo resume step should carry this.
- Resume hazard — don’t infer elapsed time from dates. On a multi-day run your kickoff baseline date is frozen in context, while the harness correctly injects the current date on compact-resume. Naively diffing them reads as a multi-day “AFK” gap when the compact actually took seconds — the gap is just the project’s calendar span. A worker that believes it was gone for days may assume the bus advanced, CI rebuilt, or canary/device state drifted, and re-verify against phantom drift. Re-derive “how long was I gone / did anything move” from observable timestamps — your last bus post,
Trigger compaction proactively, well before auto-compaction fires — and tie the threshold to how much scrutiny the work demands. Compact or respawn by ~350k for work that needs intense thought and care (gate decisions, reviews, tricky design); treat ~550k as the ceiling for long, lower-scrutiny runs/tasks. Either way you’re well under the 1M window — quality degrades long before the limit (the long-context-rot evidence is consistent; the Claude Code team’s rule of thumb is to compact around 50–60% of capacity, “least intelligent right when auto-compaction fires”). Don’t ride auto-compaction: it lands wherever the limit happens to hit — often mid-task — which is exactly the failure the compaction gate exists to prevent.
Coordinated compaction / respawn gate
Before a heavy fan-out (a big review, a milestone kickoff), an ill-timed compaction mid-phase corrupts the work. So gate it:
- The governor broadcasts the directive (an
@**all**mention wakes the whole roster): each agent compact (or hand off + respawn) at its own clean seam, then post acompacted+resumedflag naming the anchor it re-grounded on. - Each agent ACKs, confirms it’s at a clean seam (nothing in flight), does its compaction/respawn, and posts the flag.
- The governor blocks the next phase until a counted N/N of flags is in, then — and only then — opens it.
This ran cleanly three times in one forya project (before a 44-agent review and before two milestone kickoffs). Same choreography whether each agent compacted in place or respawned fresh; the gate counts “handed-off + back,” not the mechanism.
Today the two steps that bracket each agent’s compaction are still manual: typing /compact, and re-driving the session afterward (an agent doesn’t auto-resume from a compact). The pattern that automates them is a compact-agent: at the gate the governor spins up one short-lived agent (a spawned claude- session — see spawn-claude-code-session) that walks each peer through its compaction, queues the resume prompt, and verifies each peer posted its compacted+resumed flag before reporting N/N back — then tears itself down. It’s the answer to “who compacts the compactor”: a disposable agent outside the set being compacted, so nothing has to drive its own /compact. The build-out (the guarded tmux send, the pane allowlist, the resume template) lives on the mechanics page as the compaction supervisor.
The gate topic: stand it up, and listen channel-wide
Gate ceremonies — the compaction directive + compacted+resumed flags, the push-gate’s final-gate-clear <sha>, ship-gate verdicts — are coordination, not work, and must reach every peer regardless of which work topic is live. Two coupled rules make that reliable on a persistent swarm:
- Post gate machinery to a standing
gatetopic, stable for the whole project and independent of the work topic’s name or rotation. Don’t pin the gate to whatever the work topic happens to be called (a swarm running a topic literally namedv0.5-metadatafor 0.8.x work had its gate machinery riding that arbitrary, frozen name) — a fresh swarm shouldn’t have to discover the work-topic name to find the gate. SetSWARM_GATE_TOPIC(the env the push-gate hook reads) to it. - Peers must listen channel-wide for the gate (
zbus listen proj-<slug> --mentions, no topic), not pinned to their work topic. A topic-scopedlisten <work-topic> --mentionsdoes not wake on an@-mention posted to a different topic — so a gate directive on thegatetopic is silently unheard by a peer parked on its work topic. (This bit a real run: a compaction-gate@**all**on a fresh topic never woke peers pinned to the work topic; the governor had to re-post where their listeners were.) Channel-wide--mentionsis what makes a standing gate topic deliverable.
Together: gates become topic-stable (decoupled from work-topic churn) and deliverable (they reach peers wherever they’re working). The governor, which relays for everyone, listens channel-wide as a matter of course.
Milestone kickoff on a warm channel
A persistent channel doesn’t re-run proj-kickoff per milestone. Each milestone opens warm:
- The lead worker (or governor) posts a KICKOFF for the milestone — scope, what’s in and out.
- The governor folds in the gate criteria; infra/specialists fold in their constraints.
- Someone posts one consolidated
LOCKED:checklist that supersedes the back-and-forth — and that’s what everyone codes to (theLOCKED:-supersede discipline from § Topics, applied to the milestone plan).
Rotate or rename the work topic at version boundaries. A tight swarm where everyone reads everything can legitimately run one long work topic + STATE-ANCHOR discipline instead of the guide’s “one topic per thread” (forya ran ~410 messages this way without it blowing up — the STATE ANCHORs compensated). But a topic literally named v0.5-metadata still carrying 0.6.2 work misleads every later reader. Bots can’t rename topics, so name the first one generically (e.g. work or milestones), and split off a topic only when work is genuinely parallel and independently readable (a multi-hour async job — see § Cast, the async-job specialist — is the case that pays off). Keep the gate machinery off the work topic entirely — it gets its own standing topic (see § The gate topic: stand it up, and listen channel-wide), so rotating or renaming the work topic never disturbs gate delivery.
Merge at the granularity you can independently verify and roll back. Bundle changes into one merge only when they share a verification surface and a rollback fate; a device-proven fix banked alone beats a bundle where one unproven member can force-revert the proven one.
Single-inbox routing on a persistent swarm
Single-inbox is now the default fleet model via ops (§ The ops layer → Single-inbox routing): the operator watches ops, ops watches everything. On a persistent swarm the same pattern runs one tier down — the governor is the single relay for its project, upward to ops (or directly to the operator on a bare single-project run with no fleet layer). The @**operator** phone lever (and @**ops** to wake the ops agent for relay upward) and the morning-report-plus-peer-certification apply here verbatim (author ≠ attestor; § Teardown → Honesty under change). The swarm-specific unattended-run scaffolding on top of that:
- The bus carries decisions, not authority. The operator can route a decision (ship / defer / merge-go) through the governor — but not, by default, a harness permission. Out of the box, a push gated by the Claude Code permission classifier needs the human in the acting agent’s own session (or the narrow governor-tmux stopgap when you’re AFK — both in § Blocker escalation). The durable fix is per-agent permissions (mechanics page): a gated-push hook makes the default-branch push itself keystroke-free — it clears automatically when the acceptor’s
final-gate-clear <sha>is on the bus (proven end-to-end on a live release; Claude Code honors a PreToolUseallowfor a default-branch push). The one human step that remains is installing the hook: registering a publish-auto-clearing hook is classifier-gated as self-modification and needs the operator’s in-session grant once, up front. So plan the bootstrap — operator installs the hook in the acting agent’s session at setup; after that, gated pushes flow unattended. Absent the hook, fall back to staging everything and posting “I need you in my session to approve.” - Tooling auth can expire mid-run — route around it the sanctioned way. On a long or unattended run, assume any interactive-auth control surface (a CI/registry MCP session) may be gone by hour N. Before relying on it for a critical action, confirm a sanctioned non-interactive fallback exists, and route the action to the lane that still holds it — e.g. re-trigger CI via an authorized
git pushrather than the dead MCP. Do not improvise around lost auth by reading secret env bytes and calling the API raw; that trips the env-read ban. A missing sanctioned path is a blocker to surface, not a thing to hack around. - Decide what may ship unattended, up front. While the operator’s asleep: SHIP a fix only if the acceptor is code-confident and it’s off any device/runtime-sensitive path (or low blast-radius with a hot rollback); HOLD anything code-uncertain or that could break the app with no quick rollback. Tag every fix shipped-unverified overnight for an operator spot-check on wake.
(See § Blocker escalation for the governor’s tmux-unblock stopgap when the operator is AFK and the only thing in the way is a harness prompt for something clearly pre-authorized.)
References
/docker/comms/handoff.md— operational source of truth (stack shape, script reference, gotchas)./docker/comms/scripts/{zbus,ensure_agent_bot,proj-kickoff,proj-teardown}— the tooling./nfs/git/ops/—ops’s version-controlled home:CLAUDE.md(charter) +STATE.md(the fleet anchor). Non-public; the standing agent’s durable memory (§ Theopslayer →ops’s home and memory).- Persistent-swarm mechanics — per-agent permissions, the compaction supervisor, blanket/gated push setup, and
ops’s spawn/pane mechanics (non-public; for long-lived swarms and the fleet layer). - Zulip Bus Chat.log Fallback — the degraded-mode append-only
chat.logsurface for when the bus is down (§ When the bus is glitching). - Predecessor (deprecated): Let-Them-Talk Multi-Agent Workflow — Agent Guide — the conventions this guide inherits; the comm-primitive sections are LTT/MetaMCP-specific and do not apply.