How agents talk to each other: the mechanics behind communicate.sh¶
A Claude Code session on my laptop can message a Codex agent on the Mac mini by name, and the answer comes back as an ordinary cross-session message. No cloud relay, no vendor bridge, no new protocol. This post is the mechanism in four pictures: the routing table Claude Code already keeps, how a socket on another machine is made to look local, what a bus adds when the other party is on someone else's computer, and what one message goes through on the way.
1. The routing table was already there¶
Every Claude Code session writes a small sidecar file when it starts, at
~/.claude/sessions/<pid>.json. Two fields in it carry the whole design:
{
"pid": 67034,
"name": "agent-1",
"messagingSocketPath": "/tmp/cc-socks/67034.sock",
"cwd": "/Users/you/src/app",
"status": "idle",
"peerProtocol": 1
}
name is the identity and messagingSocketPath is the address: a unix domain socket the
session listens on (the directory is mode 0700, the socket 0600). The set of sidecars on
a machine is a routing table, name to socket. ListAgents reads every sidecar and connects
to each socket with a 250 ms timeout; whatever accepts is listed as live. SendMessage looks
a name up, connects, and writes one line of JSON.
flowchart LR
subgraph machine["one machine"]
S1["Claude session · pid 67034<br/>agent-1"]
S2["Claude session · pid 67101<br/>agent-2"]
F1["sessions/67034.json<br/>name: agent-1<br/>socket: /tmp/cc-socks/67034.sock"]
F2["sessions/67101.json<br/>name: agent-2<br/>socket: /tmp/cc-socks/67101.sock"]
S1 -- "writes on start" --> F1
S2 -- "writes on start" --> F2
S1 -. "ListAgents: read every sidecar,<br/>probe its socket (250 ms)" .-> F2
S1 == "SendMessage to agent-2:<br/>connect, write one JSON line" ==> S2
end
The wire format is newline-delimited JSON, one object per connection:
{"type": "user",
"message": {"role": "user", "content": "hello"},
"from": "uds:/tmp/cc-socks/67034.sock"}
content must be a plain string. from is the reply address. A delivery receipt goes back
to it only when both sockets live in the same cc-socks/ directory. And there is a guard:
the client refuses any address that is not a local path. You cannot point Claude Code at
https://some-host. That one rule shapes everything that follows.
2. Make the remote socket local¶
The guard accepts any local path, and a unix socket forwarded over SSH is a local path.
So the Claude bridge does the obvious thing: ssh -L and ssh -R mirror the two sockets at
identical paths on both machines, a sidecar for the remote session is planted on the local
side, and a supervisor re-plants it every few seconds. That last part matters, because
discovery doubles as a garbage collector: a sidecar whose socket does not answer and whose
pid is not a live local process gets deleted. Re-planting makes a tunnel hiccup self-heal.
The result is that a session on the mini appears in ListAgents on the laptop as a native
peer, and SendMessage to it works unchanged. Because the bridge is files and sockets, it
even works between sessions logged into different Anthropic accounts.
Codex has no such socket. So a small adapter daemon binds one in the same cc-socks/
directory, writes a sidecar under its own pid (so the sweep never reaps it), and answers
whatever arrives by running codex exec, or by queueing the turn into an existing Codex
thread. To Claude it is just another peer that happens to answer as codex-here.
flowchart TB
subgraph laptop["laptop"]
direction LR
A["Claude session<br/>agent-1"]
SBm["/tmp/cc-socks/88210.sock<br/>mirror of the mini's socket,<br/>sidecar planted beside it"]
SC["/tmp/cc-socks/91002.sock"]
CX["codex-here<br/>adapter daemon<br/>own pid, own sidecar"]
CODEX["Codex CLI"]
A -- "SendMessage to mini-claude" --> SBm
A -- "SendMessage to codex-here" --> SC
SC --- CX
CX -- "codex exec / codex queue" --> CODEX
end
subgraph mini["mac mini"]
direction LR
SB["/tmp/cc-socks/88210.sock"]
B["Claude session<br/>mini-claude"]
SB --- B
end
SBm <== "ssh -L / -R over Tailscale:<br/>the same path on both machines" ==> SB
On top of that sits a router: every sidecar from every bridged device folds into one table of
name → {kind, device, socket}, and communicate route <name> "…" is the same verb for a
Claude session, a Codex adapter, here or there. A message to an idle session resumes it, so a
wake is nothing more than a routed message with a trigger in front: a timer
(communicate wake reviewer --every 300) or an event (--on-pr you/repo).
3. When the other side is not your machine: buses¶
Native peering is a rendezvous. Both ends must be alive at the same moment, on a network you control, with SSH between them. That is the right shape for your own devices and the wrong shape for a collaborator's laptop, or for an agent that is asleep when you write to it.
A bus adds a hub and turns delivery inside out. The hub is a broker (standard-library
Python plus SQLite) that holds buses, memberships, bounded queues and receipts. A device
joins by redeeming a one-time invitation, which the broker exchanges for a scoped device
credential; the account it belongs to comes from the administrator's invitation, never from a
hostname or a display name. An agent then registers from inside its own session and either
publishes itself on general or joins a private bus such as photonics, where both parties
have to join before either can reach the other.
Every enrolled device runs one outbound worker. It heartbeats (a missed one expires the
agent as offline within 45 seconds), probes its local sockets to report liveness, leases
pending messages for its agents, delivers each one through the local adapter (a write to the
Claude socket, or codex queue into the exact existing thread) and acknowledges the result.
Nothing is inbound: no open port, no SSH server, no socket or file leaves the machine.
The hosted hub, bus.communicate.sh, is a Vercel gateway in front of a broker that runs on
the Mac mini. People reach the dashboard with Sign in with GitHub against a pinned
allowlist; agents reach the API with their device credential; the gateway alone holds the
secret that the broker demands on every request.
flowchart TB
subgraph hub["the hub · bus.communicate.sh"]
GW["Vercel gateway<br/>people: Sign in with GitHub, pinned allowlist<br/>agents: scoped device credential"]
BR["broker: stdlib Python + SQLite<br/>buses · memberships · queues · receipts"]
GW -- "server-only gateway secret" --> BR
end
subgraph devA["laptop · enrolled by a one-time invitation"]
WA["worker<br/>heartbeat · probe sockets<br/>lease · deliver · ack"]
A1["agent-1<br/>Claude session"]
A2["reviewer<br/>existing Codex thread"]
WA -- "write to the socket" --> A1
WA -- "codex queue" --> A2
end
subgraph devB["collaborator's laptop · their own invitation"]
WB["worker"]
B1["coupler-reviewer<br/>Claude session"]
WB -- "write to the socket" --> B1
end
WA <-- "outbound HTTPS only" --> GW
WB <-- "outbound HTTPS only" --> GW
Two scopes, deliberately different:
| Scope | Who can reach whom |
|---|---|
general |
Registering publishes an agent for discovery. Any agent on an enrolled device can initiate to a published one without publishing itself; replies flow back to the initiator inside that conversation. |
| a private bus | Both agents join explicitly, and every member can reach every other member. Non-members do not see the bus, its roster or its messages. |
4. What one message goes through¶
sequenceDiagram
autonumber
participant A as agent-1 (laptop)
participant WA as worker (laptop)
participant H as hub
participant WB as worker (collaborator)
participant B as coupler-reviewer
A->>H: bus send coupler-reviewer --bus photonics "Review the coupler geometry"
H-->>A: accepted · message id
WB->>H: lease pending messages for my agents
H-->>WB: the message (lease: 60 s)
WB->>B: write it to the Claude socket
WB->>H: ack · delivered
B->>H: bus reply MESSAGE_ID "Gap 180 nm, the coupling length is the issue"
WA->>H: lease
H-->>WA: the reply
WA->>A: delivered as a cross-session message
A receipt tells you exactly how far a message got, and no further:
| Receipt | What is known |
|---|---|
accepted |
The hub persisted it. |
leased |
The recipient's worker fetched it for delivery. |
delivered |
The worker wrote it to a Claude socket. Claude's own inbound gate may still hold it for approval. |
queued |
Codex accepted the turn into the existing thread's queue. |
failed |
The local adapter reported an error. |
cancelled, expired |
Membership changed, or the message outlived its 24 hours. |
None of them proves an agent read or answered; a reply is a separate message. Delivery is at least once, not exactly once (there is a small crash window between writing the session's queue and recording that write), so an agent that acts on a message deduplicates on the message id it was given. Queues are bounded: 256 pending per recipient, 32 KiB per message, 24 hours to live. A conversation is the pair of agents on that bus, opened by the first send; only the recipient can reply, the reply cannot change the participants, and the whole thing closes 24 hours after it opened.
5. Names that outlive processes¶
One thing the peer table cannot give you is a name that stays valid when the process behind it
is gone. A name is whatever a sidecar says right now. homi, the per-device postmaster in
the same repository, fixes that: homi claim gds-agent gives the name a socket that always
answers, a sidecar the sweep cannot reap, and a durable mailbox. Every inbound message is
stored before anything else is attempted. While a live session owns the name, homi steps
aside and drains held mail into it as turns; when the session dies, homi takes the name back
and holds. Devices link outbound-only over SSH, envelopes are acknowledged, retried and
deduplicated, and a remote agent is simply gds-agent@mini.
flowchart TB
M["message for gds-agent"] --> MB["mailbox: homi/mail/gds-agent/inbox.jsonl<br/>stored before anything else is attempted"]
MB --> Q{"does a live session<br/>own the name?"}
Q -- "yes" --> D["delivered as a turn<br/>a message is a wake"]
Q -- "no" --> HD["held durably"]
HD -- "a session claims the name,<br/>or homi spawn starts one" --> D
The rest of homi builds on that mailbox: seats (a tmux surface for the things you cannot
mailbox, like a cluster shell), move (relocate an agent to another device with its address
alive throughout), boxed agents whose only egress is mail, and fleets, where another
operator's devices see only the identities you grant.
Where this meets mit.nonlocally.org¶
The platform's own agents already speak socket-shaped protocols: the
ACP endpoint at wss://mit.nonlocally.org/acp is an agent you
talk to over a websocket with your own Open WebUI key. The fleet's bus is
bus.communicate.sh today, and
bus.nonlocally.org
is requested so it lives beside the rest of the platform. The point of all of the above is
that an agent running on a lab machine, a cluster login node or a laptop in another building
gets one name, and everything else addresses it by that name.
Try it¶
npx -y @aadarwal/communicate setup # Claude Code + Codex plugin, CLI, MCP tools
communicate codex peer local codex-here # a Codex peer on this machine; ListAgents now lists it
communicate ls aadarshs-mac-mini-2 # Claude sessions on another device over Tailscale
communicate claude bridge aadarshs-mac-mini-2 newest
communicate bus register --bus photonics # after the hub owner's one-time invitation
Or tell the agent, in its own words, "Register yourself on the bus." The source, the reverse-engineered wire contract and the bus guide are at github.com/aadarwal/communicate.