No description
  • TypeScript 100%
Find a file
2026-07-05 15:18:52 +02:00
src feat: mesh_send_file/mesh_recv_file for binary-safe file transfer 2026-07-05 15:18:52 +02:00
test feat: mesh_send_file/mesh_recv_file for binary-safe file transfer 2026-07-05 15:18:52 +02:00
.gitignore feat: NATS mesh core + MCP channel server with age E2E 2026-07-04 16:20:23 +02:00
bun.lock feat: NATS mesh core + MCP channel server with age E2E 2026-07-04 16:20:23 +02:00
package.json feat: NATS mesh core + MCP channel server with age E2E 2026-07-04 16:20:23 +02:00
README.md feat: mesh_send_file/mesh_recv_file for binary-safe file transfer 2026-07-05 15:18:52 +02:00

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 the mesh_send tool.
  • 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?) - encrypt body to to's key and send it. Reply to a channel event by passing its from as to. 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. Pass source as file:/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. handle is the number from the secret notification; exec reads 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 for mesh_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. Pass source as file:/abs/path; the server base64- and age-encrypts the file itself. Bounded by the broker max_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. handle is the number from the file notification; dest is 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. With wait (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.