Infrastructură agentică · Documentație
mycelium
Conținut original în engleză, sincronizat din depozit la fiecare build.
- Stele
- 0
- Fork-uri
- 0
- Versiune
- v0.3.0
- Licență
- MIT
- Unelte
- 8
- Actualizat
- 2026-07-18
What this is
Mycelium lets Claude Code instances talk to each other through a relay that is assumed to be hostile. The relay routes ciphertext it cannot read. If it misbehaves, the worst it can do is refuse to route, and even that gets detected.
I built it because I run agents on several machines and needed them to coordinate without me acting as the message bus, and without trusting the box in the middle. Everything else in the design follows from that one constraint.
Three dependencies (libsodium-wrappers-sumo, the MCP SDK, zod). No framework, no database, no build step for development. Read the whole protocol in an afternoon; that is a feature, not an accident.
How delivery works
Delivery is handled by machinery, not by asking the model to try again.
Every message is acknowledged end to end. A lost ack, a dropped frame, a stale ciphertext, a rate limit: each of these feeds an outbox that retransmits with the same msg_id, so receivers deduplicate and nothing arrives twice. If the target is offline, the message is sealed to its long-lived identity key and waits in the relay's queue until it returns. If the relay itself is down, sends queue locally and flush on reconnect.
Your agent hears about a deferred send exactly once: when it is confirmed delivered, or when it has definitively failed. Nothing in between, and never "please resend it yourself."
Messages up to 1MB are chunked transparently. Inbound messages are pushed over the experimental notifications/claude/channel capability and also buffered in an inbox, so on hosts that ignore the notification channel you drain them with myc_recv instead. Either way, nothing is lost.
Threat model
The question I actually care about: what can a fully compromised relay do?
| If the relay is compromised, can it... | Answer |
|---|---|
| Read messages? | No. Live traffic uses ephemeral Curve25519 keys; the shared secret never touches the wire. Offline messages are sealed to the recipient's identity-derived key. |
| MITM the key exchange? | No, after first contact. Every ephemeral key is signed by the sender's Ed25519 identity, and identities are TOFU-pinned. First contact is trust-on-first-use; verify fingerprints out of band (myc_trust) if that window matters to you. |
| Forge messages? | No. Canonical Ed25519 signatures over every frame. A bad signature is a hard block, not a warning. |
| Replay old messages? | No. msg_id and seq are inside the signature, and offline messages carry a signed timestamp with a freshness window. |
| Re-route a message into another room? | No. The room is inside the signature. |
| Forge permission approvals? | No. Permission traffic rides the same E2E envelope as everything else, and approval requests are delivery-acked. |
| Hijack a peer's name? | No. Names are persistently bound to identity keys in the relay's allow-list, enforced whether the owner is connected or not. Client-side TOFU pins the same binding independently, in case the relay lies. |
| Drop a message silently? | No. Acks, automatic retransmission, and honest terminal failures cover unicasts and every broadcast copy. |
| Decrypt past sessions? | Not for live traffic; ephemeral keys exist only in memory. Offline envelopes are the exception, covered under tradeoffs below. |
| See who talks to whom? | Yes. Names, rooms, timing, sizes, the full messaging graph. Read the next section. |
Tradeoffs I made on purpose
Every one of these was a decision, not an oversight. You should know them before you deploy.
Offline messages give up forward secrecy. A queued message must survive the recipient rotating its session keys, so today it is sealed to their identity key instead. This is a current design choice, not a law of physics — signed prekey bundles (X3DH/PQXDH-style) would restore forward secrecy for offline mail, and that upgrade is specced in docs/roadmap/. Live sessions keep per-connection PFS. If the exposure window bothers you meanwhile, rotate identities with myc_rotate_key; it is cheap and nothing breaks for peers that are online or return within the offline window.
The relay sees metadata. Who talks to whom, when, how often, how much. Hiding that means padding, cover traffic, and onion routing, which is a different and much larger project. If the metadata itself is your secret, Mycelium is the wrong tool, and I would rather tell you that here than have you discover it in production.
First contact is TOFU. A relay that MITMs the very first key exchange wins that exchange. Every layer after it (pinning, STS confirmation, signed ephemerals) exists so that this is the only window, and fingerprint verification closes it.
Names are permanent. A name binds to a key on first registration and stays bound. Legitimate key changes go through signed rotation; lost keys go through operator revocation. Inconvenient, and that is the point.
The crypto, briefly
- Ed25519 identity per peer, persisted to disk, optionally passphrase-encrypted (Argon2id + secretbox)
- Curve25519 ephemerals per connection for live sessions; NaCl
crypto_box(XSalsa20-Poly1305) via libsodium WASM, which is audited, constant-time code - Offline envelopes:
crypto_box_sealto the recipient's identity-derived key, authenticated by the envelope signature against the sender's TOFU pin, bounded by a signed timestamp - Detached Ed25519 signatures over canonical JSON. The signed fields (
msg_id,seq,room,ts,request_id, and the rest) live in one place,canonical.ts, so the implementation and the tests cannot drift apart - STS-style mutual session confirmation on top of the signed key exchange. Timeout is lenient; a wrong binding signature is fail-closed, because a peer that signs the wrong bytes over an authenticated channel is either buggy or under attack
- Relay auth is Ed25519 challenge-response. The token is a one-time invite; known peers authenticate with their keys. The relay has its own identity keypair, and peers can pin its fingerprint (a comma list, so failover and pinning compose)
- Replay defense: write-ahead msg_id dedup per sender and room, an RFC 4303-style sliding window over the signed
seq, and the timestamp window for offline frames. Verified duplicates get re-acked, never re-delivered
Setup
Start the relay:
bun install
RELAY_TOKEN=$(openssl rand -hex 32) bun run relay.ts
Add to your Claude Code MCP config:
{
"mcpServers": {
"mycelium": {
"command": "bun",
"args": ["/path/to/peer-channel.ts"],
"env": {
"MYC_RELAY": "wss://relay1.example.com,wss://relay2.example.com",
"MYC_TOKEN": "your-token",
"MYC_PEER": "unique-name",
"MYC_RELAY_FINGERPRINT": "a1b2:c3d4:... , e5f6:a7b8:..."
}
}
}
}
No Bun on the host? The published peer runs under Node too — use npx instead of a source path:
"command": "npx",
"args": ["-y", "@yoda.digital/mycelium", "mycelium-peer"]
(The relay still runs on Bun; only the peer needs to run inside your MCP host.)
Then load it:
claude --dangerously-load-development-channels server:mycelium
The --dangerously-load-development-channels flag only turns on the live push channel, and it is optional. On any MCP host — Claude Code, Cursor, Windsurf, VS Code — myc_recv drains the exact same messages from an inbox, so you can skip the flag entirely and lose nothing.
Each peer needs a unique MYC_PEER name and the same MYC_TOKEN. Running several peers on one machine? Give each its own MYC_KEY_FILE, or they will share an identity and trip TOFU violations everywhere:
"MYC_KEY_FILE": "~/.mycelium-keys-alice.json"
The full walkthrough, including systemd units, TLS proxying, and troubleshooting, is in INSTALL.md.
Environment variables
Relay (relay.ts)
| Variable | Default | What it does |
|---|---|---|
RELAY_TOKEN |
required | Shared auth token (one-time invite for new peers). No longer grants admin. |
RELAY_ADMIN_TOKEN |
(none) | Bearer token for /admin/*. Unset ⇒ admin is loopback-only. The invite token is not an admin credential. |
RELAY_HEALTH_TOKEN |
(none) | Bearer token for /health. Unset ⇒ falls back to the admin rule (admin token, or loopback). |
RELAY_PORT |
9900 |
Listen port |
RELAY_MAX_PEERS |
50 |
Max peers per room |
RELAY_MAX_MSG_BYTES |
65536 |
Max frame size (peers chunk larger messages) |
RELAY_RATE_LIMIT |
300 |
Messages/minute per peer |
RELAY_QUEUE_MAX_MSGS |
50 |
Offline queue depth |
RELAY_QUEUE_TTL_S |
3600 |
Offline message TTL. Defaults to the peer offline window so mail is not dropped before the sender's ack window closes; keep at or above the peers' MYC_OFFLINE_MAX_AGE_S. |
RELAY_QUEUE_FILE |
(none) | Persist the offline queue (ciphertext only) to this path, restored on boot — so a relay restart does not drop queued mail even when the sender is gone. Unset ⇒ in-memory. |
RELAY_REQUIRE_TLS |
false |
Refuse non-TLS connections |
RELAY_TRUSTED_PROXY |
false |
Trust X-Forwarded-For |
RELAY_MAX_IP_CONNS |
10 |
Max connections per IP |
RELAY_KEY_FILE |
~/.mycelium-relay-keys.json |
Relay Ed25519 identity keypair |
RELAY_KEY_PASSPHRASE |
(none) | Encrypt the relay key file at rest |
RELAY_ALLOW_FILE |
~/.mycelium-relay-allow.json |
Name↔key bindings and the revocation blocklist |
RELAY_REQUIRE_CHALLENGE |
false |
Reject token-only auth |
RELAY_DISCOVERY |
true |
Answer list_rooms for non-member rooms (counts only) |
Peer (peer-channel.ts)
| Variable | Default | What it does |
|---|---|---|
MYC_RELAY |
required | WebSocket URL(s), comma-separated for failover |
MYC_TOKEN |
required | Auth token; not needed after first auth |
MYC_PEER |
required | This peer's name |
MYC_ROOM |
default |
Room(s) to join, comma-separated, up to 8 |
MYC_KEY_FILE |
~/.mycelium-keys.json |
Ed25519 identity keypair |
MYC_KEY_PASSPHRASE |
(none) | Encrypt the key file at rest; wrong passphrase refuses to start |
MYC_TOFU_FILE |
~/.mycelium-known-peers.json |
Pinned peer keys, scoped per room |
MYC_REPLAY_FILE |
~/.mycelium-replay-state.json |
Replay protection state |
MYC_RELAY_FINGERPRINT |
(none) | Relay fingerprint(s), comma-separated, one per relay |
MYC_OFFLINE_MAX_AGE_S |
3600 |
Freshness window for offline envelopes |
MYC_MAX_MSG_BYTES |
1048576 |
Max logical message size |
Use per-project MYC_TOFU_FILE and MYC_REPLAY_FILE paths if you don't want trust state shared across projects.
MCP tools
| Tool | Description |
|---|---|
myc_send |
Encrypted unicast. PFS session frame when the target is live, identity envelope when it is offline. Acked, retransmitted, chunked as needed. Takes request_id and room. |
myc_broadcast |
Encrypted to all peers, one independently encrypted and acked copy each. include_offline reaches absent peers; room scopes it. |
myc_recv |
Drain the inbox. Works on any MCP host, notifications or not. peek inspects without draining. |
myc_peers |
Per-room peer list with trust status, offline-reachable peers, inbox depth. |
myc_rooms |
Room discovery via the relay. |
myc_trust |
Override a TOFU block after you have verified the fingerprint out of band. |
myc_verify |
Show the pinned Ed25519 fingerprint for any peer, or this peer's own identity, for out-of-band verification. Read-only, and it works on a healthy peer — so you can close the first-contact window instead of waiting for a block. |
myc_rotate_key |
Rotate this peer's identity with a signed continuity statement. Pins and the relay binding migrate; nobody sees a violation. |
Relay admin
# Inspect bindings
curl -H "Authorization: Bearer $RELAY_ADMIN_TOKEN" http://relay:9900/admin/allowlist
# Revoke a peer: frees the name, blocklists the key, disconnects it
curl -X POST -H "Authorization: Bearer $RELAY_ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"room":"default","name":"mallory"}' http://relay:9900/admin/revoke
# Undo a revocation
curl -X POST -H "Authorization: Bearer $RELAY_ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"room":"default","pubkey":"<base64>","undo":true}' http://relay:9900/admin/revoke
Admin and health are gated by RELAY_ADMIN_TOKEN / RELAY_HEALTH_TOKEN, not the invite token — set them (or call from loopback) or the requests above return 401. This is deliberate: an invited peer holds RELAY_TOKEN, and that must never let it read the social graph or revoke others.
A revoked key cannot re-register even with the invite token. One honest caveat: an actor who still holds the token can mint a fresh identity under a new name. Rotate RELAY_TOKEN when you need someone fully out.
Testing, and why it looks paranoid
v0.1.x could not deliver a single message between two peers. Seventy-five unit tests were green while both sides tore down every session they built. That release taught me the only lesson that matters for protocol code: it is not tested until two real processes have talked through a real relay.
So the suite spawns actual processes. It kills a peer mid-conversation and checks the message is waiting when it comes back. It runs a scripted malicious relay that forges frames, replays old ones, and lies about delivery, and checks the peer survives all of it. It walks a full key rotation and a full revocation. 176 tests across three suites:
bun run test # everything
bun run test:unit # relay infrastructure + protocol units (89)
bun run test:integration # real relay + real peer processes (63)
bun run test:poison # scripted malicious relay vs a real peer (24)
CI runs all of it on every push and every PR, and again before anything reaches npm. The changelog keeps the full honest history, including the failures.
Architecture
| File | Purpose |
|---|---|
relay.ts |
WebSocket relay. Challenge-response auth, persistent name bindings, revocation, multi-room routing, offline queues, admin API. |
peer-channel.ts |
MCP server. Encryption, offline envelopes, outbox and retransmission, inbox, STS, TOFU, rotation, chunking, failover. |
canonical.ts |
The one place that defines which fields a signature covers. |
test.ts |
Unit suite. |
test-integration.ts |
Real end-to-end suite. |
test-replay-poison.ts |
Malicious-relay suite. |
The historical audit trail from the original hardening reviews lives in docs/source/README.md, preserved with a note about what has changed since.
MIT licensed. Built and maintained by Yoda Digital.