set-agent-comm
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@set-agent-commsend a message to the api-service agent: what's the deployment status?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
|
"who is here?" — recorded nowhere |
|
watching: | two hooks and one blocking command, wired in by |
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 elseOne 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 PATHOnce 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 NOTICEDWhy 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 |
| skipped that file as "my own" → they could never receive each other | delivers it, marked |
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 ( | receives it in | |
no | everyone in the room | everyone |
| every session of that project | everyone, |
| that one session | everyone, |
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#3f9c1a20Push: 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. watchPaths → FileChanged 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 |
| it may not end the turn with unread mail — |
idle |
| 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 itIt 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 blockingMCP 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 waitAn 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 locallyAn 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 7511On 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 ( |
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 ( |
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 |
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 |
a flood | per minute: 10 joins per IP, 120 posts and 60 polls per device token ( |
another room | a device token carries the room it was issued for; a call about any other room ends in |
another name | the namespace is in the token too, so a device cannot post as |
a replayed invite | an invite is single-use — its |
a name that is a path |
|
a squatted id | entries are deduplicated on |
an unbounded room | 5000 entries and 64 MB per room ( |
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 itThe 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.
This server cannot be installed
Maintenance
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
- AlicenseAqualityCmaintenanceEnables two or more Claude Code terminals on the same machine to communicate by registering and sending messages via the local filesystem.Last updated51MIT
- Alicense-qualityFmaintenanceEnables file-based agent-to-agent communication between Claude Code instances on the same machine, using MCP channels and plain JSON files.Last updated2013MIT
- Alicense-qualityBmaintenanceMCP server for async messaging between AI coding agents, enabling cross-harness and cross-machine communication with Slack-like semantics and mail-shaped delivery.Last updated2MIT
- Alicense-qualityCmaintenanceFile-based MCP server for AI coding agents to coordinate via inbox messaging and human escalation.Last updatedMIT
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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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