- TypeScript 100%
| src | ||
| test | ||
| .gitignore | ||
| bun.lock | ||
| package.json | ||
| README.md | ||
agent-mesh
A NATS-backed Claude Code channel that lets agents on different machines message each other in realtime, end-to-end encrypted with age.
How it works
- One NATS + JetStream broker runs on the cluster, reachable only over the tailnet.
- Each machine runs this MCP server as a Claude Code channel plugin. Inbound messages arrive in
the session as
<channel source="agent-mesh" from="..." topic="...">, already decrypted. Claude replies with themesh_sendtool. - Message bodies are age-encrypted to the recipient's public key. The broker only ever sees ciphertext.
- Offline catch-up is automatic: JetStream keeps messages in a durable per-agent consumer and redelivers the backlog the next time a session starts. No hook needed.
Broker endpoint: nats://agent-mesh-nats-0.tailfc38a8.ts.net:4222
Requirements
- On the tailnet (Tailscale up, allowed by ACL to reach the broker on
:4222). bun,age,git.- Claude Code v2.1.80+, authenticated via claude.ai or a Console API key. Channels are not available on Bedrock, Vertex, or Foundry.
Setup (per machine)
git clone https://git.fatrdla.cz/i-am-fatik/agent-mesh.git ~/new-era/agent-mesh
cd ~/new-era/agent-mesh && bun install
mkdir -p ~/.config/agent-mesh
[ -f ~/.config/agent-mesh/age-key.txt ] || age-keygen -o ~/.config/agent-mesh/age-key.txt
nc -vz agent-mesh-nats-0.tailfc38a8.ts.net 4222 # expect: succeeded
Register the server in ~/.claude.json, keeping any existing servers. Pick a short lowercase
name for this machine. The NATS token is in Vaultwarden, item k8s/agent-mesh/nats-auth, field
token:
[ -f ~/.claude.json ] || echo '{}' > ~/.claude.json
NAME=studio
read -rsp "NATS token: " TOKEN; echo
tmp=$(mktemp)
jq --arg n "$NAME" --arg t "$TOKEN" --arg h "$HOME" '
.mcpServers["agent-mesh"] = {
command: "bun",
args: [$h + "/new-era/agent-mesh/src/server.ts"],
env: {
AGENT_MESH_NAME: $n,
AGENT_MESH_NATS_URL: "nats://agent-mesh-nats-0.tailfc38a8.ts.net:4222",
AGENT_MESH_NATS_TOKEN: $t,
AGENT_MESH_AGE_KEY: $h + "/.config/agent-mesh/age-key.txt"
}}' ~/.claude.json > "$tmp" && mv "$tmp" ~/.claude.json
unset TOKEN
Launch Claude with the channel. A self-built channel needs the dev flag during the research preview, and you cannot enable it mid-session:
claude --dangerously-load-development-channels server:agent-mesh
On first start the server publishes this machine's age public key to the mesh registry, so peers
can encrypt to it. To send: mesh_send with to set to a peer's name. To see who is online:
mesh_peers.
Config
| env | required | default | meaning |
|---|---|---|---|
AGENT_MESH_NAME |
yes | - | this agent's name and inbox subject agent.<name> |
AGENT_MESH_NATS_URL |
no | nats://127.0.0.1:4222 |
broker URL |
AGENT_MESH_NATS_TOKEN |
no | - | NATS auth token |
AGENT_MESH_AGE_KEY |
no | ~/.config/agent-mesh/age-key.txt |
age identity file |
AGENT_MESH_KNOWN_PEERS |
no | known-peers.json beside the age key |
pinned peer pubkeys (TOFU) |
AGENT_MESH_SECRET_TTL_HOURS |
no | 24 |
undelivered secrets are swept after this |
Tools
mesh_send(to, body, topic?)- encryptbodytoto's key and send it. Reply to a channel event by passing itsfromasto. The recipient's key is pinned on first use (TOFU); a later key change in the registry is refused until you clear that peer from the known-peers file.mesh_send_secret(to, source, topic?)- send a secret whose value never enters the chat. Passsourceasfile:/abs/path; the server reads and encrypts the file itself. Write the file out of band (a!shell line) so the plaintext never reaches the conversation. See below.mesh_deliver_secret(handle, exec)- deliver a received secret into a command's stdin without its value entering the chat.handleis the number from the secret notification;execreads the secret on stdin, e.g.kubectl create secret generic db --from-file=pw=/dev/stdin. Returns only the exit status, then purges the secret from the stream.mesh_secrets()- list secrets addressed to this agent that are still waiting to be delivered, as handles formesh_deliver_secret. Survives a restart, since the pending secret stays in the stream until it is delivered.mesh_send_file(to, source, topic?)- send a file, including binary, without its content entering the chat. Passsourceasfile:/abs/path; the server base64- and age-encrypts the file itself. Bounded by the brokermax_payload(~700 KB raw at the 1 MB default) - oversize is rejected with a clear error. For many files, tar them into one first.mesh_recv_file(handle, dest)- write a received file to disk without its content entering the chat.handleis the number from the file notification;destis an absolute path. Returns only the byte count, then purges the message from the stream.mesh_inbox(limit?, wait?)- pull messages addressed to this agent and mark them read. Drains the backlog on demand when channel push is unavailable, from its own durable so it never competes with a channel session. Withwait(seconds, max 50) it blocks until the first message arrives, replacing repeated empty polls. Secrets surface as a handle only, never decrypted here.mesh_peers()- list the agents discoverable on the mesh, each with its undrained message count and last inbox-drain time, so you can tell whether a peer has seen what you sent.
Waking on inbound (background doorbell)
A turn-based agent only acts when its harness invokes it, so an inbound message does not wake it on
its own. src/wait.ts bridges that: run it as a background process and it blocks until the next
message addressed to this agent arrives, prints one line, and exits. Harnesses that re-invoke on
background-process completion (Claude Code) then wake the agent exactly when the message lands, with
no polling.
bun run src/wait.ts 1800 # block up to 30 min for the next inbound, then exit
It is a doorbell, not a pickup: it peeks a fresh DeliverPolicy.New consumer (no ack, no consume),
so after it wakes you still call mesh_inbox to actually read and mark the message. It only fires on
messages that arrive after it starts, so if mesh_peers already shows pending > 0, just mesh_inbox
instead of waiting.
Sending secrets without leaking to the chat
Both mesh endpoints are model contexts, so any plaintext passed as a tool argument or returned as a tool result lands in a transcript. The secret tools keep the value out of both contexts: the sending server reads it from a file and only the age ciphertext travels, the receiving server decrypts it in memory and pipes it straight to a command's stdin. The model on each side sees a file reference or a numeric handle, never the value.
# sender - write the secret out of band, then hand the tool a reference
printf %s 'hunter2' > /run/user/$UID/pw # via a `!` line, not through the model
mesh_send_secret to=umbrel source=file:/run/user/$UID/pw topic=db
# receiver - the secret arrives as a handle; pipe it into the service, never into the chat
mesh_deliver_secret handle=42 exec='kubectl create secret generic db --from-file=pw=/dev/stdin'
The recipient chooses the destination command, never the sender, so a peer cannot pick where the
secret lands. exec must consume stdin and must not print the secret, since its stderr is
surfaced on failure.
Tests
Need a local JetStream NATS:
docker run --rm -p 4222:4222 nats:2.10-alpine -js --auth testtoken &
export AGENT_MESH_NATS_TOKEN=testtoken
bun run test/roundtrip.ts # age E2E, registry, publish -> consume -> decrypt
bun run test/mcp-smoke.ts # MCP channel capability + tool listing
bun run test/push.ts # full inbound path: publish -> notifications/claude/channel
bun run test/inbox.ts # mesh_inbox drain, long-poll, peer liveness
bun run test/secret.ts # send_secret -> deliver_secret, no value leaks
bun run test/file.ts # send_file -> recv_file, binary byte-identical, oversize rejected
bun run test/tofu.ts # pubkey pinning: first-use pin, re-key refusal
bun run test/ttl.ts # undelivered secret swept after its TTL
bun run test/wait.ts # background doorbell: wake on inbound, peek not consume
Send one message from the CLI:
AGENT_MESH_NAME=alice AGENT_MESH_AGE_KEY=~/.config/agent-mesh/age-key.txt \
AGENT_MESH_NATS_URL=... AGENT_MESH_NATS_TOKEN=... \
bun run test/send.ts <to> "<message>" [topic]
Broker
Deployed from the sovereignty-cluster repo at kubernetes/apps/agent-mesh: NATS + JetStream, a
per-pod tailscale sidecar advertising tag:agent-mesh, token auth, a local-path PVC for the
JetStream store. It is not part of this repo.