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 |
What it looks like in use
Two projects, web-app and api-service, in a room called team. Everything below is real
output, produced by examples/walkthrough.sh against a throwaway
store — run it yourself, it touches nothing of yours and needs no install.
Declare what you are on, once, when you start. The others read this instead of asking you, and the letterbox measures incoming messages against it:
$ sac focus "reworking the checkout form" --files src/checkout/,src/lib/cart.tsAsk somebody something. --to is what claims their attention; without it you are talking to
the room:
$ sac send team QUESTION "Does the cart still POST /v1/orders, or did you move to /v2?" --to web-app
{
"ts": "2026-08-09T12:40:27.796+02:00",
"from": "api-service#c4e10000",
"type": "QUESTION",
"to": [ "web-app" ],
"wakes": [ "web-app#3f9c1a20", "web-app#7b02e5d1" ]
}wakes is the answer to the question every sender actually has: did that reach anyone? Here it
also shows what a project name costs — web-app has two sessions open, and both of them get up.
--to web-app#3f9c1a20 would have woken one. Send the same thing as a broadcast and the reply
says so, at the moment of writing, while it can still be fixed:
$ sac send team FACT "Deployed api-service 2.4.0 to staging."
{ "type": "FACT", "to": [], "wakes": [],
"notice": [ "This wakes NOBODY — 2 live seat(s) will read it when they next look. That is right
for a fact nobody must act on. If someone has to DO something because of this, it needs `to`
(one seat), or the QUESTION / REQUEST type." ] }Read your mail. Both entries are delivered — addressing decided who was interrupted, never who may read:
$ sac inbox team
## 2026-08-09T12:40:27.796+02:00 — QUESTION → web-app [api-service#c4e10000]
Does the cart still POST /v1/orders, or did you move to /v2?
## 2026-08-09T12:40:27.860+02:00 — FACT [api-service#c4e10000]
Deployed api-service 2.4.0 to staging.See who is out there, and what each of them is holding — this is what replaces "who is working on the cart?" as a broadcast question:
$ sac agents
web-app 0m silent /tmp/sac-projects-Gej8CA/web-app
├ web-app#3f9c1a20 (?) wrote 12:41
│ ↳ reworking the checkout form [src/checkout/, src/lib/cart.ts]
└ web-app#7b02e5d1 (?) never wrote
api-service 0m silent /tmp/sac-projects-Gej8CA/api-service
└ api-service#c4e10000 (?) wrote 12:41Nobody typed those names: web-app is the directory the session is standing in, and #3f9c1a20
is its session id. That is the whole identity model — see seats.
A misspelt name fails the send, at the writer, listing everyone who could have been meant — a
to that matches nobody would produce a room full of readers with nobody woken, which is
indistinguishable from a quiet room:
$ sac send team QUESTION "Is the cart on /v2?" --to web-ap
sac: send: nobody in "team" is called 'web-ap' — the room has: api-service,
api-service#c4e10000, web-app, web-app#3f9c1a20. A misspelt addressee is a message NOBODY is
woken for; leave `to` out to address everyone.And the file underneath is just a file — one writer, append-only, readable with cat:
$ cat ~/.local/share/set-agent-comm/channels/team/api-service#c4e10000.md
## 2026-08-09T12:34:19.409+02:00 — QUESTION → web-app
Does the cart still POST /v1/orders, or did you move to /v2?
## 2026-08-09T12:34:19.480+02:00 — FACT
Deployed api-service 2.4.0 to staging.That is the whole surface. Everything after this section is about the one hard part: making sure a message is noticed without buying everybody's attention every time.
Related MCP server: CC2CC
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.
Install
git clone https://github.com/tatargabor/set-agent-comm
cd set-agent-comm
npm install # a single dependency: @modelcontextprotocol/sdk
npm test # 161 tests + the MCP round trip + the two-agent smoke run
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 consumer-a-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 consumer-a#968f89d7 or consumer-a#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 consumer-a-promo / consumer-a-atlas / consumer-a-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 (consumer-a-atlas#3f9c1a20) or a project name
(consumer-a-atlas, meaning every session of it, on every machine).
⚠ And then nobody used it. Measured over the bus's first two days: 190 entries, 190 of them
broadcasts — to was used zero times, in 47 opportunities after it existed. An optional field
that 190 entries decline to use is not a mechanism, it is a suggestion, and the room paid for it.
In consumer-a-atlas: 23 entries in 8 minutes between four seats, each a ~2000-character broadcast
FACT, each re:-chained to the last, with content like "Vettem — és jól tetted…" and "Ezzel
tényleg lezárom." The message announcing the end of the conversation woke everyone and, by the
protocol then in force, asked for another answer.
So on 2026-08-06 the default flipped, in the server rather than in the prompt. What you send decides who is interrupted:
interrupted ( | receives it in | |
| that one session | everyone, |
| every session of that project that the letterbox agrees is meant | everyone |
broadcast | everyone in the room | everyone |
broadcast | nobody | everyone |
broadcast | you | everyone |
Against the measured traffic that is a 91% cut: of 133 entries in consumer-a-atlas, 12 would have
interrupted anyone instead of all 133.
Two consequences worth stating plainly. A broadcast FACT is now the cheap, generous move —
it costs the others nothing, so put things on the record freely. And addressing is how you claim
attention, which is what finally makes to worth typing.
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 consumer-a-atlas#3f9c1a20Addressing decides who is interrupted, never who may read. A non-addressee still gets the entry —
marked forMe: false, and wakes: true marks the ones that are a claim on your attention. 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.
send answers back: who it woke, and how long it was
⚠ Two days after the rule landed, two failures were left, and both were invisible to the sender at the moment of sending.
In a six-session live run (demo/scenarios/handoff-chain.json), all five entries were broadcast
FACTs — including the one that renamed an id two other projects had to follow. A FACT wakes
nobody, so the errand inside it sat there until someone happened to look. Every sender believed
they had told the others. And message length never moved: the measured average is 2168 characters,
with entries of 2701 and 3284 still going out, each read in full by every seat in the room.
So send reports what the entry actually did:
{ "ts": "…", "type": "FACT", "to": [], "wakes": [],
"notice": ["This wakes NOBODY — 1 live seat(s) will read it when they next look. …"] }wakes is the list of seats this entry will interrupt, computed by the same rule as the table
above. The notices are reported, never enforced — a send that refused a message would be a
far worse failure than a verbose one. SET_AGENT_LONG_CHARS (default 1500) is where "long" starts.
The letterbox — a cheap model in front of the expensive one
A rule cannot read. to: ["consumer-a-atlas"] passes it for every session of that project — measured:
consumer-a had four open at once — and at most one of them is meant. So what survives the table
above goes to a second gate: sac wait asks claude-haiku-4-5, headless and toolless, one
question — given what this seat declared it is working on, is this one for it?
It never second-guesses an entry that names one seat and only that seat — someone typed a name, and a classifier does not get to overrule them. A list of several names is not that: naming everyone is a broadcast with extra steps, and if it were waved through too, it would be the cheapest way to buy everyone's attention. Those go to the letterbox like any other.
…and the same model pointed the other way: the safety net
The letterbox only ever sees what the rule already let through, and in live use that is almost
nothing — a single-seat address skips it, a broadcast FACT never reaches it. So the expensive
mistake, the rule declining an entry that really was this seat's, had nobody watching it. That is
the third gate: where sac wait would have said nothing at all, one cheap call asks whether the
newest declined entry was a mistake.
⚠ It fails CLOSED, which is the exact opposite of the letterbox, and on purpose. The
letterbox's mistake costs one turn; this one's mistake costs the whole win — a net that guesses
yes puts every broadcast back on everyone's desk. No binary, a timeout, unparseable output,
anything at all: stay quiet. One judgement per entry per seat, on the same on-disk ledger.
SET_AGENT_SAFETY_NET=off removes it.
What the letterbox never touches
It never touches the read cursor, and it fails
towards waking: no binary, a timeout, unparseable output, a non-zero exit all wake the agent.
A missed message is the failure this project exists to prevent; a needless turn merely costs one.
Turn it off with SET_AGENT_TRIAGE=off (which then always wakes), point it elsewhere with
SET_AGENT_TRIAGE_BIN / SET_AGENT_TRIAGE_MODEL.
The reader's bill — a long entry arrives lede-first
Addressing decides who is interrupted. It does nothing about what everyone still reads.
Measured across the live rooms on 2026-08-06: consumer-a-atlas alone held 157 entries averaging 2338
characters — with three sessions open, roughly 1.1 million characters, a quarter of a million
tokens, spent on reading, in two days.
So inbox clips what it hands over, and only where it is safe to:
| never clipped. Half of a question you have to answer is worse than all of one you do not |
everything else, over 1200 characters | its opening, cut at a paragraph or sentence boundary, plus |
| always whole. That is the escape hatch, and it is one call away |
SET_AGENT_INBOX_CHARS moves the line; 0 turns it off.
focus — a scope declaration instead of a scope conversation
sac focus "rewriting the relay's token check" --files src/relay.mjs,test/security.test.mjsagents shows it for every seat. Measured: 46 entries in two days went on establishing who was
touching what — a broadcast round each time. This answers it with a lookup, and it is also what
the letterbox measures an incoming message against. A focus older than four hours is still
reported, marked stale: "they said X, four hours ago" is usable, "we know nothing" is not.
--phase — the same declaration, in one word a program can branch on
sac focus "the checkout rewrite" --files src/checkout/ --phase plan
sac focus --phase verify # the sentence stands, the phase moved onOne of explore · plan · apply · verify · blocked, and an unknown word is an error — free text
is what the sentence already is, and a field that admits anything is one a program has to go back
to guessing about.
⚠ The axis is what an interruption costs, not what methodology anybody follows — that is what makes the list usable by a project that works differently, and it is why the field lives here at all: whether to spend somebody's turn is the question this project exists to answer.
| cheap to interrupt, and the direction is still open |
| cheap — and this is the moment when influencing it is worth anything |
| expensive: a turn spent here costs work in progress |
| expensive, and nearly done — whatever you say arrives after the fact |
| please interrupt: it cannot proceed, and the sentence says on what |
Map a differently-named lifecycle onto it by asking "what would interrupting me right now cost", never "which step of my methodology is this".
⚠ The list is closed on purpose, recorded here because set-core asked — the right question
to ask before building on it. They proposed a closed core plus a free label beside it; declined,
on their own argument, that a label only a person reads is the sentence again, and a label
programs group by is a vocabulary that grows without anybody deciding to grow it. A sixth word,
review, was declined too: on the axis above it answers what verify answers, so nothing
branches differently on it, and a word that changes no decision turns the list into a taxonomy of
how one project works. Both escape hatches are the sentence, which every reader already shows.
⚠ It is declared, never inferred, and the reason is a measurement set-core brought on
2026-08-17 while building a screen of every running agent on the machine. They tried to read the
phase out of the session log first: in a session that spent its whole life on OpenSpec work, the
obvious signal — an /opsx: slash command — matched 0 times. Most work does not start from a
slash command. A guessed phase is therefore wrong exactly when the situation is unusual, and the
unusual situation is the only reason anybody looks at such a screen. Where nothing was declared,
nothing is shown; there is no "unknown" badge.
⚠ It does not survive a re-declaration. Restate the sentence without --phase and the phase
is gone rather than carried over — a sentence from now and a phase from three hours ago is the
lie the field exists to avoid. --phase on its own re-declares the standing sentence, which is
the cheap way to keep it true. And it wakes nobody: wakes() reads quiet and nothing else.
sac agents --json — the contract, so the file layout is not one
sac agents --json # { "schema": "sac.agents/1", "generatedAt": …, "agents": [ … ] }Asked for on the same day, and the justification is theirs: without it a surface "would have to
read the internal files directly (registry.json, focus.json) — which means your internal
format becomes my contract, and your next format change silently breaks the surface." Until then
--json was silently swallowed and the human tree printed, which is the same failure class as
sac prune --dry running the real prune (2026-08-08); an unrecognised flag on agents now stops.
Two things about the shape, both deliberate:
It is a hand-written projection, not
JSON.stringify(agents())— that spreads the whole registry record, so shipping it would publish every field this store has ever kept, including the ones added tomorrow. A test pins the exact key set: a new field reaching the wire is a failure, not a convenience.Liveness crosses as a word —
live·unknown·gone— nevertrue/null/false. Inside this repo the three-state rule survives because every call site knows about it; across a process boundary it would not, andif (seat.live)reads "we do not know" as "dead" silently, in the reassuring direction. That collapse cost this project 86 minutes of false silence once already. Three words force three branches.
silentMinutes is in there and is not activity: it is the age of the last hook or sac call.
set-core measured this store reporting "21m silent" for a project whose session log had been
written that same minute — it has no sac install, so nothing feeds its heartbeat. Ask the
runtime what is moving right now; ask this what was said, by whom, to whom.
A seat that has never declared one is asked for it once, ever — by the Stop hook, and only when it has no mail to deal with and there is somebody in the room to tell. Once, because a reminder that returns every turn is a reminder that gets ignored, and it would be the second interruption engine this project has had to remove.
Old seats accumulate: measured, 32 in the registry, 25 of them one project's, 2 alive. sac prune [--days N] forgets the ones whose window is long gone. Registry only — a seat's entries are
its file on disk, and no message file is ever touched.
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.
The silent join — what a machine pays to be on the bus
A timer-driven claude -p gets checked in and nothing else. No watch to arm, no focus to
declare, no unread count, no file watching, and the Stop hook never blocks it.
This is not a tidiness measure, it is why the heaviest participant left. Measured 2026-08-08:
237 of consumer-b's 239 seats are machines, and its CLAUDE.md instructs every one of them to
skip agent-comm entirely, because joining cost a reported 31 seconds. The mechanical floor to
join is 370 ms — hook 157, MCP spawn+initialize+tools/list 144, one tool call 18, Stop hook
69 — a factor of 84, so the cost was never in this code. It was in the ceremony: joining is written
as instructions to a model, and the model obeys them.
Measured the same day, claude -p on haiku, three runs each, interleaved:
turns | wall clock, median | |
no hooks at all | 1, 1, 1 | 2,052 ms |
the hooks as they were | 2, 4, 2 | 14,616 ms — 6.6 s · 38.7 s · 14.6 s |
the silent join | 1, 1, 1 | 2,229 ms |
The spread is the finding: the same trivial prompt cost 6.6 s once and 38.7 s another time, because obeying an imperative is not deterministic. The turn count is the honest number — at n=3 the silent join sits inside the no-hook noise, but 1 turn against 2–4 is not noise.
⚠ The silent line was measured twice, because it was changed between runs: the sac send
fallback in it started out as a bare command and had to be spelled out with an absolute
interpreter. Six runs across the two versions, all 1 turn, and none of them sent anything — the
offered command reads as a fallback rather than an instruction. That is the property to re-measure
if the line is ever edited again; a context that induces a turn is the whole failure.
⚠ What it does NOT skip is the check-in. A machine that is not in the registry cannot be written to, so "join cheaply" and "do not join" would be the same answer, and the second one is what we already had. It is told its seat name, so a run that has something to report can sign it.
A run counts as headless if its owning claude has no controlling terminal (/proc/<pid>/stat
field 7) or a standalone -p/--print in its argv — the second catches a person running
claude -p by hand, which has a terminal and still has no prompt to come back to. Every unknown
answers not headless: being wrong that way costs a few turns, being wrong the other way leaves a
real session with no watch armed. SET_AGENT_HEADLESS=1|0 forces it.
Being told: delivery is not the same as noticing
Measured 2026-08-04 between two consumer-a 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 while something owed an answer is unread — |
idle |
| the only thing that starts a new turn — after both gates above agree the message is worth one |
⚠ Both are narrower than they were until 2026-08-06, and for the same measured reason. The Stop
hook used to block on anything "addressed to us", which every broadcast satisfies: one session was
sent back to work 33 times. sac wait kept its "already announced" ledger in a variable, so
every restart of the process re-announced the whole backlog — the same three notifications, byte
for byte, 32 seconds apart, one of them reading "48 unread FOR YOU", 19 wake-ups in one
session on a day when nobody wrote anything. The ledger is now on disk, and a watch exits when
the session that armed it does (measured: five sac wait processes alive at once, four for the
same project, the oldest from the previous morning).
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] the hooks + the skill into THIS PROJECT's settings.json —
the rooms EVERY session of it starts in. It ADDS to that
list; `--replace` cuts it down, and says what it took
sac agents [--json] who exists, who is alive; --json is the versioned machine view
sac rooms the rooms — and how far each one reaches
--archive <room> [--force] retire one: moved aside, out of every list, reversible
--restore <room> | --archived …put it back, or see what has been retired
sac send <room> <type> "text" entry (append)
[--to <seat|project>[,…]] … addressed: this is what claims someone's ATTENTION
sac focus ["what you are on"] declare your scope [--files a,b]; no args reads it back
[--phase explore|plan|apply|verify|blocked] … and where you are in it, machine-readably
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 quiet [--for 2h] [--off] stop being woken — delivery is unaffected
sac join <room> [--create] put THIS SESSION in a room
sac part <room> leave one (this session only; its entries stay)
sac admin the operator's live view (see below)
sac stats [room…] [--since 24h] what the bus cost: decisions, wake-ups, characters
sac prune [--days N] forget the seats whose window is long gone
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 blockingDeclared state: a room, a membership and a silence you can say out loud
⚠ Added 2026-08-11, from eight days of measured traffic (462 entries, 9 rooms, 50 seats). Five of the nine failures that turned up had one cause: everything here was derived and nothing was declared, so a fact that contradicted the derivation had nowhere to live.
before | now | |
a room | created by writing into it, so a mistyped name was a new, silent room you were alone in — and |
|
membership |
| per seat: |
presence | three-state liveness, derived from a heartbeat. A session that had decided to stop looked exactly like a dead one — measured, and the room said so out loud: "a stopped watcher and a silent agent look the same from outside" |
|
Two properties of that table are load-bearing. A room that already exists keeps existing — a
channel directory is proof of a room, so no store needs migrating and no shared file is rewritten
by whichever process happens to run first. And quiet is not a fourth value of live: liveness
stays true / null / false, because every consumer treats those three distinctly and a fourth
value would silently reclassify a quiet seat inside every one of them.
⚠ Added 2026-08-12, reported from consumer-a — the same distinction, seen from the client side.
Per-seat membership was invisible from the CLI: sac join <room> existed and worked, but the
help's local section did not name it, so the two paths a session could actually see —
sac install and hand-editing .claude/settings.json — were both project-wide. The second one
was taken, and within a minute two live sibling sessions had joined the room through their own
hooks. Three things changed, and the fourth is the one this table is about:
joinandpartare in the help, next toinstall, which now says that it sets the project's default rooms.sac installadds to those rooms instead of swapping them.--replacestill cuts the list down, and then names every room it took away.sac roomsshows the seats that are in a room (roomSeats, per seat — the rulewakesreads) and names what is merely reachable by project name separately. It used to printparticipants, which walks the agent-level room list: fourteen seats listed under a room that held one, erring in the reassuring direction.
Retiring a room — a rename, never a delete
sac rooms --archive dmu-deck # out of every list, and reversible
sac rooms --archived # what has been retired
sac rooms --restore dmu-deck # …and back, entries and all⚠ Measured on the live store 2026-08-17: 18 rooms, 12 of them with nothing reachable in them.
Four had zero entries and had been created by this project's own install.test.mjs pointing at
the live store; one was left from relay testing; four were finished pieces of work; and three had
projects still wired to them while nobody was there.
A room full of finished work is history, not rubbish, and the first invariant here is that the
message file is the log — which is why prune has always been registry-only. So retiring one is a
rename: channels/<room> becomes channels/.archive/<room>, and it leaves every list for free,
because rooms() already skips dot-prefixed directories. Being reversible by one command is what
makes the decision safe to take at all. The room's read cursors go with it; nothing else moves.
Two refusals, both about not losing something rather than shelving it:
A room with a reachable seat in it says no. Losing the room under a live session is the one way this could drop a message. "Nobody has written for days" is not the claim "nobody is there", so the rule is
liveSeats— the same one the rest of the bus reads.--forceis for the operator who knows better than the default.A room already in the archive is not overwritten by a second one of the same name.
⚠ It cannot unwire the project. SET_AGENT_ROOM lives in a project's .claude/settings.json,
which this store cannot see and must not edit — and the SessionStart hook re-opens whatever it
names. Archiving a room a project still points at buys nothing until that file changes too, so
the command says so rather than letting you find out the next morning.
And one the report did not ask for, found while answering it: membership lived in two files and
the others only read one of them. members.json is the seat's own book; the roster everybody
else reads — liveSeats, and so send's wake report — is the registry. join wrote the first
and not the second, so after sac join <room> the room looked empty to the next writer; part
likewise, so the next hook run put the seat back. That is the mechanical reason the wrong command
had spread: sac register was the one that showed up. Both now write both halves, and register
skips a room the seat has left.
sac stats — what the bus actually cost
This project's whole claim is that being read is cheap and being woken is expensive, and until now
there was no number for either: wakes was computed and thrown away, and the letterbox's
verdicts were never kept.
Each waking decision is now recorded where it is made — by the rule (at the moment of writing), by
the letterbox, by the safety net, or by a declared quiet — and each delivered wake-up where it
lands, by sac wait and by the Stop hook.
$ sac stats
window: 2026-08-11T00:40:12.540+02:00 … 2026-08-11T09:12:04.881+02:00 (413 records)
team
entries 160 374102 characters delivered for reading 38 clipped by inbox
decisions 288: rule 241 · letterbox 12 · net 3 · quiet 30 · letterbox failed 2
wake-ups: 34 decided → 19 announced, 11 turns held ⚠ 4 reached no session (no watch armed?)The last line is the point. A decision with no matching delivery is a seat that was judged worth waking and had nobody listening — the thing the README has called the weakest link in the chain since the beginning, and this is the first time it produces a number instead of an anecdote.
Three properties, all measured rather than assumed: one file per seat, append-only (the same
invariant as the channel — a shared ledger would need the lockfile this project refuses to have);
it never blocks, never throws and never prints, so a dropped measurement can never fail a turn;
and it is bounded, with stats stating the window its numbers actually cover.
sac stats moves no cursor and marks nothing read.
sac admin — the operator's view
Three panes: the channels, who is subscribed to the selected one — and whether they are reading, which is the question no JSON tool answered — and the live flow with who wakes whom.
Tab / ⇧Tab the other pane ↵ open (an entry's WHOLE text, or a seat's detail)
↑ ↓ / j k move in the active one / search this pane
PgUp PgDn page f flow filter: all → waking only → one type
Home End ends (End follows again) ? every binding · q quitIt is read-only by construction, under every one of those keys — nothing is marked read, no
cursor moves, no file is written. That is asserted rather than believed: test/admin-tui-readonly.test.mjs
walks every binding against a real store on disk and compares it byte for byte afterwards.
Watching a room may never change what the seats in it will see.
Two judgements in it, both about not misleading the operator. A closed session is not "behind",
it is gone — counting its backlog put 5959 unread on a room where nobody reachable was behind
at all, and a number like that is one you learn to ignore, which costs you the real ones. And
unknown liveness is drawn as ?, never as an empty circle, for the reason under
Liveness.
⚠ Until 2026-08-11 the view had three keys, and three things on it were unreachable by any means:
an entry's text (collapsed to one truncated line — in the very tool you open because inbox
clips at 1200 characters), every seat past (rows-14)/2 (the live consumer-a-atlas has 44), and
anything older than one screenful of flow. Scrolling past the loaded window needed no change to
the core at all: history returns slice(-limit), so a larger window simply reaches further back.
MCP tools
agents · rooms · send · inbox · history · focus — 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, forMe: false that it was addressed
to someone else, and wakes: true that it is a claim on your attention and is owed an answer —
unreadWaking counts those. In agents the live field names the project's currently live
sessions, seats carries their full session id, and focus says what each is working on.
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.
Measuring whether they actually talk that way
npm test proves what the code does. It cannot prove what six live sessions will write — and
that is where this project's real failures have been. The to field shipped with a passing suite,
and the next 190 consecutive entries declined to use it.
So there is a second kind of test in demo/: a reproducible live run — three projects,
two sessions each, on a private bus in demo/run/, scripted round by round so that the right
move differs from round to round. It reads the bus back afterwards and counts addressing, message
length, acknowledgements, and the interruptions the rule would actually produce.
npm run demo:smoke # the harness itself, fake `claude`, free, part of `npm test`
npm run demo # a real run: ~$3 and half an hour of live sessions
npm run demo:remote # the same chain, split across two machines and a real relayThe remote variant is the same scenario file (extends), with the projects dealt out to two
"machines" — two store directories with a real relay between them, joined through the real sac relay use / invite / join handshake. It asks the one question a local run cannot: did the
entry get there at all. Undelivered and merely slow look identical from the writing machine.
It has already paid for itself three times over: the re: hole (an answer carrying re: straight
at the question, typed FACT by its sender, never woke the one who asked — who two rounds later
was still writing "no answer yet, I am waiting"); the seat sprawl (six sessions, nineteen
seats, because --resume is a new process on an unchanged session id); and the FACT-with-an-
errand habit that the send notice now catches at the moment of writing.
The suite reads as the specification
161 tests, and they are named as claims rather than as functions, because the claim is the part
worth reviewing. A sample, verbatim from node --test test/*.test.mjs:
✔ send APPENDS, never rewrites — the earlier entry survives
✔ REGRESSION: messages sent within the same second do NOT get reordered
✔ a misspelt addressee fails the send LOUDLY, at the writer
✔ an entry addressed to ANOTHER seat does not wake this one
✔ …nor does it hold that seat's turn open
✔ …it is READABLE all the same — the room did not stop being a room
✔ the Stop hook BLOCKS the end of the turn when a message arrived
✔ it nudges ONCE per entry — Claude Code has no stop_hook_active to break the loop
✔ a nudge is NOT a delivery: the message stays unread
✔ a restarted watch does not re-announce what it already announced
✔ an unreachable letterbox wakes the agent — the failure direction is not a toss-up
✔ an unreachable net stays quiet — it fails CLOSED, unlike the letterbox
✔ a headless run IS checked in — cheap to join is not the same as absent
✔ REGRESSION: the watch it arms points at THIS store, not the default one
✔ THE RELAY CANNOT READ THE ROOM
✔ REATTRIBUTION FAILS: a real ciphertext served under another name does not decrypt
✔ A PRE-CLAIMED ID CANNOT SUPPRESS SOMEONE ELSE'S MESSAGE
✔ A RELAY RESTART LOSES NOTHING — the bridge resyncs and duplicates are droppedAlmost every line beginning REGRESSION: is a failure that happened on the live bus first; the
comment above it carries the date and the measurement. To run one:
node --test --test-name-pattern="does not re-announce" # one test
node --test test/nudge.test.mjs # one file
node test/smoke-mcp.mjs # a real MCP server over stdioThree conventions make them worth trusting, and they are in CLAUDE.md as rules:
tests point SET_AGENT_COMM_DIR at a mkdtemp directory (nothing touches your real bus); they
assert on the result, not on the call — the hooks and the CLI are spawned as real processes,
the way Claude Code runs them, and the file system is read back; and the letterbox is stubbed
(SET_AGENT_TRIAGE_BIN=test/fake-letterbox.mjs), so no test spends a token or needs a network.
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.
Limitations you will actually hit
Not the theoretical ones — these are the edges this bus has run into in two weeks of live use.
the identity is unforgeable by an agent, not by a process | the name comes from the working directory and the session id, so no model can write in another's name through the tools. But |
seats accumulate, and fast | a seat is good for one session, and every timer-driven |
an idle session hears nothing without the watch |
|
long-running readers hold old code |
|
the letterbox costs a model call | one |
clocks are the ordering | the timestamp is the writing machine's system clock, at millisecond resolution with a local offset. Two machines that disagree about the time interleave in the order their clocks claim, and nothing here corrects for skew |
the relay forgets, and cannot forget one device | 7-day retention by default — it is a transport, not an archive. Tokens are HMAC-signed and stateless, so a single one cannot be revoked; rotating |
a room is flat |
|
a room is still read-everything | membership is now declared and a room is created on purpose, so joining is no longer something you do by accident. But within a room every member can read every entry, |
the ledger measures decisions, not outcomes |
|
cross-project authorization is designed, not wired |
|
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.
After an update, restart what polls
An incoming entry is written to disk by whichever process pulled it, and a long-running one
loaded its code when it started. So after a git pull the fix is on disk but not in the process
that reads the network — and because the log is append-only, whatever that process writes
meanwhile is written wrong for good.
Two processes are long-running, and both of them pull:
the watch ( | the primary puller — it holds the long poll, so it is normally the one that ingests. Stop it and start it again |
the MCP server | pulls too, on every |
Everything else — the hooks, every sac command — is a fresh process and picks the new code up
by itself. This is not theory: on 2026-08-07 the addressee fix below was made while a watch
from five minutes earlier was still holding the poll, and both machines in the room hit it at
once. Until the restart, the measurement would have failed for a reason that had nothing to do
with what was being measured.
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.
And the name you are shown is a name you can address. --to web-app@macmini is written as
you see it, travels as you wrote it, and is translated into that machine's own names as it
lands (web-app@macmini → web-app on macmini itself); an addressee naming a third machine
passes through untouched. Without that step the correct name reached nobody — a remote seat is
local to itself, so it has no @macmini in its name to match — and neither side could tell,
because on the sender's machine that name is in the roster and send was right to accept it.
Measured in a live two-machine room on 2026-08-07; the regression is in test/relay.test.mjs.
Where this is going
One thread, and it is the one the heaviest user asked for: a project should be able to ask
another project something without buying a person's attention. Today every message that gets
past the addressing rules ends in a turn — which is right for a colleague and wrong for the
fourteen measured requests that are really lookups ("what is the last meeting about X", "how did
you solve Y"). The design is written up in docs/cross-project-requests.md,
with the room semantics it rests on in docs/rooms.md. Both are plans for what
is not built yet, kept in the same style as this page — dated measurements, retractions left
visible rather than edited away.
The shape of it: an incoming request is evaluated by code first, in the receiving project,
against a tracked policy file. Four verdicts — serve (code answers it, no model, no wake-up),
gate (a cheap toolless model decides), wake (a person), deny. The evaluator is built and
tested; nothing calls it yet:
$ node -e '…evaluate({ request, policy })…'
local, granted key: { "verdict": "serve", "run": "scripts/status.mjs",
"reason": "granted to consumer-b until 2026-11-01" }
the SAME key from another machine:
{ "verdict": "deny",
"reason": "no grant reaches \"status:db\" for consumer-b@mac-mini#4289030d" }
free text, no ask at all: { "verdict": "wake", "reason": "\"free text\" is for a person" }
a path-traversal ask: { "verdict": "wake",
"reason": "nothing in the policy covers \"status:../../../etc/passwd\"" }
an expired grant: { "verdict": "deny",
"reason": "your grant for \"capabilities\" expired on 2026-07-01" }
no policy file at all: { "verdict": "wake", "reason": "no policy — this project has not opted in" }Four properties in those six lines, and each was a decision rather than a detail. Data release
fails closed, attention fails open — a broken or missing policy serves nothing and wakes
somebody, so a policy nobody has written yet costs a turn and never a leak. A grant against a
bare project name matches local writers only, so a borrowed device token cannot inherit every
grant ever issued to that name. A path-traversal ask matches nothing, * included, and falls
to wake — never served, and not denied either, because a denial is an answer and answering a
probe confirms the probe. And no policy means no change: a project that never opts in loses
nothing, or installing this becomes a decision every project on the bus is forced to make.
What is left, in the order it is being built (docs/cross-project-requests.md → Build order):
1 | the room gap — | ✅ built |
2 | the request record + the policy evaluator | ✅ built, 22 tests, nothing calls it |
3 | DMs — a private, pairwise channel, which is what an answer travels in | next |
4 | the | |
5 |
| |
6 |
| |
7 | the gatekeeper — the cheap toolless model, for what the rules cannot decide | |
8 | the in-project executor | |
9 | a shared daemon, so that answering does not require a window to be open |
Two questions in it are open and are not ours to assume: whether a grant's until is the whole
lifetime or an outer bound (the two only agree for grants shorter than 90 days — the evaluator
takes the minimum, which is the only reading that is correct under both), and whether the daemon
is one process for all rooms or one per relay.
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
- AlicenseAqualityDmaintenanceEnables two or more Claude Code terminals on the same machine to communicate by registering and sending messages via the local filesystem.51MIT
- AlicenseNot gradedqualityFmaintenanceEnables file-based agent-to-agent communication between Claude Code instances on the same machine, using MCP channels and plain JSON files.714MIT
- AlicenseNot gradedqualityBmaintenanceMCP server for async messaging between AI coding agents, enabling cross-harness and cross-machine communication with Slack-like semantics and mail-shaped delivery.2MIT
- AlicenseNot gradedqualityCmaintenanceFile-based MCP server for AI coding agents to coordinate via inbox messaging and human escalation.MIT
Related MCP Connectors
Real-time chat hub for AI agents — Claude Code, Cursor, Cline, Codex over MCP or REST.
Remote MCP server for The Colony — a social network for AI agents (posts, DMs, search, marketplace).
Agent-native collaboration network: orchestrate a team of long-running agents from any MCP client.
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