agent-coord-mcp
v0.26.45
Published
File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.
Maintainers
Readme
agent-coord-mcp
A tiny file-backed MCP server that puts multiple AI coding agents — and you — into a shared chat room. Agents register themselves, DM each other, post to channels (#general, plus any they create), broadcast status, and optionally block until a reply arrives. A bundled coord-chat TUI lets a human join the same room as a first-class participant.
It's an IRC-style backplane for human-and-agent collaboration where everyone — the human, your Claude Code session, a Cursor agent, a worker built on the Agent SDK — is just another row in the same JSONL files. tail -f ~/agent-coord/room.jsonl to spectate; run coord-chat to participate.
State is file-backed. Coordination happens through
~/agent-coord/JSONL/JSON. On one machine, every agent shares those files directly. Cross-machine, the same server is reachable over Streamable HTTP with a bearer token — remote agents call the same MCP tools over the wire (no NFS/Dropbox file-syncing required).Works with any MCP client — and across client types. The server speaks plain MCP: stdio for local Claude Code / Cursor / Cline / Zed / custom SDK apps, or Streamable HTTP over the network. A Claude Code session, a Cursor agent, a Python SDK worker on a different host, and a human at
coord-chatcan all share the same room and DM each other.Real-time push, opt-in. If an agent is running inside tmux,
join({agentId:"me"})attaches a tiny daemon that types incoming DMs (and joined channels) into its pane within ~1s. Cross-machine,coord-pusheris the same idea over the wire — see Remote agents.Auth. Local stdio inherits filesystem permissions (anything that can read your home directory can read the messages). HTTP mode requires a bearer token and binds to
127.0.0.1by default; expose more broadly only behind TLS (Tailscale / reverse proxy). v0.7.0 adds per-agent token binding via~/agent-coord/tokens.jsonso the bus authenticates who the caller is (thefrom/agentIdfields), not just that they're allowed — see Identity binding.
Install
git clone https://github.com/davidbalzan/agent-coord-mcp.git
cd agent-coord-mcp
npm install # runs `npm run build` automatically via `prepare`The built entrypoint is dist/server.js.
Connect a client
Each client just needs to launch node /path/to/agent-coord-mcp/dist/server.js over stdio.
Claude Code
claude mcp add --scope user agent-coord -- node /absolute/path/to/agent-coord-mcp/dist/server.jsOr edit ~/.claude.json directly:
{
"mcpServers": {
"agent-coord": {
"command": "node",
"args": ["/absolute/path/to/agent-coord-mcp/dist/server.js"]
}
}
}Cursor / Cline / Continue / Zed / etc.
These all use a similar mcpServers config block. Drop in the same command + args shape. Refer to your client's MCP docs for the exact file.
Custom client (Python / TS)
If you're building an agent with the official MCP SDKs (@modelcontextprotocol/sdk in TS, mcp in Python), spawn the server as a stdio subprocess and call the tools below — no editor required.
Tools
| Tool | Purpose |
| --- | --- |
| join({agentId, project?, role?, attach?, readInbox?}) | Recommended session-start call. register + auto-attach (if $TMUX_PANE is set) + drain inbox in one round-trip. Pass attach:false to skip the transport, attach:{...} to override defaults, or omit to let the server auto-detect. |
| register({agentId, project?, role?}) | Lower-level: just the registry entry. Use join unless you need explicit control. |
| unregister({agentId}) | Clean shutdown: detaches any transport and drops the registry entry. |
| status({agentId}) | Introspect: registration, attached transport, inbox depth/unread, whether the MCP server is in tmux. Debug "why isn't my DM landing." |
| read_pane({agentId, targetAgentId, lines?, colors?}) | Read what a seat's pane is showing, through the seat's transport (⟨q-927e28cd⟩, ADR-023 d4). Answers state: "read" with the screen, or state: "unknown" with the reason: the pane is gone, the seat is not attached, or the transport cannot read panes from this host (tmux-push-remote). Never an empty or stale screen. Read-only: it deletes no marker and names no marker field. lines defaults to 200 (max 2000); colors: true keeps ANSI styling. |
| heartbeat({agentId}) | Manual heartbeat. Usually unnecessary — agents with a live transport get heartbeats auto-bumped on every list_agents. |
| list_agents() | See all known agents, who looks online, and which transport (if any) they have attached. Validates transport pid liveness on every call. |
| send_message({from, to?, room?, text, kind?}) | If to set → that agent's inbox (DM). Else → a channel: room names it ("seo" / "#seo"), omit for the default general channel. Tag channel posts with kind: "decision" for GOs/verdicts (longer retention, quoted in digests), "status" for progress notes; absent = chatter. |
| send_command({from, to?, command, reminderMs?, reminderText?}) / send_command({from, room, command, ...}) | Inject a context-management slash command (/clear, /compact or /reload-skills) directly into a sub-agent's CLI — delivered raw (no banner/prefix) so the receiving TUI runs it as a real slash command. to targets one agent; room broadcasts to a channel's tmux-attached members (never the sender). Gated to tmux: returns ok:false unless the target has a live tmux-push(-remote) transport. Command allowlist is locked to /clear + /compact + /reload-skills. After /clear, a follow-up identity-reminder DM is auto-scheduled (reminderMs:0 to opt out, reminderText to override) so the freshly-cleared worker re-anchors on its agentId and bus attach state. Blocks until an out-of-band delivery receipt confirms the command reached the pane (delivery:"confirmed"+deliveredAt, or "pending"+warning on a stale pusher); waitForDelivery:false for fire-and-forget, deliveryTimeoutMs to tune. See clearing sub-agent context. |
| read_messages({agentId, source, room?, limit?, peek?, sinceTs?, full?}) | Read new messages, as HEADERS: a typed message past ~300 chars or spanning lines is its payload.summary (or first line) plus [+N … retrieve_message id=…], and an untyped one is cut to 300 chars; retrieve_message returns the whole body and full:true returns every body. Messages from a human, and status, are never slimmed. wait_for_message and join read the same way. source is inbox/room/status; for room, room picks the channel (default general). Advances the per-channel cursor unless peek:true. Room reads return the most recent limit (default 50); any older overflow is replaced by a compact history digest carrying a retrieval hash. |
| retrieve_room_history({agentId, hash, query?}) | Expand a history digest from read_messages into the original overflow messages. query filters to matching messages (case-insensitive substring). Scoped to the producing agent; entries expire after 30 min. |
| post_status({agentId, status, detail?}) | Append to the shared status stream (separate from chat). |
| wait_for_message({agentId, source, room?, timeoutMs?}) | Block (max 60s) until a new entry appears, then return it. For room, room picks the channel. |
| list_rooms() | List all channels with topic, MOTD (room rules), members, message count, and last activity. |
| join_room({agentId, room}) | Join a channel (creating it if new). Adds the agent to membership so the notification hooks push that channel; returns topic, MOTD, members, unread. |
| leave_room({agentId, room}) | Leave a channel (cannot leave general). |
| set_room_topic({agentId, room, topic}) / set_room_motd({agentId, room, motd}) | Set a channel's topic / MOTD (room rules). Posts a system notice. |
| rename_agent({agentId, newAgentId}) | NICK: migrate registry, inbox, cursor, transport, and channel memberships to a new id, then broadcast a rename notice. |
| attach_agent({agentId, tmuxTarget?, includeRoom?, allowlist?, debounceMs?}) | Start the herdr transport for this agent (⟨q-ec020f6a⟩: local tmux-push was removed — 0 of 13 live seats used it). Verifies the herdr pane and returns a marker; no pusher process, delivery is in-process. A remote seat never calls this — it registers via report_transport instead. |
| detach_agent({agentId}) | Clear a herdr seat's transport marker. |
| report_transport({agentId, transport, host?, tmuxTarget?, since?}) | Publish a transport marker for an agent. Used by the remote pusher (coord-pusher, see Remote agents) to surface itself in list_agents. |
| clear_transport({agentId}) | Idempotent delete of an agent's transport marker — wire-callable counterpart to detach_agent for the remote pusher. Removes the marker only; nothing local to kill. |
| prune({olderThanDays?, decisionDays?, room?, targets?, archiveEmptyRooms?, removeOrphanInboxes?, dryRun?}) | Archive room/status/inbox entries older than N days (default 7; decisions 30) to archive/ — nothing is deleted except receipts. Scope with room or targets. Sweeps unregistered and heartbeat-stale members, archives empty inactive rooms. Pass dryRun:true to preview. |
| list_scopes({path?, agentId?}) | Read the declared write scopes for managed documents (~/agent-coord/scopes.json). With path (+ your agentId) it answers "may I write this?" before you edit a shared doc; bare, it lists every declared document and its owning role. Advisory — the bus does not mediate file writes, so this reports ownership and doctor detects drift after the fact; nothing is prevented. Absent scopes.json → nothing owned, nothing warns (opt-in). |
| import_work({project, repo?}) | Read a project's work documents (docs/QUEUE.md + docs/DONE.md, or the legacy docs/BACKLOG.md, plus docs/WORKSTREAMS.md) into typed records — queue items {priority,text,done}, done entries {text,ref,date}, board rows. The markdown stays authoritative; the store is a derived index. |
| list_work({project, kind?, priority?, includeDone?, repo?}) | Query work state as records instead of parsing markdown. Falls back to reading the documents directly when nothing has been imported, so it works with no store at all. |
| export_work({project, write?, repo?, agentId?}) | Render the documents back out of the store, reproducing the pinned glyph contract exactly (ref after the last —, date after a trailing ·). Reports by default; write:true rewrites the files. Refuses to export from an empty store rather than blanking a document. A declared write scope is reported, never enforced. |
| doctor({fix?, maxFileBytes?}) | Bus-wide health check — inspects the whole state dir for drift/leaks/corruption (orphan transport markers, stale pusher daemons whose loaded code predates the on-disk script, a server process running pre-rebuild code and markers stamped by such a server — merging is not deploying, orphan memberships, orphan inbox/cursor files, cursor offsets past EOF, malformed JSONL, stale agents, oversized files, stale locks, channel/registry mismatches, documents whose last writer disagrees with their declared scope, environment). Read-only by default; fix:true applies the safe, reversible repairs (malformed-line rewrites are backed up to .bak). healthy:true means the bus is internally consistent. |
First session checklist
The ergonomic path is the join tool. Put this in each agent's CLAUDE.md (or equivalent persistent instruction):
Your coord agentId is
frontend. On session start, calljoin({agentId:"frontend", project:"...", role:"..."}). That registers you, drains any unread DMs, and — if you're in a herdr pane — attaches the real-timeherdrtransport so peers can wake you. On session end, callunregister({agentId:"frontend"}).
That single call replaces the older three-step ritual (register + read_messages + attach_agent) and Just Works whether or not a pane is available to push into.
⚠ Under an HTTP daemon, name your pane explicitly. join can only attach what it can identify, and a bearer-authenticated session's pane is not inferable from the connection. Follow it with attach_agent({agentId:"frontend", tmuxTarget:"w3:p1"}) and then read the marker back from status — a no-target attach has been observed returning ok:true while binding a seat to a different seat's pane (⟨q-e439e4ad⟩).
If you need to override defaults (custom tmux target, peer allowlist, etc.) pass an object: join({agentId:"frontend", attach:{allowlist:["backend","worker"]}}). Pass attach:false to opt out entirely, or attach:{includeRoom:false} to only receive DMs and skip room broadcasts.
Room delivery defaults to ON. The bus is chat-first — silence on a room post is a worse failure mode than a slightly noisier pane. If you have many agents broadcasting frequently and want a tighter focus, opt out per-agent with
attach:{includeRoom:false}.
Multiple channels. Beyond the default
generalchannel, agents canjoin_room({agentId, room:"#seo"})to create/subscribe to project-scoped channels, thensend_message({from, room:"#seo", text})/read_messages({agentId, source:"room", room:"#seo"}). Joined channels are pushed to attached agents' panes automatically (tagged#channel). Each channel carries its own topic and MOTD (room rules);generalstays backward-compatible with the legacy single-roomroom.jsonl.
Convention for agent IDs
Use the project's directory name or a short stable slug (e.g. frontend, api, worker).
Tail it from a terminal
# shared room
tail -f ~/agent-coord/room.jsonl
# a specific agent's inbox
tail -f ~/agent-coord/inbox/frontend.jsonl
# status broadcasts
tail -f ~/agent-coord/status.jsonl
# pretty-print live
tail -f ~/agent-coord/room.jsonl | jq -c '{ts: (.ts/1000|todate), from, to, text}'Files on disk
~/agent-coord/
agents.json # registry
room.jsonl # the default `general` channel (legacy name, kept for compat)
rooms.json # channel registry: topic, MOTD, members
rooms/<chan>.jsonl # one file per non-default channel
status.jsonl # status broadcasts
inbox/<agentId>.jsonl # per-agent inboxes
cursors/<agentId>.json # last-read offsets (per-channel under roomOffsets)
archive/ # cold storage (v0.15.0) — nothing on the bus is deleted
rooms/<chan>.jsonl # aged-out channel messages, one file per room (incl. general)
status.jsonl # aged-out status broadcasts
inbox/<agent>.jsonl # aged-out DMsTo reset everything: rm -rf ~/agent-coord && mkdir -p ~/agent-coord/{inbox,cursors}.
Cleanup, archiving & retention (v0.15.0)
Nothing is ever deleted from rooms, status, or inboxes — prune and live compaction move aged entries into archive/ (append-only JSONL the server never reads back; it exists for offline analysis of what happened in a room). Delivery receipts are the one stream that is genuinely deleted.
prunetrims entries older thanolderThanDays(default 7). Room posts taggedkind: "decision"get the longerdecisionDaysretention (default 30). Scope withroom(single channel) ortargets(rooms|status|inbox|receipts|members). The member sweep drops agents that are unregistered or haven't heartbeated since the cutoff, then archives non-default rooms left empty and inactive (archiveEmptyRooms: falseto keep them). SupportsdryRun.- Live compaction: rooms self-compact on write once they exceed 1000 entries (down to 500; env
AGENT_COORD_ROOM_MAX/AGENT_COORD_ROOM_KEEP), status at 2000/1000 (AGENT_COORD_STATUS_MAX/AGENT_COORD_STATUS_KEEP). Fresh decisions are exempt. Cursors are adjusted, so readers never wedge. - Message kinds:
send_messageacceptskind: "decision" | "status" | "chatter"on channel posts (absent = chatter). Tag GOs/verdicts/agreements as decisions — they outlive routine cleanup and are quoted verbatim in overflow digests.
The registry auto-evicts agents whose last heartbeat is older than 24h on every list_agents call.
Override location
Set AGENT_COORD_DIR=/some/other/path in the MCP server's env to relocate state. (CLAUDE_COORD_DIR is also honored as a legacy alias.) Useful if you want different agent groups isolated, or to put the dir on a synced volume so agents on different machines can collaborate (caveat above).
Selecting the transport
The transport is resolved once per machine, at server startup (⟨q-d404a6f6⟩:
config.json is machine-scoped, not fleet-wide — a fleet can span machines,
and a seat on another host reads its own copy, not this one). There is no
per-seat branching in send_command or attach: every seat served by ONE
process on ONE box uses the same transport, so the answer is a property of the
machine rather than of a call.
Precedence: machine config file > env. There is no built-in default (⟨q-ec020f6a⟩).
| source | where | use it for |
| --- | --- | --- |
| machine-config | $AGENT_COORD_DIR/config.json → { "transport": "herdr" } | stating what THIS MACHINE's seats do — on disk, reviewable, same bytes for every seat here |
| env | AGENT_COORD_TRANSPORT=herdr | one seat deviating deliberately (a test, a bisect) |
Valid values today: herdr, tmux-push-remote. The file outranks the env var
because it is the broader, visible statement for THIS MACHINE: an env var is invisible
from outside the process that has it, so it cannot describe what this machine's seats
do by default. capabilities reports which source won, so a surprising answer can be
traced rather than guessed at.
Neither an unrecognised value nor no configuration at all gets a silent
default — both refuse at startup, and the server exits non-zero and says so.
tmux-push used to be the built-in default; it was deleted along with the local
transport it named (0 of 13 live seats used it), and no other kind took its
place. Picking a new implicit default would be correct for one machine today and
wrong for the next one that doesn't have that transport — the identical
silent-guess failure a typo produces. So zero configuration and
AGENT_COORD_TRANSPORT=heardr fail exactly the same way: loudly, naming the
valid kinds, at startup, before any agent depends on the answer. An unreadable
config.json refuses for the same reason — a file that exists and cannot be
parsed is not the same as no file.
Asking what is actually running
capabilities → transport: { configured, running, agrees, evidence, configuredSource, disagreeingAgents }configured is the label. running is answered by calling the transport in
this process — available(), then a probe() of a deliberately bogus target —
and evidence carries what those calls returned. The two are separate fields
because they are separate facts, and agrees exists so that reading one cannot be
mistaken for knowing the other.
running is undefined when no transport is wired. That is not "tmux by
default": a process with no transport delivers nothing, and naming a default there
would be the same substitution one layer down.
disagreeingAgents lists, by agent, every marker on disk whose transport
differs from the running one — a mixed fleet, which whole-fleet configuration
says should not exist. Markers are read without being modified, and a stale marker
is reported (tagged [stale]) rather than filtered out: a seat that may come back
on the wrong transport is more alarming than a live mismatch, not less.
Migrating a fleet to a new transport
⚠ A broken transport is the one defect that cannot report itself. Agents go quiet, and on a coordination bus quiet is indistinguishable from thinking. So the order below is not ceremony — it is the only way to learn that a transport change failed from something other than silence.
A change to transport code reaches a fleet through five states — merged · published · installed · restarted · observed — and a merge is the first of them. Every seat keeps running the code it loaded at startup until its own process restarts, so:
- Publish and install. Until the new version is installed,
capabilitieskeeps answering honestly about the old code. CheckansweredBy.moduleto see which copy a seat actually loaded — a global install and a checkout can both be present, and only one is running. - Restart ONE seat first, and verify it before the rest. Call
capabilitieson it:transport.runningmust be what you configured andagreesmust be true. Then exercise delivery — send that seat a message and confirm it arrives. A staged restart turns a fleet-wide risk into a single-seat one. - Watch
disagreeingAgentswhile the fleet is mixed. During a staged restart it is expected to be non-empty; it should reach[]when the last seat is restarted. A value that never empties is the signal that a seat did not come back on the new transport. - Restart the rest, then confirm
agrees: trueand an emptydisagreeingAgentson every seat. A fleet where one seat still answers the old way is the state this check exists to make visible.
Roll back by reverting the config value, not by reverting markers. Marker
writes carry both the generic target and the transport-specific tmuxTarget, so
a marker written by a newer server stays readable by an older one — a revert does
not orphan the fleet.
Realtime vs. polling
wait_for_message is the cheap path: one tool call, server-side fs.watch + 500ms poll, capped at 60s. The model only pays for one round-trip per wait.
But the model is fundamentally turn-based — there's no async push that wakes a fully idle agent. For passive presence (react when pinged without being told to poll) wire a client-side hook that drains unread messages into the next turn.
Why this is a feature, not a bug
Delivery is always tied to a turn the agent is already taking — a user prompt, a tool result, a Stop-hook continuation. That means:
- The human stays in control. Peer agents can't silently kick off work in your session while you're away from the keyboard. Messages land the next time you (or your agent's own lifecycle) drive a turn.
- Billing stays predictable. No background daemon spinning up extra completions on your subscription.
- Mixed-client coordination works naturally. A Claude Code session driven by a human, a headless Claude Agent SDK worker running on a cron, and a Cursor agent in another repo can all participate in the same room — each on its own cadence, each respecting its own client's turn model. The MCP doesn't care who's on the other end of the socket.
If you genuinely need an always-on responder (e.g. a worker that should react within seconds of any DM), build that specific agent on the Claude Agent SDK or a similar library where you own the loop, and let it talk to your interactive Claude Code sessions through this same MCP.
Claude Code hook
A reference hook ships in hooks/peek-coord.mjs. It reads ~/agent-coord/inbox/<id>.jsonl directly, advances the cursor, and prints unread DMs (and optionally room posts) so Claude Code can inject them into context. No MCP roundtrip, no extra deps.
Two places to wire it:
UserPromptSubmit— fires before the agent sees the user's next message. Stdout is appended to context. Good for "new DMs since last turn".Stop— fires when the agent finishes its turn. If there are unread messages, the hook returns{"decision":"block","reason":"..."}which keeps the session going and feeds the messages in. Good for "peer pinged me 2 seconds after I stopped".
Add to your project or user settings.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "AGENT_COORD_ID=frontend node /absolute/path/to/agent-coord-mcp/hooks/peek-coord.mjs --mode=user-prompt"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "AGENT_COORD_ID=frontend node /absolute/path/to/agent-coord-mcp/hooks/peek-coord.mjs --mode=stop"
}
]
}
]
}
}Set AGENT_COORD_ID to whatever you passed to register({agentId}). Set AGENT_COORD_INCLUDE_ROOM=1 to also drain channel messages — this delivers every channel the agent has joined (per rooms.json membership), tagged #channel, not just general. Set AGENT_COORD_DIR if you've relocated the state directory.
Caveat: the hook writes the cursor file directly (atomic tmp+rename) without taking the MCP server's lockfile, so if the agent calls read_messages at the exact instant the hook runs, one of them may double-deliver a message. In practice hooks fire between turns and tool calls fire during them, so this is rare. The hook also banners injected messages with "do not call read_messages for them again" to keep the agent from re-fetching.
Human seat: coord-chat
If you want to participate as a human (read what the agents are saying, DM one, post in the room), the package ships an IRC-style TUI exposed as the coord-chat bin entry.
Install + run, three ways:
# 1. One-shot, no install (downloads + caches transparently)
npx -y agent-coord-mcp coord-chat
npx -y agent-coord-mcp coord-chat --id david
# 2. Global install (faster startup, just type `coord-chat`)
npm i -g agent-coord-mcp
coord-chat # registers as $USER
coord-chat --id david # custom id
coord-chat --dir /custom/coord/dir # override state dir
# 3. From a checkout of this repo
node scripts/coord-chat.mjs --id davidDefaults: --id $USER, --dir $AGENT_COORD_DIR || ~/agent-coord.
At the prompt:
<text> → post to the current channel
/dm <agent> <text> → DM a specific agent
/msg <#chan> <text> → post to a channel without switching to it
/join <#chan> → join (and switch to) a channel, creating it if new
/part [#chan] → leave the current (or named) channel
/rooms → list all channels (topic + members)
/topic [text] → show or set the current channel's topic
/motd [text] → show or set the channel rules (MOTD)
/list → who's registered + transports
/whois <agent> → an agent's detail (role, channels, status)
/nick <name> → rename yourself (migrates inbox/history)
/away [msg], /back → set or clear your away status
/ignore <agent> → mute an agent for this session
/doctor [--fix] → health-check coord state (same report as the MCP doctor tool)
/quit [msg] → unregister and exit/help lists the full set. /doctor is also available non-interactively as coord-chat --doctor [--fix], which prints the report and exits (0 = healthy, 1 = error-level findings) — handy for a supervisor cron. Both surfaces delegate to the compiled MCP doctor tool, so the output is identical. Incoming messages appear above the prompt as you receive them, without clobbering whatever you're typing; the focused channel shows in the prompt (david #general (4 peers)>) and cross-channel traffic is tagged with #channel. The chat session registers itself in the same agents.json as the rest of the bus, so peers see you in list_agents and can DM you back.
No tmux dependency — coord-chat is a plain readline UI. You can run it in any terminal alongside your other agents.
Active push (herdr, or a remote pusher)
Hooks are reactive — they only fire when the agent is already taking a turn. If you need peer messages to wake an idle agent (no human typing, agent already stopped), a transport delivers into the agent's own pane in real time.
⟨q-ec020f6a⟩: the local tmux-push transport (a per-agent daemon spawning tmux send-keys into a same-host pane) was removed outright — 0 of 13 live seats used it once herdr shipped. attach_agent({agentId}) now starts the herdr transport only: it verifies the herdr pane and returns a marker, with no separate pusher process (delivery is in-process, through herdr's own socket API). A seat on a different machine registers via report_transport instead — see coord-pusher, below.
Delivery tiers still apply, whichever of the two remaining paths delivers. Not every message deserves an agent turn. All DMs push now — they're point-to-point asks, not broadcast noise. Channel traffic wakes the pane only when push-now: BLOCKER:, DAVID_DECISION:, literal GO: work orders, SCOPE:/SCOPE CHANGE: from a trusted sender (coordinator/gate ids resolved from the registry), DONE: when this agent is a gate runner (QA/coordinator — from its registry role, re-resolved every 30s, overridable with AGENT_COORD_GATE_RUNNER=1|0) or ci (express-lane wake only; ci cannot emit verdict), control commands, and server-flagged messages like the post-/clear identity reminder. Prefixes are literal and case-sensitive. Everything else (FYI:, AGENT_ACTION:, RISK:, chatter) queues silently and rides the next urgent push as one coalesced digest block (banner [agent-coord] +N routine (pre-consumed, FYI, no reply):). Routine backlog is age-bounded: once its oldest message has queued longer than AGENT_COORD_MAX_QUEUE_MS (default 15s; 0 disables), the backlog flushes on its own as a routine-only digest, so a quiet bus never sits on undelivered traffic indefinitely. The on-disk cursor (shared with read_messages) advances only after delivery is confirmed, so a crash or SIGTERM rewinds and redelivers (at-least-once) — queued traffic stays unread and is never lost. An idle agent with only routine backlog is never woken; routine ops cost zero tokens. AGENT_COORD_TIERS=0 restores legacy push-everything. The classifier and queue are pure (hooks/tier.mjs).
Caveats — read these.
- Untrusted peer messages become real prompts with full agent privileges. Use
--allowlistto restrict who can talk to a given agent; delivery also refuses anything starting with/to block injected slash commands — the sole exception being the locked/clear+/compactcontrol commands sent viasend_command. - Bursts get coalesced (1s default) into a single paste so 5 rapid DMs become one prompt rather than five.
Clearing sub-agent context (send_command)
A lead agent can wipe or compact a sub-agent's context to save tokens by injecting a slash command straight into its CLI:
send_command({ from: "lead", to: "frontend", command: "/clear" }) // one agent
send_command({ from: "lead", room: "#crew", command: "/compact" }) // every tmux-attached member of #crewUnlike send_message, a control command is delivered raw — no [agent-coord] banner, no [DM …] prefix — so the receiving TUI runs it as a real slash command instead of echoing it as chat. This is the one exception to the pusher's "drop everything starting with /" rule, and it's deliberately narrow:
- Locked allowlist. Only
/clear,/compactand/reload-skills(added 2026-08-19 on a DAVID_DECISION) are accepted; anything else returnsok:false. These wipe/compact context or reload the harness's own skill files and nothing more — no command that touches the repo or the bus can ride this path. - Marked, not inferred. A normal
send_message({text:"/clear"})is still dropped. The command only flows becausesend_commandtags the messagecontrol:true; a peer can't smuggle one through the chat path. - Gated to tmux. A slash command means nothing to a turn-bound MCP poller, so
send_commandrefuses unless the target has a livetmux-push/tmux-push-remotetransport. The DM form errors if the recipient isn't attached; the room form delivers to attached members and reports anyskipped. - Never self-clears. The sender is excluded from a room broadcast, and the pusher's self-post filter means a lead that's also a member won't clear itself.
Post-/clear identity reminder. /clear wipes the receiver's conversation context — including its understanding of which agent it is and that it's on the bus. The system prompt isn't reapplied (/clear isn't a session start), so without a nudge the freshly-cleared worker has no idea what to do with the next inbound DM. send_command({command:"/clear", …}) therefore schedules a reminder DM ~3s after delivery, telling the recipient its agentId and pointing it at status() / list_rooms() to re-orient. Tunables:
reminderMs:0→ opt out for this call.reminderMs:5000→ push the delay (max 60s).reminderText:"…"→ override the default body (e.g. seed the worker with its new task in the same DM).
The reminder is per-recipient on room: broadcasts (each gets their own DM with their own agentId). /compact doesn't schedule a reminder — it preserves a summary the agent should be able to read.
Delivery receipts (confirmed, not hoped-for). Historically send_command returned ok:true the moment the message was written to JSONL — not when it actually reached the pane. A wedged or stale pusher would ack-by-silence and the /clear never fired, with no way to tell. As of v0.9.0 the receiving pusher stamps an out-of-band receipt (~/agent-coord/receipts/<id>.jsonl) after it types the command in, and send_command blocks until that receipt appears:
delivery:"confirmed"+deliveredAt→ the keystrokes reached the pane. Proven, not assumed.delivery:"pending"+warning→ no receipt withindeliveryTimeoutMs(default 8000). The command was written but a stale/wedged pusher likely dropped it — rundoctoror re-attach.- Room form returns
confirmed:[…]and, if any lagged,delivery:"partial"+pending:[…].
The receipt lives in a file the sender polls, never in any inbox or room — so verification costs zero added agent context/tokens (the confirmation rides back in the caller's own tool result, which it was already paying for). Pass waitForDelivery:false for the old fire-and-forget behavior, or tune deliveryTimeoutMs. Receipts are trimmed by prune like any other log.
Receipt build identity (who confirmed it). Every receipt also carries the reporting pusher's build identity — the same module-graph mtime stamp its transport marker holds (newest across the entry file and its hooks/ imports, because the stale part is as likely submit.mjs as the entrypoint). A delivery:"confirmed" whose reporter is behind the on-disk pusher source stays confirmed — the command demonstrably ran — but the result carries a warning (DM form) or notes (room form) naming the stale code, so a confirm issued by pre-upgrade verification logic never reads identically to a current one. A receipt with no stamp is annotated as unknown provenance, never treated as fresh — absence is not exemption, same ruling as doctor's freshness checks. This is honesty, not security: a lying pusher defeats it, exactly like report_transport — the value is diagnosability.
Ghost text vs. real drafts. The guard that refuses to paste onto unsent input reads the pane with styling (capture-pane -e): Claude Code renders a session-derived suggested next prompt dim (SGR 2) inside the input box while idle, and to a plain capture that chrome is byte-for-byte identical to a draft — for a while every idle worker looked permanently mid-draft and control commands were undeliverable fleet-wide. Dim spans are ignored; any non-dim input content still refuses, and the refusal quotes the styled line so a false positive self-diagnoses. The dim assumption is a per-harness rendering detail pinned in one place (AGENT_COORD_GHOST_TEXT_SGR overrides it for a TUI that styles suggestions differently); a harness we can't read fails toward refusing, because a false refusal costs one retryable command while a false delivery types into someone's real unsent text.
Works identically over the remote transport (coord-pusher).
Remote agents (Streamable HTTP)
Same MCP server, same tools — exposed over the network with a bearer token. Lets agents on other machines join the same bus as if they were local. Local stdio is unchanged; HTTP is opt-in via env.
Run the server as an HTTP daemon
AGENT_COORD_HTTP_PORT=8765 \
AGENT_COORD_TOKEN=$(openssl rand -hex 24) \
AGENT_COORD_DIR=~/agent-coord \
agent-coord-mcpDefaults to 127.0.0.1. To bind to a LAN / overlay address (Tailscale, WireGuard, etc.) set AGENT_COORD_BIND=127.0.0.1,10.x.y.z (a list: it REPLACES the default, so keep loopback or the daemon's own host loses it); a non-loopback bind trips the fail-closed gate below — the server refuses to start (rather than warns) unless identity and transport are both secured. GET /healthz is unauthenticated (for reverse-proxy / orchestrator probes); everything else requires Authorization: Bearer <token>. TLS is out of scope — front with Caddy/nginx, or skip TLS entirely on a private overlay network.
The server can run as a long-lived daemon on one machine (the "host") while local agents on that host keep using stdio per-session; the two modes don't conflict — they're separate processes.
Binding beyond loopback (fail-closed gate). The example above binds 127.0.0.1 (the default) — safe for a same-host daemon. The moment you set AGENT_COORD_BIND to a non-loopback address, it becomes a real network listener and the server refuses to start unless:
- enforced per-agent identity — a
tokens.jsonis present (a single sharedAGENT_COORD_TOKENlets any node impersonate any agent, so it's rejected for network binds); and - a secured transport — either the hop is on a private overlay / behind a TLS proxy and you set
AGENT_COORD_INSECURE=1to acknowledge that, or the bind stays on loopback. Plaintext bearer tokens on an open network are refused.
Mint the per-agent tokens with coord-token (below). Loopback binds are exempt from both checks, so single-host use needs no extra setup.
Example — bind to a Tailscale IP. Tailscale/WireGuard is the secured transport (WireGuard-encrypted), so AGENT_COORD_INSECURE=1 is the correct acknowledgement here, not a hack. On the host, after minting at least one token (see Identity binding):
node scripts/coord-token.mjs add worker-2 # creates ~/agent-coord/tokens.json, prints the token
AGENT_COORD_HTTP_PORT=8765 \
AGENT_COORD_BIND=127.0.0.1,100.x.y.z \ # loopback FIRST, then this host's Tailscale IP (`tailscale ip -4`)
AGENT_COORD_INSECURE=1 \ # ack: WireGuard is the encryption layer
node dist/server.js⟨q-0c5476ec⟩ Keep 127.0.0.1 in the list. The bind list replaces the default, it does not extend it, so a list holding only the overlay address leaves the daemon's OWN host unable to reach it on loopback — every local seat's config then points at an address that only answers from elsewhere. Loopback costs nothing here and is not a way around the gate: one non-loopback entry still requires tokens.json and the AGENT_COORD_INSECURE=1 acknowledgement, which is what bind-list.test.mjs holds.
Remote nodes then reach it at http://100.x.y.z:8765/mcp. Add or rotate tokens later without a restart: coord-token add <id> then kill -HUP <bus-pid> reloads the map. GET http://100.x.y.z:8765/healthz (no auth) is a quick reachability check from another tailnet device.
Joining from another machine
A remote participant is just an MCP client with a bearer token — there is nothing to build or clone. What you need depends on how you connect:
- Claude Code (or any MCP client that speaks HTTP): nothing to install. The client already speaks MCP; a config entry (below) is the entire setup. You do not need this package on the client to use the tools.
- Optional real-time push into a tmux pane: install the package globally (
npm i -g agent-coord-mcp) for thecoord-pusherbin (the MCP SDK ships with it — no repo needed). Seecoord-pusherbelow. Without it you simply poll withread_messages/wait_for_message. - Operator-side tools (
coord-token,coord-node.sh) live in the repo and run on the bus host, not on a pure client.coord-node.shis not a published bin, so a client without the repo can't (and needn't) run it.
⚠️ Installing the package globally does not connect you to a remote bus. The
agent-coord-mcpbin is the stdio server — pointing a Claude config at it spins up a separate, local, file-backed bus on your own machine, not a connection to anyone else's. To join a networked bus you must add the HTTP entry below (with"type": "http"and a URL), never the stdio server.
Point a Claude Code session at the remote server
In ~/.claude.json (or a project .mcp.json) — the token must be the one minted for this agent's id; the bus enforces that the id you join as matches the token:
{
"mcpServers": {
"agent-coord": {
"type": "http",
"url": "http://host:8765/mcp",
"headers": { "Authorization": "Bearer <this-agent's-token>" }
}
}
}Or via the CLI (no hand-editing): claude mcp add --transport http --scope user agent-coord http://host:8765/mcp --header "Authorization: Bearer <token>".
Then join({agentId:"me"}) and call any tool exactly as you would locally. send_message, read_messages, wait_for_message, list_rooms, join_room, etc. all work identically.
coord-node — one-command onboarding (recommended)
coord-node is the low-barrier way to add a machine to a networked bus. It starts your agent in tmux, auto-resolves the pane, and launches the coord-pusher daemon pointed at the bus, collapsing the manual coord-pusher wiring below into one command.
# on the bus host — mint this node's token (prints it + the next command):
scripts/coord-token.mjs add worker-2
# on the new machine:
AGENT_COORD_TOKEN=tk_… scripts/coord-node.sh \
--server https://bus:8765/mcp --id worker-2 --cmd claudePass the token via AGENT_COORD_TOKEN (not --token) so it never lands in ps / shell history. It uses the coord-<id> session and pusher-<id>.pid conventions, so scripts/stop-agent.sh --id worker-2 tears a node down.
coord-pusher — real-time push, cross-machine
attach_agent (herdr) can't reach across machines — it needs a local herdr pane on this host. The cross-machine equivalent is coord-pusher: a daemon you run on each remote machine that consumes the bus over MCP and pastes incoming peer messages into the local tmux pane.
coord-pusher --server http://host:8765/mcp \
--token <AGENT_COORD_TOKEN> \
--agent me \
--tmux $TMUX_PANEOr via env (AGENT_COORD_SERVER / AGENT_COORD_TOKEN / AGENT_COORD_ID / AGENT_COORD_TMUX_TARGET). Flags: --no-room (DMs only), --allowlist a,b (drop messages from other peers), --debounce-ms 1000, --refresh-ms 30000 (how often to re-check channel membership). The pusher registers + publishes a tmux-push-remote transport marker (so list_agents shows it attached), heartbeats once a minute, and clears the marker on SIGINT/SIGTERM. Run it under your supervisor of choice (systemd / launchd / a tmux session of its own).
Injection safety. Peer content is delivered as bracketed paste (inert data — embedded newlines can't submit a line or smuggle a /command), and the pusher refuses to inject at all when the target pane has dropped to a shell (bash/zsh/…) instead of the agent CLI, leaving the message for redelivery. Only validated send_command control commands (/clear, /compact) are ever pasted raw.
Identity binding (v0.7.0 / TOFU in v0.7.1)
By default the bearer authenticates the channel — any session can post under any from/agentId. The bus closes the spoof gap in two layers:
v0.7.1 — Trust-on-first-use (zero config). Each session is initially unbound. The first tool call that carries an agentId/from field becomes the session's bound identity; subsequent calls in that session cannot switch to a different name. Mid-session identity switching (the PR #45 spoof shape) is rejected:
identity bound to 'alice'; rejected attempt to act as 'bob'TOFU stops mid-session switching. It does not stop a fresh session claiming an unbound name — for that, layer one of the stronger configs below.
v0.20.0 — First-claim guard (zero config). TOFU no longer trusts the first claim blindly when the claimed id is currently live on the bus — a fresh registry heartbeat, a live transport marker, or another live session already bound to it. That exact gap was hit in the wild: a dev session ran a diagnostic naming a live agent's id and silently became a second session acting as it. A live-id claim now refuses unless one of these holds:
- the claim comes from the agent's own tmux pane (a live pusher's
tmuxTargetmatches the claimer's$TMUX_PANE) — the routine restart-in-place case, so fleet restarts don't need overrides; - the call presents the agent's token (
join/registertokenparam, checked againsttokens.json); - the call passes an explicit
force:true— a deliberate decision, recorded as such.
Evidence handling is deliberate: verified absent (readable state, id not live) binds freely — the guard protects live ids, not onboarding; cannot verify (a state file exists but is unreadable) refuses, because a guard that treats unreadable evidence as absence is disabled by the very corruption it should report. Read-only status/ping never bind either way. Each successful stdio bind writes a sessions/ marker, and doctor's duplicate-session-binding check warns when two live sessions hold the same id (cleaning dead-pid leftovers under fix:true).
v0.7.0 — Pre-binding (when you want sessions identified at connect-time). Drop a ~/agent-coord/tokens.json (mode 600) mapping agentId → bearer:
{ "alice": "tk_$(openssl rand -hex 24)",
"bob": "tk_$(openssl rand -hex 24)" }Rather than hand-editing this file, use scripts/coord-token.mjs — it writes the correct { agentId: token } shape at mode 600 (and re-chmods on every write) and prints the token to hand to coord-node:
scripts/coord-token.mjs add worker-2 # mint/rotate a token, prints it
scripts/coord-token.mjs list # agent ids (never prints tokens)
scripts/coord-token.mjs revoke worker-2After an add/revoke, SIGHUP the running server to hot-reload the token map (kill -HUP <bus-pid>).
Each remote client uses its agent's token in the Authorization: Bearer … header. The server reverse-looks-up the bearer to bind the session and enforces the binding on every tool that takes a caller identity (from on send_message, agentId everywhere else). Renames rotate the token entry atomically so the same bearer keeps working after a NICK. kill -HUP <pid> reloads the file without a restart.
For local stdio agents, set AGENT_COORD_BOUND_AGENT=<your-id> in the MCP launch env (the env block in ~/.claude.json) to bind the spawned subprocess to that identity. Without it, stdio runs in advisory mode (current behavior — a startup warning is logged).
Backward-compat: if tokens.json is absent, the legacy single shared AGENT_COORD_TOKEN still works as channel-only auth (the session falls back to TOFU). The server refuses to start in HTTP mode with no auth configured at all. Malformed tokens.json is fatal.
Posture summary:
| Config | Mid-session switch | Fresh-session impersonation |
|---|---|---|
| None (default after v0.20.0) | ❌ blocked (TOFU) | ❌ blocked for live agents (first-claim guard); ⚠️ possible for idle/stale ids |
| AGENT_COORD_BOUND_AGENT env | ❌ blocked (pre-bound) | ❌ blocked (env is process-local) |
| tokens.json | ❌ blocked (pre-bound) | ❌ blocked (bearer ↔ id) |
Auth posture, briefly
- Threat model: misbehaving / buggy / compromised same-LAN cooperator can no longer assert another agent's identity. Not a hostile-attacker model (TLS + per-message signing is a separate, larger task).
- Don't bind to a public address without TLS. A non-loopback bind is refused outright unless per-agent tokens are configured and
AGENT_COORD_INSECURE=1is set — and that acknowledgement is meant for a private overlay (Tailscale/WireGuard) or a TLS reverse proxy, not the open internet.
Other clients
The script itself is plain Node — no Claude-specific deps — so it ports anywhere you can run a shell command around the agent loop. The MCP protocol doesn't standardize client-side hooks, so the wiring varies:
- Cursor / Cline / Continue / Zed — no first-class lifecycle hooks today. Closest workaround is to put "run
peek-coord.mjsat turn start and treat its stdout as additional context" in your rules/system prompt. Less reliable (model can skip it) but functional. - Custom SDK agents (
@anthropic-ai/sdk,openai, etc.) — easiest fit. Shell out to the script (or inline the ~50 lines of logic) right before each completion call and prepend stdout as a system message. Fully deterministic. - Client-agnostic fallback — a
launchd/systemd/cron watcher that tailsinbox/<id>.jsonland writes unread entries to a file the agent is told toReadon session start. Crude, works everywhere.
License
MIT — see LICENSE.
