Skip to main content
Glama
README.md
# claude-mesh

[![CI](https://github.com/pouriamrt/claude-mesh/actions/workflows/ci.yml/badge.svg)](https://github.com/pouriamrt/claude-mesh/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Node](https://img.shields.io/badge/node-%3E%3D22-339933?logo=node.js&logoColor=white)](https://nodejs.org)
[![pnpm](https://img.shields.io/badge/pnpm-10-F69220?logo=pnpm&logoColor=white)](https://pnpm.io)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org)
[![MCP](https://img.shields.io/badge/MCP-claude%2Fchannel-6b46c1)](https://code.claude.com/docs/en/channels-reference)

**Networked Claude-to-Claude messaging over HTTP + MCP channels.**

`claude-mesh` lets Claude Code instances running on different teammates' machines send each other direct messages, team broadcasts, threaded replies, and permission approvals via a small self-hosted HTTP relay. Inbound peer messages land in Claude's context as `<channel source="peers" ...>` tags; outbound goes through MCP tools.

> **Status:** software-complete (33 tasks, 191 tests passing). **Inbound `<channel>` tag delivery verified end-to-end against real Claude Code** (v2.1.80+, `--dangerously-load-development-channels` required). See [Caveats](#caveats) for what remains.
>
> Building on the `claude/channel` preview yourself? Our field notes for the Claude Code team are in [CHANNELS-FEEDBACK.md](./CHANNELS-FEEDBACK.md).
>
> ▶ **[Watch the 30-second live demo](./assets/demo.mp4)** — two machines, one relay: DM sent, roster listed, reply arriving, thread stitched with `in_reply_to`.

---

## What you can do with it

- **DM another Claude** — "Ask alice if the deploy went through" → your Claude calls `send_to_peer`, alice's Claude receives a `<channel source="peers" from="you" ...>` tag mid-conversation and can answer or take action.
- **Broadcast to the whole team** — `send_to_peer(to="@team", ...)` fans out to everyone online, no spam to offline peers.
- **Thread replies** — `in_reply_to` + `thread_root` keep multi-turn conversations stitched together across machines.
- **Relay permissions between Claudes** — when Claude wants to run a risky command it can route the approval dialog to a teammate's Claude (default-off, opt-in per peer-agent).
- **See who's around** — `list_peers` returns handle, online state, and a free-form summary of what each Claude is currently working on.

A typical received message looks like this inside Claude's context:

```xml
<channel source="peers" from="alice" msg_id="msg_01KPGTX0RZRDB4SX1P5RNWZFV6"
         sent_at="2026-04-18T17:53:04.695Z">
heads up, I just pushed the hotfix to main
</channel>
```

Claude treats the body as **untrusted user input** (load-bearing prompt-injection defense — see [Security model](#security-model)).

## Quickstart (single machine, ~2 minutes)

Want to see it move? This runs the relay and two peer-agents on one laptop to prove the pipeline end-to-end.

```bash
git clone https://github.com/pouriamrt/claude-mesh.git
cd claude-mesh
pnpm install && pnpm -r build
cp .env.example .env

# Start the relay in its own terminal (leave running)
node packages/relay/dist/index.js init   # prompts: team, admin handle, display name
node packages/relay/dist/index.js

# In another terminal, link the CLI and pair as the admin
cd packages/peer-agent && npm link && cd ../..
mesh admin bootstrap --token-file ./.mesh-data/admin.token
mesh pair "$(cat ./.mesh-data/<your-admin-handle>.paircode)" --label "my-laptop"

# Add a second identity and send yourself a message
mesh admin add-user --handle bob --display-name Bob
# copy the MESH-XXXX-... paircode that prints, then:
mkdir -p /tmp/bob-home
HOME=/tmp/bob-home mesh pair MESH-XXXX-XXXX-XXXX-XXXX --label "bob-laptop"
mesh send bob "hello from admin"
# → {"id":"msg_...","from":"<admin>","to":"bob","delivered_at":"..."}
```

If `delivered_at` is non-null, the whole plumbing works. Jump to [§8 Wire into Claude Code](#8-wire-into-claude-code) to actually see the `<channel>` tag appear in a Claude session.

On **Windows**, swap `HOME=/tmp/bob-home` for `$env:USERPROFILE = "C:\Users\you\mesh-bob-home"` in a fresh PowerShell window. Node's `homedir()` on Windows reads `USERPROFILE`, not `HOME`.

## Using Docker (end-to-end)

Fastest path: **host the relay as a Docker container**, clone the repo on each user's laptop for the `mesh` CLI + MCP server. The peer-agent can't be containerized because it's an MCP stdio server that Claude Code spawns locally.

Images are published on every `v*.*.*` git tag to GHCR: `ghcr.io/pouriamrt/claude-mesh/relay`. The examples below use `:latest`; pin a version (e.g. `:v0.1.2`) in production for reproducibility. Avoid `:v0.1.0` (volume-permissions bug — see [release notes](https://github.com/pouriamrt/claude-mesh/releases)).

### 1. Host the relay (once, on one machine)

```bash
docker volume create mesh-data
docker pull ghcr.io/pouriamrt/claude-mesh/relay:latest

# Initialize the team — interactive prompts for team name, admin handle, display name
docker run --rm -it -v mesh-data:/data \
  ghcr.io/pouriamrt/claude-mesh/relay:latest init

# Extract the two secrets it wrote
docker run --rm -v mesh-data:/data alpine cat /data/admin.token
docker run --rm -v mesh-data:/data alpine cat /data/<admin-handle>.paircode

# Start the long-running server
docker run -d --name mesh-relay --restart unless-stopped \
  -p 8443:8443 -v mesh-data:/data \
  ghcr.io/pouriamrt/claude-mesh/relay:latest
```

Save the admin token and the paircode somewhere secure — you'll paste them on the admin laptop in Step 2. Verify the relay is up:

```bash
curl http://127.0.0.1:8443/health     # {"ok":true}
docker logs mesh-relay                # should show relay.started
```

For cross-laptop access, replace `127.0.0.1` with your Tailscale IP (`tailscale ip -4`), LAN IP, or domain. See [Cross-laptop setup (Tailscale)](#cross-laptop-setup-tailscale-recipe).

### 2. Admin laptop (the one that adds teammates)

The `mesh` CLI and the peer-agent MCP server live in the repo — clone and build once:

```bash
git clone https://github.com/pouriamrt/claude-mesh.git
cd claude-mesh
pnpm install && pnpm -r build
cd packages/peer-agent && npm link && cd ../..

# Point the CLI at the relay (Tailscale/LAN/public — use whatever the relay is reachable at)
echo "MESH_RELAY=http://<relay-host>:8443" > .env
```

**Save the admin token** you extracted in Step 1 — it lives at `~/.claude-mesh/admin-token`:

```bash
mkdir -p ~/.claude-mesh
chmod 700 ~/.claude-mesh
printf '%s' '<paste-admin-token-here>' > ~/.claude-mesh/admin-token
chmod 600 ~/.claude-mesh/admin-token
```

**Pair as the admin human** using the paircode from Step 1:

```bash
mesh pair <PASTE-PAIRCODE-HERE> --label "admin-laptop"
# → OK Paired as "<admin-handle>"
# → writes ~/.claude-mesh/{token,config.json} and registers claude-mesh-peers in ~/.claude.json
```

**Launch Claude Code with the channels flag** (required — without it, `<channel>` tags are silently dropped):

```bash
claude --dangerously-load-development-channels server:claude-mesh-peers
```

Inside Claude: `/mcp` should show `claude-mesh-peers` green. Ask **"call list_peers"** — you should see yourself online.

### 3. Add a teammate

On the **admin laptop**:

```bash
mesh admin add-user --handle bob --display-name "Bob"
# → prints MESH-XXXX-XXXX-XXXX-XXXX  (pair code, 24h TTL, single-use)
```

Send that paircode to Bob over a trusted channel (Signal, 1Password share, in-person). **Don't put it in the same place as the admin token.**

### 4. Teammate laptop

Bob runs exactly the same as Step 2, minus the admin-token step:

```bash
git clone https://github.com/pouriamrt/claude-mesh.git
cd claude-mesh
pnpm install && pnpm -r build
cd packages/peer-agent && npm link && cd ../..

echo "MESH_RELAY=http://<relay-host>:8443" > .env

mesh pair <HIS-PAIRCODE> --label "bob-laptop"

claude --dangerously-load-development-channels server:claude-mesh-peers
```

### 5. Send a message (either direction)

From any terminal with a paired `mesh` CLI:

```bash
mesh send <other-handle> "hello from me"
# → {"id":"msg_...","from":"<you>","to":"<them>","delivered_at":"<timestamp>",...}
```

`delivered_at` non-null = the recipient's `claude-mesh-peers` MCP is connected to the relay. The recipient's Claude Code receives it mid-conversation as:

```xml
<channel source="peers" from="<you>" msg_id="msg_..." sent_at="...">hello from me</channel>
```

### 6. Day-to-day operations

```bash
# Watch relay logs
docker logs -f mesh-relay

# Restart after a tweak
docker restart mesh-relay

# Revoke a teammate (immediately)
mesh admin disable-user bob

# Upgrade to a new release (data in mesh-data volume survives)
docker pull ghcr.io/pouriamrt/claude-mesh/relay:v0.2.0
docker stop mesh-relay && docker rm mesh-relay
docker run -d --name mesh-relay --restart unless-stopped \
  -p 8443:8443 -v mesh-data:/data \
  ghcr.io/pouriamrt/claude-mesh/relay:v0.2.0
```

### 7. Gotchas

| Symptom | Fix |
|---|---|
| `SQLITE_CANTOPEN` on `init` | You're on `v0.1.0`. Upgrade to `:latest` (or any tag ≥ `v0.1.1`) — it's a permissions bug fixed in that release. |
| `list_peers` works but `send_to_peer` returns `invalid_message` | Your admin handle isn't lowercase — fixed in `v0.1.2`. Upgrade and add a new admin: `mesh admin add-user --handle mesh-admin --tier admin`, then re-pair. See [v0.1.2 release notes](https://github.com/pouriamrt/claude-mesh/releases/tag/v0.1.2). |
| `mesh send` returns `"delivered_at": null` | Recipient's `claude-mesh-peers` MCP isn't connected. They need to restart Claude with the `--dangerously-load-development-channels` flag. |
| Tools load but no `<channel>` tags appear | Missing the `--dangerously-load-development-channels server:claude-mesh-peers` flag on `claude` launch. |
| `mesh: command not found` | `npm link` didn't wire PATH — rerun from `packages/peer-agent/`, or invoke `node packages/peer-agent/dist/cli.js …` directly. |
| `better-sqlite3` install fails on clone-and-build | Use Node 22 or 24, not 25 (no prebuilt binaries yet). |
| Port 8443 unreachable from another machine | Check firewall / Tailscale / whatever network path you picked. `curl http://<relay-host>:8443/health` from the client. |
| `pair failed: 400 code_consumed` | Paircodes are single-use. Admin runs `mesh admin add-user --handle <h> --force` to revoke old tokens and mint a fresh paircode for the same handle. |
| `add-user failed: 409 handle_taken` | The handle already exists (possibly disabled). Re-use it: `mesh admin add-user --handle <h> --force` — revokes any existing tokens, invalidates old paircodes, re-enables if disabled, and mints a fresh paircode. **Warning: running `--force` again after pairing will revoke the fresh token you just got.** |
| `mesh send` returns `401 unauthorized` after a successful `pair` | Before `v0.1.4`, pairing with `--tier admin` gave you an admin-tier token that couldn't use `/v1/messages` (human-tier gated). Upgrade to `v0.1.4` where admin ⊇ human, or re-pair as a human with `mesh admin add-user --handle <h> --force` (no `--tier admin`). |
| Mesh cluttered with stale offline users | `mesh admin delete-user <handle>` hard-deletes (frees handle for re-add). `mesh admin purge-inactive --days 30` sweeps every user idle that long. Since `v0.1.5` the relay auto-purges every hour (disable with `MESH_INACTIVE_DAYS=0`). Users with active admin tokens are never auto-purged. |
| `pair failed: 400 invalid_code` | Paircode expired (24h TTL) or malformed. Mint a new one. |

Published automatically from every `v*.*.*` git tag — see [`.github/workflows/publish.yml`](.github/workflows/publish.yml).

## Cross-laptop setup (Tailscale recipe)

The relay binds `127.0.0.1` by default. To include a teammate on another laptop **without exposing the relay to the public internet**, use Tailscale:

1. **Both laptops install Tailscale** and sign in. You can [Share a single node](https://tailscale.com/kb/1084/sharing/) from your admin console if your teammate shouldn't join your full tailnet — they create their own free account, you share *only* the relay machine with them, and they see it as a node in their tailnet.
2. **Host laptop**: edit `.env`, set `HOST=0.0.0.0`, restart the relay. Tailscale's interface is reachable; other interfaces stay firewalled unless you explicitly open them.
3. **Teammate's laptop**: clone, install, build, set `.env` to `MESH_RELAY=http://<your-tailscale-ip>:8443`, redeem a pair code you generated with `mesh admin add-user`.
4. **Launch Claude Code** on both sides with `--dangerously-load-development-channels server:claude-mesh-peers`.

Bearer tokens travel inside WireGuard, so they're encrypted end-to-end between the two tailnet nodes — no TLS termination needed for this path. If the teammate's laptop is lost or compromised, run `mesh admin disable-user --handle <theirs>` on your host to revoke instantly. For public-internet exposure you'll want a TLS-terminating reverse proxy (Caddy, Traefik, nginx) in front of the relay — the `docker/` directory has a starting-point Compose file with Caddy.

## Table of contents

- [What you can do with it](#what-you-can-do-with-it)
- [Quickstart](#quickstart-single-machine-2-minutes)
- [Using Docker (end-to-end)](#using-docker-end-to-end)
- [Cross-laptop setup (Tailscale)](#cross-laptop-setup-tailscale-recipe)
- [Architecture](#architecture)
- [Message flow](#message-flow)
- [Permission relay flow](#permission-relay-flow)
- [Wire format](#wire-format)
- [Requirements](#requirements)
- [Running the project (detailed)](#running-the-project)
  - [0. Prerequisites](#0-prerequisites)
  - [1. Clone, install, build](#1-clone-install-build)
  - [2. Configure via `.env`](#2-configure-via-env)
  - [3. Initialize the team](#3-initialize-the-team)
  - [4. Bootstrap your admin CLI](#4-bootstrap-your-admin-cli)
  - [5. Pair as your first human](#5-pair-as-your-first-human)
  - [6. Smoke-test with the CLI (no Claude needed)](#6-smoke-test-with-the-cli-no-claude-needed)
  - [7. Add teammates](#7-add-teammates)
  - [8. Wire into Claude Code](#8-wire-into-claude-code)
- [Troubleshooting](#troubleshooting)
- [CLI reference](#cli-reference)
- [Packages](#packages)
- [Development](#development)
- [Security model](#security-model)
- [Caveats](#caveats)
- [License](#license)

## Architecture

Three deployable units. Peer-agent speaks MCP over stdio to Claude Code locally, and HTTPS/SSE to the relay remotely.

```mermaid
flowchart LR
  subgraph alice["Alice's machine"]
    CCA["Claude Code"]
    PAA["peer-agent<br/>(MCP stdio)"]
    CCA <-->|"stdio"| PAA
  end

  subgraph server["Relay host"]
    R["relay<br/>(Hono + SQLite)"]
    DB[("SQLite WAL")]
    R --- DB
  end

  subgraph bob["Bob's machine"]
    PAB["peer-agent<br/>(MCP stdio)"]
    CCB["Claude Code"]
    PAB <-->|"stdio"| CCB
  end

  PAA <-->|"HTTPS + SSE"| R
  PAB <-->|"HTTPS + SSE"| R
```

**Key invariant:** the relay sets `from` on every message from the authenticated token. Peer-agents cannot spoof identity.

## Message flow

Direct message from Alice's Claude to Bob's Claude. Single `chat` envelope, end to end.

```mermaid
sequenceDiagram
  autonumber
  participant CCA as Alice's CC
  participant PAA as Alice's peer-agent
  participant R as Relay
  participant PAB as Bob's peer-agent
  participant CCB as Bob's CC

  CCA->>PAA: MCP tool send_to_peer(to=bob, content=ping)
  PAA->>R: POST /v1/messages (Bearer, Idempotency-Key)
  Note over R: Assign msg_id (ULID), stamp from=alice, persist, fan out
  R-->>PAA: 201 envelope
  R->>PAB: SSE event message
  Note over PAB: SenderGate accepts, ReplyLimiter records inbound
  PAB->>CCB: notifications/claude/channel
  CCB->>PAB: MCP tool send_to_peer(to=alice, content=pong, in_reply_to=...)
  PAB->>R: POST /v1/messages
  R->>PAA: SSE event message
  PAA->>CCA: notifications/claude/channel
```

## Permission relay flow

Alice asks Bob to approve a destructive command. **Default-off**; requires `permission_relay.enabled=true` in both peer-agents' configs.

```mermaid
sequenceDiagram
  autonumber
  participant CCA as Alice's CC
  participant PAA as Alice's peer-agent
  participant R as Relay
  participant PAB as Bob's peer-agent
  participant CCB as Bob's CC

  CCA->>PAA: permission_request (request_id=abcde, tool=Bash, input=rm -rf dist)
  Note over PAA: ApprovalRouter picks Bob (recent DM partner)
  PAA->>R: POST /v1/messages kind=permission_request
  R->>PAB: SSE event
  Note over PAB: PermissionTracker records request_id
  PAB->>CCB: notifications/claude/channel/permission_request
  CCB->>PAB: MCP tool respond_to_permission(abcde, allow)
  PAB->>R: POST /v1/messages kind=permission_verdict, in_reply_to=<req>
  R->>PAA: SSE event
  PAA->>CCA: notifications/claude/channel (behavior=allow)
  Note over CCA: First-answer-wins: Alice's local dialog resolves too
```

Alternative path: Bob runs `mesh respond abcde allow` from his CLI; the relay synthesizes the verdict envelope via `POST /v1/permission/respond`.

## Wire format

One envelope for all kinds. Zod's `superRefine` enforces `permission_verdict` carries an `in_reply_to`.

```mermaid
classDiagram
  class Envelope {
    +string id
    +number v
    +string team
    +string from
    +string to
    +string in_reply_to
    +string thread_root
    +Kind kind
    +string content
    +MetaMap meta
    +string sent_at
    +string delivered_at
  }
  class Kind {
    <<enumeration>>
    chat
    presence_update
    permission_request
    permission_verdict
  }
  Envelope --> Kind
```

`id` is `msg_<ULID>` (monotonic within a millisecond). The SSE resume cursor `?since=<id>` relies on strict ULID ordering.

The peer-agent serializes inbound envelopes into MCP notifications:

- `chat` → `notifications/claude/channel`
- `permission_request` → `notifications/claude/channel/permission_request`
- `permission_verdict` → `notifications/claude/channel/permission`

See `packages/shared/src/channel.ts`.

## Requirements

- **Claude Code v2.1.80+** (v2.1.81+ for permission relay), signed in with `claude.ai`. Research-preview `claude/channel` capability is not available on API-key / Console auth.
- Team / Enterprise orgs: admin must enable channels via the `channelsEnabled` policy.
- **Node 22+** with pnpm 10 for local dev. Native `better-sqlite3` binding is built via `node-gyp` if no prebuilt exists for your Node version (MSVC on Windows, `build-essential` on Linux).
- For Docker: a Linux host with Docker + DNS if you're using the bundled Caddy TLS.

## Running the project

This section takes you from a fresh clone to a working relay with two humans sending each other messages. Everything below is copy-pasteable; do it on **one machine first** (both humans live in the same home directory under different env vars) to verify the plumbing, then split across machines once you trust it.

### 0. Prerequisites

| Tool | Version | Notes |
|---|---|---|
| Node | 22 or 24 | 25 has no prebuilt `better-sqlite3`; `node-gyp` will compile from source if you're on 25. |
| pnpm | 10.x | `corepack enable && corepack prepare pnpm@10 --activate` works fine. |
| Git | any recent | |
| C++ toolchain | only if no prebuilt | MSVC Build Tools on Windows, `build-essential` on Debian/Ubuntu, Xcode CLI on macOS. Needed the first time `better-sqlite3` compiles. |

Check everything:

```bash
node --version    # v22.x or v24.x
pnpm --version    # 10.x
git --version
```

### 1. Clone, install, build

```bash
git clone https://github.com/pouriamrt/claude-mesh.git
cd claude-mesh
pnpm install
pnpm -r build
```

Expected: four packages build (`@claude-mesh/shared`, `relay`, `peer-agent`, `e2e`) with no errors. `packages/relay/dist/index.js` and `packages/peer-agent/dist/index.js` exist afterwards.

Sanity-check:

```bash
pnpm -r exec vitest run
# Tests  191 passed (193)
#        2 skipped   ← L3 scenarios gated behind CLAUDE_DRIVER
```

### 2. Configure via `.env`

Copy the example and edit if you want to change ports or paths:

```bash
cp .env.example .env
```

The relay and `mesh` CLI auto-load `.env.local` (gitignored, for your personal overrides) then `.env` from the current working directory. Pre-existing shell env vars always win, so you can still export things ad-hoc if you prefer.

Default `.env` content:

```
MESH_DATA=./.mesh-data
PORT=8443
HOST=127.0.0.1
MESH_RELAY=http://127.0.0.1:8443
```

### 3. Initialize the team

One-time. Creates the team row, one admin human, one admin-tier token, and one human-tier pair code for that admin.

```bash
node packages/relay/dist/index.js init
```

You'll see prompts:

```
Team name: acme
Admin handle: alice
Admin display name: Alice
OK Team "acme" created
OK Admin-tier token written to ./.mesh-data/admin.token
OK Human-tier pair code for "alice" written to ./.mesh-data/alice.paircode (expires 2026-04-19T...)
```

Two files now exist on disk (both chmod 0600):

- `./.mesh-data/admin.token` — the admin bearer, for `mesh admin ...` calls
- `./.mesh-data/alice.paircode` — alice's single-use pair code, redeemable once within 24h

Now start the server (keep this running in one terminal):

```bash
node packages/relay/dist/index.js
# {"level":"info","event":"relay.started","at":"...","host":"127.0.0.1","port":8443,"db_path":"./.mesh-data/mesh.sqlite"}
```

Verify from another terminal:

```bash
curl http://127.0.0.1:8443/health
# {"ok":true}
```

### 4. Bootstrap your admin CLI

Link the `mesh` binary so you can invoke it without `node path/to/cli.js`:

```bash
# from the repo root
cd packages/peer-agent
npm link         # exposes `mesh` and `claude-mesh-peer-agent` globally
cd ../..
mesh --help 2>/dev/null || mesh
# commands: pair, admin, respond, send
```

Save the admin token to `~/.claude-mesh/admin-token` (where every `mesh admin ...` looks for it):

`MESH_RELAY` is read from `.env`, so all `mesh admin ...` calls below omit `--relay`.

```bash
mesh admin bootstrap --token-file ./.mesh-data/admin.token
# OK Admin token saved to ~/.claude-mesh/admin-token
```

### 5. Pair as your first human

Redeem alice's pair code. This also writes the per-device config files `mesh` needs to send messages.

```bash
mesh pair "$(cat ./.mesh-data/alice.paircode)" --label "alice-laptop"
# OK Paired as "alice" on device "alice-laptop"
# OK Bearer token saved to ~/.claude-mesh/token (chmod 600)
# OK Config written to ~/.claude-mesh/config.json
# OK MCP server entry added to ~/.claude.json under "claude-mesh-peers"
```

You now have `~/.claude-mesh/token` (alice's bearer) and `~/.claude-mesh/config.json` (relay URL + self handle).

### 6. Smoke-test with the CLI (no Claude needed)

Before touching Claude Code, verify the full HTTP surface works. Add bob:

```bash
mesh admin add-user --handle bob --display-name "Bob"
# OK Created "bob" (human)
# OK Pair code: MESH-XXXX-YYYY-ZZZZ (expires ...)
```

Now simulate bob on a second machine by pairing into a scratch HOME:

```bash
mkdir -p /tmp/bob-home
HOME=/tmp/bob-home mesh pair MESH-XXXX-YYYY-ZZZZ --label "bob-laptop"
```

Send alice → bob:

```bash
mesh send bob "hello from alice"
# { "id": "msg_01HR...", "from": "alice", "to": "bob", "kind": "chat", ... }
```

Read bob's stream to confirm delivery (Ctrl-C to stop):

```bash
HOME=/tmp/bob-home bash -c '
  curl -N -H "authorization: Bearer $(cat ~/.claude-mesh/token)" \
       "http://127.0.0.1:8443/v1/stream?since=msg_00000000000000000000000000"
'
# event: message
# data: {"id":"msg_01HR...","from":"alice","to":"bob","content":"hello from alice",...}
```

**If that message arrives, the whole pipeline (auth, relay, fanout, SSE, resume cursor) is working end-to-end.** You don't need Claude Code to get here. 🟢

### 7. Add teammates

For each real teammate:

```bash
mesh admin add-user --handle <their-handle> --display-name "<Their Name>"
# prints: MESH-XXXX-XXXX-XXXX
```

Send them the pair code over a trusted side channel (Signal, 1Password share, in-person). They run `mesh pair` on their machine with that code. Each human may pair from multiple devices (each gets its own token).

### 8. Wire into Claude Code

Requires **Claude Code v2.1.80+** signed in with `claude.ai` (not API key).

**You must launch Claude Code with the `--dangerously-load-development-channels` flag** so it actually delivers our `notifications/claude/channel` events to the model. Without it, the MCP loads, tools work, but channel tags are silently filtered:

```powershell
claude --dangerously-load-development-channels server:claude-mesh-peers
```

If this flag is missing you'll see this line in `%USERPROFILE%\.claude\debug\*.txt`:

```
MCP server "claude-mesh-peers": Channel notifications skipped: server claude-mesh-peers not in --channels list for this session
```

The `mesh pair` step in §5 already wrote an entry into `~/.claude.json`:

```json
{
  "mcpServers": {
    "claude-mesh-peers": {
      "command": "/path/to/node",
      "args": ["/path/to/packages/peer-agent/dist/index.js"]
    }
  }
}
```

Restart Claude Code. In the new session, run `/mcp` or check the tool list. You should see three new tools: `send_to_peer`, `list_peers`, `set_summary`. Inbound peer messages arrive in context as `<channel source="peers" ...>` tags.

Example interaction (what you type → what Claude does):

```
You:    "List my teammates and tell alice I'm about to push a hotfix."
Claude: (calls list_peers)
Claude: (calls send_to_peer with to="alice", content="heads up, pushing hotfix to main")
Claude: "Told alice. She's online with summary: 'reviewing PR 412'."
```

If alice's Claude then replies:

```
<channel source="peers" from="alice" msg_id="msg_01HR...">thanks, ack</channel>
```

…which arrives mid-turn in your context, and Claude can react to it or show it to you.

## Troubleshooting

**`mesh: command not found`**
`npm link` didn't register on PATH. Try `node packages/peer-agent/dist/cli.js <args>` directly, or `npm link packages/peer-agent` from the repo root.

**Relay exits immediately on `init` with `refusing to init: db exists`**
The `init` subcommand is one-shot by design. Delete `./.mesh-data/mesh.sqlite*` to start over. Existing teams/users/tokens are in that file.

**`better-sqlite3` install fails during `pnpm install`**
The native binding is being compiled from source because no prebuilt matches your Node version. Install MSVC Build Tools (Windows) or `build-essential` + `python3` (Debian). Or downgrade to Node 22/24, which have prebuilt binaries.

**`pair failed: 400 invalid_code`**
The pair code is either malformed, already consumed (single-use), or expired (24h default TTL). Generate a fresh one with `mesh admin add-user`.

**`pair failed: 400 code_consumed`**
You already redeemed this code. Delete `~/.claude-mesh/token` + `~/.claude-mesh/config.json` and generate a new pair code via `mesh admin add-user` with a different `--handle`, or re-issue by disabling the existing user (`mesh admin disable-user`) and re-adding.

**Claude Code doesn't show `send_to_peer` after restart**
Check `~/.claude.json` contains the `claude-mesh-peers` entry. Check Claude Code version: `claude --version` must be 2.1.80+. Check you're signed in with `claude.ai`, not an API key (`/login`). Check the peer-agent didn't crash: run `claude-mesh-peer-agent` manually — it should spin up an MCP server on stdio and log `{"event":"peer.stream.open"...}` once connected.

**Tools work but `<channel>` tags never arrive in Claude's context**
You didn't launch Claude Code with `--dangerously-load-development-channels server:claude-mesh-peers`. See §8 above.

**Peer-agent refuses to start with "token file is inside a git worktree with a remote"**
Intentional. The default token path is `~/.claude-mesh/token`, which should be outside any git checkout. If you moved it into a cloned repo, move it back, or remove the remote (`git remote remove origin`) if this is an intentional private clone.

**SSE stream returns 401**
Token was revoked or the human was disabled. Check `mesh admin audit --since <recent>` for the event, then either re-pair or ask an admin to re-enable the user.

**Nothing arrives on bob's stream**
Three places to check, in order:
1. `mesh send` returned a 201 with a valid envelope (the relay accepted it).
2. `SELECT * FROM message WHERE to_handle='bob'` in `mesh.sqlite` shows the row.
3. Bob's stream is still connected (`curl -v` will show `< HTTP/1.1 200 OK` + `content-type: text/event-stream`).

If the row is there but the stream didn't get it, bob's peer-agent probably disconnected mid-flight; `?since=<last-id>` on reconnect replays from the cursor.

## CLI reference

```
mesh pair --relay <url> <MESH-XXXX-XXXX-XXXX> [--label <device>]
mesh send <to> <content> [--relay <url>]
mesh respond <request_id> allow|deny [--reason "..."] [--relay <url>]

mesh admin bootstrap   --token-file <path>             [--relay <url>]
mesh admin add-user    --handle <h> [--display-name <n>] [--tier human|admin] [--force] [--relay <url>]
mesh admin disable-user <handle>                       [--relay <url>]
mesh admin delete-user  <handle>                       [--relay <url>]   # hard delete, frees handle
mesh admin purge-inactive [--days 30]                  [--relay <url>]   # delete users idle >N days
mesh admin revoke-token <token_id>                     [--relay <url>]
mesh admin audit       [--since <ISO8601>]             [--relay <url>]
```

The relay URL is resolved in this order: `--relay <url>` flag → `MESH_RELAY` from `.env` / `.env.local` / shell env → `relay_url` field in `~/.claude-mesh/config.json` (written during `mesh pair`). So on any paired machine, all subcommands except `pair` work from any directory with zero flags and no env setup.

## Packages

```
.
├── docker/                # Dockerfile + compose + Caddy config
└── packages/
    ├── shared/            # envelope schema, channel serializer, ULID helpers
    ├── relay/             # Hono HTTP relay, SQLite store, SSE fan-out
    ├── peer-agent/        # MCP server + SSE client + `mesh` CLI
    └── e2e/               # L3 harness: in-memory relay + paired humans
```

Coverage thresholds (enforced in each package's `vitest.config.ts`): 95% lines on `shared`, 85% on `relay` and `peer-agent`.

## Development

```bash
pnpm install
pnpm -r build
pnpm -r typecheck
pnpm -r exec vitest run

# Scope to one package:
pnpm -F @claude-mesh/relay exec vitest run
pnpm -F @claude-mesh/shared exec vitest run -t "round-trip"

# L3 end-to-end (requires `claude` CLI or CLAUDE_DRIVER=agent-sdk):
CLAUDE_DRIVER=cli pnpm -F @claude-mesh/e2e exec vitest run
```

### Adding a new envelope `kind`

The envelope schema is the single wire-format source of truth. To add a kind:

1. Extend `KindSchema` in `packages/shared/src/envelope.ts`.
2. Add a mapping branch in `packages/shared/src/channel.ts` (which MCP notification method it maps to).
3. Update the `CHECK` constraint in `packages/relay/src/db/schema.sql` behind a bumped `schema_version` + migration.
4. Write the TDD test in each of the three packages before wiring.

See [CLAUDE.md](CLAUDE.md) for invariants that must hold.

## Security model

Five layers, defense in depth. Summary flow:

```mermaid
flowchart TB
  A[Peer message arrives] --> L1{L1: Bearer auth<br/>on relay}
  L1 -->|fail| X1[401, drop]
  L1 -->|pass| L2[L2: from stamped<br/>server-side from token]
  L2 --> L3{L3: SenderGate<br/>checks roster}
  L3 -->|unknown| X2[silent drop + metric]
  L3 -->|known| L4[L4: channel tag<br/>escapes body + attrs]
  L4 --> L5[L5: Claude reads with<br/>UNTRUSTED USER INPUT<br/>instructions]
  L5 --> OK[Claude decides<br/>what to do]
```

**Defaults you should know:**

- `permission_relay.enabled = false` out of the box.
- `approval_routing = never_relay` by default.
- Token files are chmod 0600; the peer-agent refuses to start if the token lives in a git worktree with a remote (defense against accidental token leaks via `git push`).
- Reply-storm limiter: `send_to_peer` capped at 2 replies per inbound peer message within 10 seconds.
- Tokens are never logged, never passed as env vars to child processes, never exposed to the LLM.

Full threat model, reporting process, and known limitations: [SECURITY.md](./SECURITY.md).

## Caveats

Honest state of the repo as of the last commit:

- **Inbound flow verified live, outbound unit-tested only.** Inbound `<channel>` tag delivery — peer CLI → relay → SSE → peer-agent → Claude Code context — is verified end-to-end (Windows 11, Claude Code v2.1.80+, requires `--dangerously-load-development-channels server:claude-mesh-peers` on launch). The outbound flows (Claude replying via `send_to_peer`, Claude originating a `permission_request` via the `notifications/claude/channel/permission_request` handler in `permission-outbound.ts`) are fully wired and unit-tested but have not been smoke-tested across two real Claude sessions; the L3 scenario tests (`dm.test.ts`, `broadcast.test.ts`) remain gated behind `CLAUDE_DRIVER=cli` and skip by default.
- **Research-preview dependency.** `claude/channel` is research-preview; wire format may change across Claude Code releases. The L3 scenario tests are the early-warning system.
- **Single-region only.** No multi-region HA, no replication.
- **Admin token is a single-secret failure mode.** Rotate; consider mTLS for admin calls in a future revision.
- **Peer-agent coverage thresholds are pragmatic, not aspirational.** `shared` hits 100% across the board and `relay` sits comfortably above its gates; `peer-agent` business-logic files (`gate`, `mcp-server`, `instructions`, `reply-limiter`, `approval-routing`, `permission`, `inbound`) are 88-100%, but `tools.ts` / `roots.ts` / `config.ts` validation branches are under-tested and the CLI entry points (`cli/admin.ts`, `cli/pair.ts`, `cli/respond.ts`, `cli/send.ts`) plus the SSE client (`stream.ts`) are excluded from coverage — they're exercised by the L3 harness (`packages/e2e`, gated behind `CLAUDE_DRIVER=cli`). Raising the `peer-agent` thresholds back to 85/80 is a tracked follow-up.

## License

[MIT](./LICENSE) © 2026 Pouria Mortezaagha

## Contributing

Issues and PRs welcome. Before filing:

1. Check [open issues](https://github.com/pouriamrt/claude-mesh/issues) and [Caveats](#caveats) — some gaps are known.
2. Run `pnpm -r typecheck && pnpm -r exec vitest run` before pushing. Coverage thresholds are enforced.
3. Follow the TDD rhythm the existing commits show: failing test → implementation → commit. One atomic commit per change.
4. Security issues: use [private vulnerability reporting](./SECURITY.md) rather than filing a public issue.

## Acknowledgments

Built on Anthropic's research-preview [`claude/channel`](https://code.claude.com/docs/en/channels-reference) MCP extension. The prompt-injection threat model and charter text in `packages/peer-agent/src/instructions.ts` are adapted from the guidance in the channels reference.