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>
457 lines
22 KiB
Markdown
457 lines
22 KiB
Markdown
# 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`](./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.
|
|
|
|
```jsonc
|
|
{
|
|
"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`.
|
|
|
|
```jsonc
|
|
{
|
|
"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.
|
|
|
|
```jsonc
|
|
{
|
|
"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)
|
|
|
|
```jsonc
|
|
{
|
|
"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)
|
|
|
|
```jsonc
|
|
{
|
|
"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)
|
|
|
|
```jsonc
|
|
{
|
|
"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_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:
|
|
- broker side: [`apps/broker/src/crypto.ts`](../apps/broker/src/crypto.ts)
|
|
- client side: [`apps/cli/src/crypto/`](../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:
|
|
|
|
```jsonc
|
|
{
|
|
"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/`](../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:
|
|
|
|
```jsonc
|
|
{
|
|
"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:
|
|
|
|
```sh
|
|
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](./roadmap.md). Full protocol spec is in progress.
|