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>
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 messages —
crypto_secretboxwith 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:
- broker side:
apps/broker/src/crypto.ts - client side:
apps/cli/src/crypto/
Invite links
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.