Skip to main content
Glama
tatargabor

set-agent-comm

by tatargabor

set-agent-comm

Messaging between agents on one machine: a file-based channel plus a registry, over MCP and a CLI. Tailored to Claude Code.

This is not a greenfield invention: it lifts into code the protocol of a channel between two of our own long-running Claude Code sessions, which we ran in on 400 entries and ~1 MB of traffic since July 2026. Lifting it out adds three things the hand-kept version could not do:

hand-kept channel (until now)

set-agent-comm

the agent wrote with Write/Edita full rewrite of a 555 KB file per message, and out of two concurrent writes one was silently lost

send appends

"who is here?" — recorded nowhere

agents: who exists, where, when they were last alive

watching: Monitor long-poll + a cron patrol + pgrep keep-alive, ~60 lines in CLAUDE.md, with three measured lessons about how TaskList and pgrep get it wrong in both directions

two hooks and one blocking command, wired in by sac install — and the measured lesson that a file watcher cannot wake an idle session, so the long poll stays (see Being told)

Protocol — one file, one writer

Everyone appends to their own file only, and reads the others'. No lost update and no lockfile — after a session dies the lock would stay stuck, and from then on nobody would write.

~/.local/share/set-agent-comm/
  registry.json            who exists, where, when they were last alive
  cursors.json             how far each agent has read the others
  nudges.json              what each seat has already been told about
  channels/<room>/
    web-app#3f9c1a20.md    written by: one SESSION of web-app (see below)
    web-app#7b02e5d1.md    written by: another session of the same project
    api-service#c4e1.md    written by: api-service · read by: everyone else

One entry:

## 2026-08-03T18:42:07.318+02:00 — QUESTION → api-service (re: 2026-08-03T18:40:11.002+02:00)
The text, in markdown.

Types: QUESTION · ANSWER · FACT · REQUEST. The timestamp and the sender are filled in by the server, never by the model — measured on 2026-07-24 on the hand-kept channel: both agents were guessing the date (off by +6 and +1.5 hours), which blinded the "silent for N minutes" condition. The part is the addressee and is optional (see Who a message is for); entries written before it existed read as broadcasts, which is what they were.

Related MCP server: CC2CC

Install

git clone https://github.com/tatargabor/set-agent-comm
cd set-agent-comm
npm install                       # a single dependency: @modelcontextprotocol/sdk
npm test                          # 84 tests + the two-agent smoke test
npm install -g .                  # optional: puts `sac` and `set-agent-comm-mcp` on the PATH

Once per project, in stdio mode (this is the default). ⚠ From here on the directory changes: these two lines belong to the project you want on the bus, not to this repo. Type them into that project's own Claude Code session, with the ! prefix, which runs them right there:

! claude mcp add agent-comm -e SET_AGENT_ROOM=team -- set-agent-comm-mcp
# without a global install: -- node /path/to/set-agent-comm/src/stdio.mjs

! sac install team                # the two hooks that make sure a message is NOTICED

Why from inside the session, rather than from any terminal: the working directory is the identity here. sac install takes the agent's name from it, and bakes it — together with the absolute path of every hook and of the sac wait command inside the skill — into .claude/settings.json and .claude/skills/agent-comm/. In the session that directory is the project by construction. In a terminal it is wherever you happen to be standing, and a hook wired in under the wrong name does not fail: it fires, and checks in as somebody else. (From a plain terminal it works just as well — cd into the project first, and read back the name it printed.)

It takes effect at the next session start — a SessionStart hook is read when a session begins, so restart or /resume afterwards. sac install prints exactly that, and the MCP line to go with it. (Both commands are safe to re-run: install updates its own entry instead of adding a second copy, and takes a backup before it writes.)

The agent's name comes from the project's directory name (override with SET_AGENT_NAME).

Two sessions in one project — seats

The directory name identifies the project; a seat identifies the session inside it. The seat name carries the session id — web-app#3f9c1a20 — so a name says exactly which session it is, and it can be matched against the session a Claude Code window reports for itself. The id comes from CLAUDE_CODE_SESSION_ID, which the MCP server process, the SessionStart hook and every sac call inherit alike: nothing to configure, nothing to mistype, and no agent can write in another's name.

The trade-off, chosen deliberately: a name is good for one session, so a restart starts a new file and the room keeps the files of past sessions. What has content is history and stays; the empty files of dead sessions — a session that announced itself and never wrote — are cleaned up by the SessionStart hook.

What this buys, measured on 2026-08-04 in the live wpc-atlas room, where all three failed silently:

before

with seats

the two sessions wrote

into the same file

each into its own

inbox

skipped that file as "my own" → they could never receive each other

delivers it, marked sibling: true

the read cursor

shared — whichever read first marked it read for the other

one per seat

The reader gains from it too: the room used to carry "do not regenerate yet" (11:31) and "already regenerated" (11:46) under a single sender name — the receiving agent answered the wrong one and had to say so. Now the sender is wpc-pont#968f89d7 or wpc-pont#526b22ce.

A new session does not get the project's older history as unread mail — but what was written in the last hour is delivered to it. ⚠ Measured on 2026-08-04 at 23:09, and it cost the very message this was built for: a session sent a detailed request at 22:38, the other side was resumed half an hour later — and a resume means a new session id, hence a new seat, whose cursor marked that request read before anyone had seen it. Half an hour is not history; it is the other half of a conversation. agents lists the live seats in the live field and their full session id in seats; a caller with no session id (cron, a bare terminal) gets no seat of its own, and send then warns that someone else writes into the same file.

Several rooms

SET_AGENT_ROOM accepts a comma-separated list (-e SET_AGENT_ROOM=team,design) when one project talks to different partners in separate conversations. The hook then sets up every room, and there is no default room: send without an explicit room fails, naming the rooms you are in. Picking the first one would deliver a message to the wrong audience silently — and that cannot be taken back.

Who a message is for

A room of two needs no addressing: everything in it is for the other one. A room of four does. ⚠ Measured on 2026-08-05 across the wpc-promo / wpc-atlas / wpc-demo rooms: a message aimed at one sibling session woke every seat in the room, and each of them spent a full turn establishing that it was not being spoken to.

So send takes an optional to — a seat (wpc-atlas#3f9c1a20) or a project name (wpc-atlas, meaning every session of it, on every machine):

woken (sac wait, the Stop hook)

receives it in inbox

no to — broadcast

everyone in the room

everyone

to: ["wpc-atlas"]

every session of that project

everyone, forMe: false for the rest

to: ["wpc-atlas#3f9c1a20"]

that one session

everyone, forMe: false for the rest

Addressing decides who is woken, never who may read. A non-addressee still gets the entry — marked forMe: false, and unreadForMe counts what is genuinely its own. Hiding it would be the more expensive mistake: a reader who cannot see what the other two agreed on is how two sessions do the same work twice.

The asymmetry between the two failure modes is deliberate. Omitting to reaches everyone — one turn too many, an annoyance. A to that names nobody in the room would reach no one, and a room full of readers with nobody woken is indistinguishable from a quiet room. Hence a name that matches no participant fails the send, at the writer, where it can still be fixed, and the error lists everyone who could have been meant.

sac send atlas QUESTION "Are you the window with the atlas open?" --to wpc-atlas#3f9c1a20

Push: the SessionStart hook

sac install writes it into the project's .claude/settings.json; by hand it is:

{ "hooks": { "SessionStart": [ { "hooks": [ {
  "type": "command",
  "command": "SET_AGENT_ROOM=team node /path/to/set-agent-comm/hooks/session-start.mjs"
} ] } ] } }

It takes the session's seat, checks in to the registry, puts the others' files — a sibling session of the same project included — on Claude Code's native file watcher (watchPaths), and prints any unread messages at the start of the session. It does not watch our own file: that would be a self-wake loop. At startup it also tells the session what its name on the bus is and which other sessions of the project are live — otherwise the agent would sign its messages with the bare project name in the text.

Being told: delivery is not the same as noticing

Measured 2026-08-04 between two wpc-pont sessions: delivery worked and nothing happened. The message was in the room, unread, with the right cursor — and the other session sat idle at its prompt, because nothing told it. watchPathsFileChanged does fire while a session is idle, but it cannot start a turn; it only leaves context for the next one. Two gaps, two answers:

the other agent is

mechanism

what it does

working

Stop hook (hooks/stop.mjs)

it may not end the turn with unread mail — decision: "block" sends it back with the room named

idle

sac wait inside a Monitor

the only thing that starts a new turn: every message is an event in the chat

Both hooks are wired in by one command, run in the project — from its own Claude Code session, for the reason given under Install:

! sac install team                # --dry-run first if you want to see it

It adds them to .claude/settings.json, leaves every other hook alone, takes a backup before writing, and a re-run updates its own entry instead of adding a second copy. (Measured need: on a live project the Stop hook was simply forgotten in a settings file holding a dozen hooks — and from the outside a forgotten hook looks exactly like a quiet room.)

It also installs a skill into .claude/skills/agent-comm/. The tools are a capability and need no skill; the skill carries the protocol around them, which does not fit into a hook's one-liner: answer even when a message is not for you (silence looks the same as not noticing), agree before two sessions of one project touch the same files, and unread the moment you notice you swallowed something. The watch command is substituted in at install time — a skill is a static file, and an agent guessing at a path is an agent that silently does not watch.

The SessionStart note tells every session to arm that watch, in full:

Monitor({ command: "… sac wait <rooms>", description: "agent-comm inbox", persistent: true })

⚠ This sentence was missing until 2026-08-05, and it was the weakest link in the chain: a mechanism nobody switches on is indistinguishable from one that does not exist.

Both wake a session only for what is addressed to it — a broadcast included, since that is addressed to everyone. An entry aimed at another seat stays unread and waits for the next inbox; it does not start a turn and does not hold one open.

Both only ever look: advance: false, so a notification never marks a message read — a monitor firing while the agent is busy must not swallow it. And the Stop hook nudges once per entry: Claude Code has no stop_hook_active field, so a hook that blocked on every unread message would trap an agent that does not read it. Blocking is a strong move; it is spent on saying something new.

CLI

sac install <room> [--dry-run]      wire both hooks into this project's settings.json
sac agents                          who exists, who is alive
sac rooms                           the rooms — and how far each one reaches
sac send <room> <type> "text"       entry (append)
     [--to <seat|project>[,…]]      … addressed: ONLY they are woken (default: everyone)
sac inbox <room>                    new messages from others (marks them read)
sac peek <room>                     the same, without moving the cursor
sac unread <room> [n]               make the last n messages unread again
sac history <room> [n]              read back
sac wait [--once] [room…]           block until a message arrives (for a Monitor)
sac watch-paths <room>              the files to watch (for the hook)
sac register <room>                 check in to the registry (for the hook)

sac relay use <url> --secret <s>    point this machine at a relay (see Across machines)
sac relay status                    the relay, and the rooms bridged to it
sac invite <room> --for <device>    mint an invite for ONE room  [--ttl <seconds>]
sac join sac-join:<code>            accept one, on the other machine
sac sync [room…]                    push and pull once, without blocking

MCP tools

agents · rooms · send · inbox · history — the from field is filled in by the server, so an agent cannot write a message in someone else's name. send takes an optional to (see Who a message is for). On an inbox entry sibling: true means it came from another session of the same project and forMe: false that it was addressed to someone else — unreadForMe counts the ones that are yours. In agents the live field names the project's currently live sessions, and seats carries their full session id.

Why stdio is the default, when our set-designer uses HTTP

We took over the structure of our set-designer MCP server — one core (tools.mjs), two thin transports — but the default mode differs, and for a reason: set-designer has one global state, whereas here we have to know who writes.

  • stdio: Claude Code starts the client with its own cwd → identity comes from the project directory, for free and unforgeably.

  • HTTP (npm run http, 127.0.0.1:7510): every client arrives at the same port, so identity lives in the URL path (/mcp/web-app) — that is, in the project's MCP config, not in a parameter the model could choose per call. Use it when you need a daemon, or when a non-Claude-Code client connects too.

Scope — what this DELIBERATELY cannot do

  • Local by default. No auth, no network, no server to operate. Reaching another machine is opt-in and lives in a separate layer — a bridge plus a relay (see below) — which is how the original "that will be a separate protocol, not an extension of this one" decision was kept: the local protocol below did not change to make it possible.

  • Not an ant farm. It is not a task dispatcher and not an orchestrator: two (or N) human-led sessions talk in it.

Across machines (optional)

The local rules are unchanged: every machine keeps its own append-only log, and that log is the source of truth. On top sits a bridge (in the client) and a relay (a small server).

machine A                    relay (Railway, VPS, Tailscale…)        machine B
  send → local file  ──push──►  encrypted entries, 7-day retention  ──pull──►  local file
  sac wait  ◄──────────────────  long poll  ────────────────────────────────►  sac wait

An incoming entry is appended to the remote writer's file in the local room, so from that moment inbox, the read cursor, the Stop hook and the skill work on it unchanged — nothing downstream had to learn that a message can come from another machine.

Handshake

# on the machine that operates the relay
sac relay use https://comm.example.com --secret $RELAY_SECRET
sac invite atlas --for "zoli-mbp"       # → sac-join:…  (valid 15 minutes; --ttl <seconds>)

# on the other machine — nothing else is needed, not the relay secret
sac join sac-join:…
sac install atlas                       # hooks + skill, as locally

An invite reaches exactly one room. The token it turns into is stamped with atlas, and the relay checks that stamp on every call: with it you can neither post into nor read another room on the same relay (403, naming the room the token is actually for). This is what makes it sane to invite a colleague onto your own relay — they arrive in the room you meant, and the rest of it stays invisible to them. sac rooms shows, on each machine, which rooms it can reach and under what name.

Hand the invite over out of band (Signal, a call). It carries the room key, and that key is what keeps the relay unable to read the room — send it through the relay and that is gone.

The relay secret never travels: it lives only on the machine that mints invites (in ~/.local/share/set-agent-comm/relays.json, mode 600) and the joining device never sees it. What the device gets is a token good for 365 days (RELAY_DEVICE_TTL_DAYS) — long enough that working together is not interrupted by an expiry, which was the point.

Running the relay

RELAY_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))") \
  npm run relay          # PORT defaults to 7511

On Railway: point it at the repo, set RELAY_SECRET, done — npm start runs the relay and PORT is supplied by the platform. Nothing else is platform-specific: the same process runs on a VPS, in Docker, behind Tailscale (no public endpoint at all), or on localhost for a test.

Everything else has a default, and the defaults are the intended setup — RELAY_SECRET is the only variable you have to set. RELAY_HOST (default 0.0.0.0) binds the listener: set it to 127.0.0.1 when something in front of it terminates TLS, so the port is not reachable on its own. The rest — RELAY_RETENTION_HOURS, RELAY_DEVICE_TTL_DAYS, the RELAY_LIMIT_* and RELAY_MAX_ROOM_* ceilings — are described where they matter, under What the relay refuses.

What the relay is, and is not

not the source of truth

lose it entirely and no message is lost: the machines re-upload, and duplicates are dropped by entry id

not an archive

7-day retention (RELAY_RETENTION_HOURS). An archive would have to be operated — which is what we are avoiding

not a reader

bodies are AES-GCM ciphertext; the room key never leaves the participants' machines. The relay decides who may post, never learns what — this is measured, not asserted (test/relay.test.mjs)

stateless

tokens are HMAC-signed, so there is no database and no volume. The cost, stated: a single token cannot be revoked on its own — rotating RELAY_SECRET invalidates all of them and everyone re-joins

What the relay refuses

A relay on the open internet is reachable by everyone, so the little it does, it does before anything else:

an unencrypted URL

the device token travels in a header on every call, so the client refuses plain http:// outright — an invite cannot talk it into one either. The exception is a link that is already encrypted or never leaves the house: loopback, .local, .ts.net (Tailscale), and RFC1918 / CGNAT addresses

a flood

per minute: 10 joins per IP, 120 posts and 60 polls per device token (RELAY_LIMIT_JOIN / _POST / _POLL), answered with 429 and a retry-after. A long poll is one request for its whole 25 seconds, so a normal participant never comes near it

another room

a device token carries the room it was issued for; a call about any other room ends in 403 before the body is read

another name

the namespace is in the token too, so a device cannot post as web-app@some-other-machine. The name is decided by the invite, not by whoever redeems it — otherwise a joiner could ask for a namespace already in use and write under it

a replayed invite

an invite is single-use — its jti is remembered until it would have expired anyway

a name that is a path

writer and ts become a file name and a header line on every receiving machine. Anything carrying a separator, a control character or a .. segment is dropped — by the relay and, independently, by the receiver

a squatted id

entries are deduplicated on (writer, ts) derived at the relay, never on the id the client sends. The id is sha256(writer|ts) — predictable — so accepting it would let a member pre-claim the ids of someone else's future entries and have the real ones dropped as duplicates. Silently

an unbounded room

5000 entries and 64 MB per room (RELAY_MAX_ROOM_ENTRIES, RELAY_MAX_ROOM_MB), oldest first. Time-based retention alone is not a ceiling: at the post limit one valid token is half a gigabyte a minute, and this is all in memory

Who wrote it is part of what was written. The sender and the timestamp travel in the clear — the relay routes by them — so they are bound to the ciphertext as additional authenticated data (entryAad). Change either in transit and the decrypt fails. Without that binding the relay could re-attribute any entry it forwards without ever having the room key: take a real ciphertext from A and serve it as B's. The body would decrypt perfectly, because the body never said who wrote it — and "an agent cannot write in someone else's name", which the local bus gets for free from the working directory, would have stopped at the network's edge.

What it deliberately does not protect against: someone who holds a valid device token can flood their own room within the limits, and a token cannot be revoked one by one (that is the price of being stateless — see the table above). Both are answered by rotating RELAY_SECRET, after which everyone re-joins. And the relay still sees metadata: who writes, when, and how much. It cannot read a word of it, but "cannot read the room" is not the same as "cannot see the traffic".

Names say how much to trust them

web-app#3f9c1a20            local   → unforgeable (cwd + session id)
web-app@macmini#7b02e5d1    remote  → only as good as the device token behind it

The relay enforces the namespace in the token: a device cannot post under another machine's name. But @macmini is a weaker claim than a local name, and the reader is entitled to see which one it got.

Prior art and relatives

The reuse-before-build scan (2026-08-03) found these before we wrote a line: AMQ (Maildir, MIT — the atomic JSON write pattern comes from it), patchcord (cross-machine, but needs Supabase + a server), agent-com, claude-peers-mcp. Deciding on our own version was deliberate: developability — integrating with set-core's bug/release flow does not fit into a third-party package.

License

MIT — see LICENSE.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • User-owned memory for AI agents, Copilot, Claude, IDEs, CLIs, and chat apps over remote MCP.

  • Local-first RAG engine with MCP server for AI agent integration.

  • Real-time chat hub for AI agents — Claude Code, Cursor, Cline, Codex over MCP or REST.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tatargabor/set-agent-comm'

If you have feedback or need assistance with the MCP directory API, please join our Discord server