Skip to content

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.