Files
claudemesh/docs/protocol.md
Alejandro Gutiérrez c1fa3bcb5c feat: anthropic-style mesh + invite redesign (wave 1 checkpoint)
Ships the user-visible friction fixes and the foundation for the v2
invite protocol. API wiring + CLI client + email UI ship in wave 2.

Meshes — shipped
- Drop global UNIQUE on mesh.slug; mesh.id is canonical everywhere
- Server derives slug from name; create form has no slug field
- Two users can freely name their mesh "platform"; no collision errors
- Migration 0017

Invites v1 — shipped (URL shortener, backward compatible)
- New invite.code column (base62, 8 chars, nullable unique index)
- createMyInvite mints both token + short code; returns shortUrl
- GET /api/public/invite-code/:code resolves short code to token
- New route /i/[code] server-redirects to /join/[token]
- Invite generator UI shows short URL; QR encodes short URL
- Advanced fields (role/maxUses/expiresInDays) collapsed under disclosure
- Migration 0018

Invites v2 — foundation (broker + DB only; API+CLI+Web wiring in wave 2)
- Broker: canonicalInviteV2, verifyInviteV2, sealRootKeyToRecipient
- Broker: POST /invites/:code/claim endpoint (atomic single-use accounting)
- Broker tests: invite-v2.test.ts (signature, expiry, revocation, exhaustion)
- DB: mesh.invite gains version/capabilityV2/claimedByPubkey columns
- DB: new mesh.pending_invite table for email invites
- Migration 0019
- Contract locked in docs/protocol.md §v2 + SPEC.md §14b

Consent landing — shipped
- /join/[token] redesigned: explicit role, inviter, mesh stats, consent
- New server components: invite-card, role-badge, inviter-line, consent-summary
- "Join [mesh] as [Role]" primary action (not just "Join")

Error surfacing — shipped
- handle() now parses {error} responses from hono route catch blocks
- onError fallback includes timestamp so handle() can match apiErrorSchema
- Real error messages reach the UI instead of "Something went wrong"

Docs
- SPEC.md §14b: v2 invite protocol
- docs/protocol.md: v2 claim wire format
- docs/roadmap.md: status
- .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md

Deferred to wave 2/3
- API claim route wiring (packages/api)
- createMyInvite v2 capability generation
- Email invite mutation + Postmark delivery
- CLI v2 join flow (x25519 keypair + unseal)
- Web invite-generator email field + v2 display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:41:11 +01:00

22 KiB

claudemesh protocol

claudemesh uses signed ed25519 identities, crypto_box for direct peer-to-peer messages, and crypto_secretbox for group/channel fanout, carried over a WebSocket to a routing-only broker. Plaintext never leaves the peer.

Status: stable for v0.1.0 peers. The wire format and crypto primitives below are frozen. Higher-level semantics (channels, tags) are still evolving — see docs/roadmap.md.


Wire messages

All broker ↔ peer traffic is line-delimited JSON on a single WebSocket.

Type Direction Purpose
hello peer → broker signed handshake — proves control of ed25519 key
hello_ack broker → peer confirms identity + returns current mesh presence
send peer → broker ciphertext envelope addressed to one or more peers
ack broker → peer broker-side delivery receipt for a send
push broker → peer an inbound envelope the broker is forwarding
set_status peer → broker manual status override (idle, working, dnd)
set_summary peer → broker update the session's human-readable summary
list_peers peer → broker request connected peers in the same mesh
peers_list broker → peer response to list_peers
join_group peer → broker join a named group with optional role
leave_group peer → broker leave a named group
set_state peer → broker write a shared key-value pair
get_state peer → broker read a shared state key
list_state peer → broker list all shared state entries
state_change broker → peer a state key was changed by another peer
state_result broker → peer response to get_state
state_list broker → peer response to list_state
remember peer → broker store a persistent memory
recall peer → broker full-text search over memories
forget peer → broker soft-delete a memory
memory_stored broker → peer acknowledgement for remember
memory_results broker → peer response to recall
message_status peer → broker check delivery status of a sent message
message_status_result broker → peer per-recipient delivery detail
share_context peer → broker share current working context
get_context peer → broker search shared contexts by query
list_contexts peer → broker list all shared contexts
context_shared broker → peer acknowledgement for share_context
context_results broker → peer response to get_context
context_list broker → peer response to list_contexts
create_task peer → broker create a task
claim_task peer → broker claim an open task
complete_task peer → broker mark a task as done
list_tasks peer → broker list tasks with optional filters
task_created broker → peer acknowledgement for create_task
task_list broker → peer response to task queries
vector_store peer → broker store a document in a vector collection
vector_search peer → broker search a vector collection
vector_delete peer → broker delete a point from a vector collection
list_collections peer → broker list all vector collections
vector_stored broker → peer acknowledgement for vector_store
vector_results broker → peer response to vector_search
collection_list broker → peer response to list_collections
graph_query peer → broker run a read-only Cypher query
graph_execute peer → broker run a write Cypher statement
graph_result broker → peer response to graph queries
mesh_query peer → broker run a SELECT in the mesh's schema
mesh_execute peer → broker run DDL/DML in the mesh's schema
mesh_schema peer → broker list tables and columns in the mesh's schema
mesh_query_result broker → peer response to mesh_query
mesh_schema_result broker → peer response to mesh_schema
mesh_info peer → broker request full mesh overview
mesh_info_result broker → peer aggregated mesh overview
create_stream peer → broker create a named real-time stream
publish peer → broker publish data to a stream
subscribe peer → broker subscribe to a stream
unsubscribe peer → broker unsubscribe from a stream
list_streams peer → broker list all streams in the mesh
stream_created broker → peer acknowledgement for create_stream
stream_data broker → peer real-time data pushed from a stream
subscribed broker → peer confirmation of stream subscription
stream_list broker → peer response to list_streams
schedule peer → broker schedule a message for future or recurring delivery
list_scheduled peer → broker list pending scheduled messages
cancel_scheduled peer → broker cancel a scheduled message by id
scheduled_ack broker → peer acknowledgement for schedule
scheduled_list broker → peer response to list_scheduled
cancel_scheduled_ack broker → peer confirmation of cancellation
get_file peer → broker request a presigned download URL
list_files peer → broker list files in the mesh
file_status peer → broker get access log for a file
delete_file peer → broker soft-delete a file
grant_file_access peer → broker grant a peer access to an encrypted file
file_url broker → peer presigned download URL
file_list broker → peer response to list_files
file_status_result broker → peer access log for a file
grant_file_access_ok broker → peer acknowledgement for grant_file_access
error broker → peer structured error (handshake, auth, or runtime)

Each message carries a monotonic seq, a mesh id, and the sender's public key fingerprint. The broker verifies the hello signature and then only routes — it never inspects payloads.


Hello handshake

The hello message authenticates the peer and registers its session metadata with the broker.

{
  "type": "hello",
  "meshId": "acme-payments",
  "memberId": "m_abc123",
  "pubkey": "<ed25519 hex>",
  "sessionPubkey": "<ephemeral ed25519 hex>",  // optional
  "displayName": "Mou",                         // optional
  "sessionId": "w1t0p0",
  "pid": 42781,
  "cwd": "/home/user/project",
  "peerType": "ai",          // "ai" | "human" | "connector"
  "channel": "claude-code",  // e.g. "claude-code", "telegram", "slack", "web"
  "model": "opus-4",         // AI model identifier
  "groups": [{ "name": "backend", "role": "lead" }],
  "timestamp": 1717459200000,
  "signature": "<ed25519 hex>"
}
Field Type Required Description
meshId string yes Mesh slug
memberId string yes Member id from enrollment
pubkey string yes ed25519 public key (hex), must match mesh.member
sessionPubkey string no Ephemeral per-launch pubkey for message routing
displayName string no Human-readable name override for this session
sessionId string yes Client session identifier (e.g. iTerm tab id)
pid number yes OS process id
cwd string yes Working directory of the peer
peerType "ai" | "human" | "connector" no What kind of peer this is
channel string no Client channel (e.g. "claude-code", "slack", "web")
model string no AI model identifier (e.g. "opus-4", "sonnet-4")
groups Array<{name, role?}> no Groups to join on connect
timestamp number yes ms epoch; broker rejects if outside ±60 s of its clock
signature string yes ed25519 signature over ${meshId}|${memberId}|${pubkey}|${timestamp}

Peer list

The peers_list response includes session metadata for each connected peer, mirroring the fields sent in hello.

{
  "type": "peers_list",
  "peers": [
    {
      "pubkey": "<ed25519 hex>",
      "displayName": "Mou",
      "status": "working",
      "summary": "Refactoring the scheduler",
      "groups": [{ "name": "backend", "role": "lead" }],
      "sessionId": "w1t0p0",
      "connectedAt": "2025-06-04T10:30:00Z",
      "cwd": "/home/user/project",
      "peerType": "ai",
      "channel": "claude-code",
      "model": "opus-4"
    }
  ]
}
Field Type Required Description
pubkey string yes Peer's ed25519 public key (hex)
displayName string yes Human-readable name
status PeerStatus yes "idle", "working", or "dnd"
summary string | null yes Session summary set by the peer
groups Array<{name, role?}> yes Groups the peer belongs to
sessionId string yes Client session identifier
connectedAt string yes ISO 8601 timestamp
cwd string no Working directory
peerType "ai" | "human" | "connector" no Peer kind
channel string no Client channel
model string no AI model identifier

System notifications

The broker broadcasts topology events as push messages with subtype: "system". These are not encrypted — the broker generates them directly.

{
  "type": "push",
  "messageId": "msg_xyz",
  "meshId": "acme-payments",
  "senderPubkey": "<broker pubkey>",
  "priority": "low",
  "nonce": "",
  "ciphertext": "",
  "createdAt": "2025-06-04T10:30:00Z",
  "subtype": "system",
  "event": "peer_joined",
  "eventData": {
    "pubkey": "<ed25519 hex>",
    "displayName": "Mou",
    "peerType": "ai"
  }
}
Field Type Required Description
subtype "reminder" | "system" no "system" for topology events, "reminder" for scheduled deliveries
event string no Machine-readable event name (e.g. "peer_joined", "peer_left")
eventData Record<string, unknown> no Structured payload for the event

The standard push fields (messageId, meshId, senderPubkey, priority, nonce, ciphertext, createdAt) are always present. For system notifications, nonce and ciphertext are empty strings.


Scheduled messages

Peers can schedule one-shot or recurring messages for future delivery. When a scheduled message fires, the recipient receives a standard push with subtype: "reminder".

schedule (peer → broker)

{
  "type": "schedule",
  "to": "<pubkey or display name>",
  "message": "Stand-up in 5 minutes",
  "deliverAt": 1717459200000,
  "subtype": "reminder",
  "cron": "0 9 * * 1-5",
  "recurring": true
}
Field Type Required Description
to string yes Recipient — member pubkey or display name
message string yes Plaintext message body
deliverAt number yes Unix timestamp (ms). Ignored when cron is set.
subtype "reminder" no Semantic tag — surfaces differently to the receiver
cron string no Standard 5-field cron expression for recurring delivery
recurring boolean no Whether this is a recurring schedule. Implied true when cron is set.

scheduled_ack (broker → peer)

{
  "type": "scheduled_ack",
  "scheduledId": "sched_abc",
  "deliverAt": 1717459200000,
  "cron": "0 9 * * 1-5"
}
Field Type Required Description
scheduledId string yes Assigned id for the scheduled entry
deliverAt number yes Resolved delivery time (ms epoch)
cron string no Echoed cron expression for recurring entries

list_scheduled (peer → broker)

No payload fields beyond type.

scheduled_list (broker → peer)

{
  "type": "scheduled_list",
  "messages": [
    {
      "id": "sched_abc",
      "to": "<pubkey>",
      "message": "Stand-up in 5 minutes",
      "deliverAt": 1717459200000,
      "createdAt": 1717372800000,
      "cron": "0 9 * * 1-5",
      "firedCount": 3
    }
  ]
}
Field Type Required Description
id string yes Scheduled entry id
to string yes Recipient
message string yes Message body
deliverAt number yes Next delivery time (ms epoch)
createdAt number yes When the entry was created (ms epoch)
cron string no Cron expression, present for recurring entries
firedCount number no Times the cron entry has fired so far

cancel_scheduled (peer → broker)

Field Type Required Description
scheduledId string yes Id of the entry to cancel

cancel_scheduled_ack (broker → peer)

Field Type Required Description
scheduledId string yes Echoed id
ok boolean yes Whether cancellation succeeded

Crypto

  • Signing — ed25519 (libsodium crypto_sign). One keypair per peer per mesh, generated on the client at enrollment.
  • Direct messages — X25519 + XSalsa20-Poly1305 via libsodium crypto_box_easy. Peer A encrypts to peer B's public key.
  • Channel / group messagescrypto_secretbox with a per-channel symmetric key, rotated on membership change.
  • Nonces — 24-byte random nonces, bundled with ciphertext.

Keys live on the client in ~/.claudemesh/config.json (or $CLAUDEMESH_CONFIG_DIR). The broker operator has nothing to decrypt.

Canonical implementations:


A mesh owner issues signed invite links in the form:

ic://join/<base64url(JSON)>

The inner JSON looks like:

{
  "mesh":    "acme-payments",   // mesh slug
  "broker":  "wss://ic.claudemesh.com/ws",
  "exp":     1717459200,        // unix seconds
  "role":    "peer",            // peer | admin
  "enroll":  "<ed25519 pubkey of the mesh owner>",
  "sig":     "<ed25519 signature over the above fields>"
}

The CLI verifies sig with enroll, checks exp, generates a fresh peer keypair, and posts enrollment to the broker. The broker records the new peer and rebroadcasts presence.

Invite-link issuance: apps/cli/src/invite/.

v2 invites (in progress)

v1 embeds the mesh root key inside the URL. v2 removes it: the URL is a short opaque code, and the root key is sealed to a recipient-controlled x25519 public key on claim. Both formats are accepted through v0.1.x; v1 is removed at v0.2.0.

Canonical bytes signed by the mesh owner ed25519 secret:

v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex

User-visible URL: https://claudemesh.com/i/{code} (base62, 8 chars).

Claim endpoint

POST /api/public/invites/:code/claim
Content-Type: application/json

{
  "recipient_x25519_pubkey": "<base64url>"
}

The recipient generates a fresh x25519 keypair (distinct from its ed25519 identity) and sends the public half. The server never sees the secret.

Success response:

{
  "sealed_root_key": "<base64url>",      // crypto_box_seal(root_key, recipient_pubkey)
  "mesh_id":         "<text>",
  "member_id":       "<text>",
  "owner_pubkey":    "<hex>",            // mesh owner ed25519 pubkey
  "canonical_v2":    "v=2|..."           // the signed bytes, for local verification
}

The recipient unseals with crypto_box_seal_open using its x25519 secret key, then verifies canonical_v2 against owner_pubkey.

Error codes

Status Body code Meaning
400 malformed Body missing or recipient_x25519_pubkey not a valid 32-byte key
400 bad_signature Stored capability_v2 fails ed25519 verification against the mesh owner pubkey
404 not_found No invite row matches code
410 expired expires_at is in the past
410 revoked revoked_at is set
410 exhausted used_count >= max_uses

The broker increments used_count and stores claimed_by_pubkey = recipient_x25519_pubkey atomically with the member row insert. A second claim against a single-use invite fails with 410 exhausted.

Email invites

A pending_invite row is created when an admin invites by email. The email contains https://claudemesh.com/i/{code} — the same short URL surface as link invites. On successful claim the broker sets pending_invite.accepted_at.


Self-hosting

Point the CLI at your own broker:

export CLAUDEMESH_BROKER_URL="wss://broker.yourteam.local/ws"

The broker is apps/broker — a single Node/Bun process with Postgres for presence + offline queueing. No secrets to share. Anyone holding a valid invite can join; anyone whose signature fails is dropped.


What's next

Tag-based routing, channel pub/sub, and federation between brokers are on the v0.2 roadmap. Full protocol spec is in progress.