A small, persistent message hub that routes envelopes between Claude Code
agent sessions. One always-on process holds per-agent queues, verifies
identity, and wakes sleeping sessions when a message arrives. Topology is
hub-and-spoke: an orchestrator session dispatches tasks; unattended
repo-resident sessions block on await, do the work, and reply.
The hub never interprets message bodies and never modifies envelope
content beyond stamping the authenticated sender. It originates
envelopes in exactly one case: a bounce notifying a sender of an
irrecoverably lost delivery.
One Cargo package builds a shared protocol library and two binaries:
agent-hub-server— the hub process. Loopback-only TCP listener, token authentication, in-memory FIFO queues, append-only audit log. Runs only in a protected environment; clients never have access to it.agent-hub— the client CLI, installed on the shared PATH. Two verbs,sendandawait, over a synchronous connection. Skills invoke this binary and never speak TCP themselves.
Both binaries share the envelope schema and wire line types at compile time, so client and server cannot disagree about the protocol.
Transport is TCP bound to 127.0.0.1 only, carrying newline-delimited
JSON: one JSON object per line, UTF-8. Every connection is short-lived
and performs exactly one verb:
- The client sends a hello line:
{"hello":{"name":"...","token":"..."}}. - The hub verifies the name/token pair against its roster. Failure gets one error line and a closed connection; success gets an ok line.
- The client sends one verb line (
sendorawait), receives the reply, and the connection ends.
The unit of routing:
{
"id": "<sender-assigned unique id, UUIDv4>",
"from": "mneme",
"to": "datastore",
"ts": "2026-07-21T06:40:51Z",
"kind": "task",
"correlationId": null,
"body": { }
}fromis always overwritten by the hub with the authenticated name — a client cannot speak as anyone it did not authenticate as.tomay name an agent the hub has never seen; unknown destinations are accepted and queued deliberately (the agent may connect later).kindistaskorresultin v1, but the set is extensible: the hub routes unknown kinds without complaint.bounceis reserved for hub-synthesized loss notices.correlationIdisnullor theidof the envelope this one answers.bodyis opaque. The hub validates envelope fields and never inspects, interprets, or transforms the body.tsis the sender's clock and informational only; the audit log's append order is the authoritative ordering of protocol events.
- One in-memory FIFO queue per agent name.
- A
sendeither hands the envelope directly to a parkedawait(among parkedawaits whose filters all pass, the most specific wins — reply-to plus kind, then reply-to, then kind, then unfiltered — oldest first within a tier) or queues it. - An
awaitconsumes at most one envelope. Consumption commits when the socket accepts the first byte of the write to the client: a write that fails with zero bytes accepted returns the envelope to routing (next matching waiter, else queue front); only a partial write loses it. The hub's own guarantee is best-effort, at-most-once per envelope. - A partial-write loss is reported to the sender by a hub-synthesized
bounce envelope:
from: hub,kind: bounce,correlationIdset to the lost envelope's id, body naming the lost id, intended recipient, and reason. It is committed and audited like any envelope; a lost bounce is not bounced again. - At-least-once is achieved end-to-end, not by the hub: the orchestrator treats every dispatch as outstanding until its correlated result arrives and re-dispatches after a deadline; consumers are idempotent. Queue loss on hub restart is an accepted consequence, recovered by the same re-dispatch discipline.
- The
awaittimeout is enforced hub-side. A timeout reply guarantees nothing was consumed — a client-side socket deadline could not make that guarantee.
The hub's roster maps each agent name to a token; asserted name must
match presented token. Rationale: hub messages are instructions that
agent sessions act on with real permissions, so an unauthenticated local
port would be a prompt-injection surface for any errant local process.
Tokens live in plain files, mode 600, never committed; the CLI reads a
token only via --token-file — no environment variables, no discovery.
The name hub is reserved as the hub's own synthesized identity; a
roster defining it is a fatal startup error, so from: hub is
unforgeable.
The hub writes two records that never merge:
- Audit log (
audit_log_path, JSONL): the authoritative ordering of protocol events — every connection, every auth failure, every accepted envelope in full (body included,fromalready stamped), every delivery. Append-only, written solely by the hub. Write-only in v1: grep is the query interface. - Service log (
service_log_path): operational diagnostics — startup boundaries, connection lifecycles, send/await outcomes with elapsed times, and error paths. Token values and body content never appear here.
From the repository root:
cargo build --release
The binaries land in target/release/:
target/release/agent-hub-servertarget/release/agent-hub
(A plain cargo build produces debug builds at target/debug/ instead;
the paths below assume the release build.)
Deployment boundary: copy or symlink agent-hub onto the shared PATH
for agents to use; keep agent-hub-server out of any client-accessible
PATH — it belongs only to the protected environment that operates the
hub.
Copy the committed example and edit it:
cp config.example.toml config.toml
port = 46110 # TCP port on 127.0.0.1
audit_log_path = "audit.jsonl" # append-only protocol record
service_log_path = "service.log" # operational diagnostics
roster_path = "roster.toml" # name -> token mapConfig is strict: every key is required, unknown keys are fatal, and
there are no runtime defaults. Relative paths in config.toml resolve
against the directory the server is started from, so either start the
server from the directory containing these files or use absolute paths.
Create the file named by roster_path, restrict it to the operator, and
add one entry per agent:
touch roster.toml && chmod 600 roster.toml
[agents]
mneme = "<token>"
datastore = "<token>"Any unique secret string works as a token; e.g. generate one with
openssl rand -hex 16. A missing roster file, an empty roster, a blank
name or token, the reserved name hub, or keys outside the [agents]
table are fatal startup errors: a hub that can authenticate nobody must
not start.
Give each agent its own token in a file of its own, containing exactly
that agent's roster token (tokens/<name>.token is the convention used
here):
mkdir -p tokens
printf '%s\n' '<token>' > tokens/mneme.token
chmod 600 tokens/mneme.token
A trailing newline in the token file is tolerated; the CLI trims it
before authenticating. Roster and token files are credential material:
mode 600 and never committed (the repository's .gitignore already
excludes roster.toml, *.token, and tokens/).
From the directory containing config.toml (assuming relative paths in
the config):
./target/release/agent-hub-server --config config.toml
On success the service log ends its startup sequence with
listener bound; hub ready. The server runs until killed; uptime is the
operator's guarantee. Fatal startup errors go to stderr, and also to the
service log once it is initialized.
Both verbs require --as (agent name), --token-file, and --port.
The port is deliberately explicit — no default, no environment variable:
the client never silently chooses which service it talks to.
agent-hub send --as mneme --token-file tokens/mneme.token --port 46110 \
--to datastore --kind task --body '{"op":"reindex"}'
Prints the accepted envelope's id on stdout and exits 0. Acceptance
means queued (or delivered), not delivered. Use --body - to read the
JSON body from stdin, and --correlation-id <id> when answering a
previous envelope.
agent-hub await --as datastore --token-file tokens/datastore.token --port 46110 \
[--reply-to <id>] [--kind <kind>] [--timeout <secs>]
Blocks until one matching envelope is available, prints it as one JSON
line on stdout, and exits 0. Without filters it matches any envelope
addressed to the authenticated name; with --reply-to <id> it matches
only envelopes whose correlationId equals <id> — the blocking
send-and-wait composite; with --kind <kind> it matches only envelopes
of that kind. Filters conjoin: every filter given must pass. Without
--timeout it blocks indefinitely.
This one-shot, exit-on-event contract is what lets await double as a
resident session's background watcher: process exit wakes the session.
Filtered awaits let a watcher coexist with other waits, e.g.
--kind wake.
| Code | Meaning |
|---|---|
| 0 | Success (send: queued; await: envelope delivered on stdout) |
| 1 | Connection, auth, validation, usage, or protocol failure — nothing queued or consumed |
| 2 | await hub-side timeout — nothing consumed; the caller may re-arm |
Hub error messages pass through to stderr verbatim, prefixed with
agent-hub:.
# Resident arms its watcher (typically a background process):
agent-hub await --as datastore --token-file tokens/datastore.token --port 46110
# Orchestrator dispatches a task and blocks for its result:
id=$(agent-hub send --as mneme --token-file tokens/mneme.token --port 46110 \
--to datastore --kind task --body '{"op":"reindex"}')
agent-hub await --as mneme --token-file tokens/mneme.token --port 46110 \
--reply-to "$id" --timeout 300
# Resident replies to the task it received (its id was in the envelope):
agent-hub send --as datastore --token-file tokens/datastore.token --port 46110 \
--to mneme --kind result --correlation-id "$id" --body '{"status":"done"}'Deliberate v1 exclusions (queue durability, presence queries, broadcast,
multi-machine transport, delivery receipts, mid-task conversations) and
the extension seams for lifting them are recorded in
SPEC-Agent-Comms-Hub.md, the normative wire contract. PLAN.md records
the implementation's fixed design decisions.