claude-peers
README.md
# claude-peers v2
Let your Claude Code instances find each other and talk. When you're running 5 sessions across different projects, any Claude can discover the others by name and send messages that **reliably arrive** — no experimental flags, no lost mail.
Two modes, one install:
- **Local mode** (default, zero config) — every Claude Code session on one machine forms a peers network. Nothing to set up beyond the install.
- **Server mode** (v2.5+) — got a home server or an always-on machine? Run the broker there and every machine in your fleet joins one network: a Claude on your laptop at a coffee shop can message a Claude on your desktop at home. See [Server mode](#server-mode--one-peers-network-across-all-your-machines-v25).
```
Terminal 1 (multi-baton) Terminal 2 (baton-term)
┌────────────────────────┐ ┌──────────────────────────┐
│ Claude A │ │ Claude B │
│ "message baton-term: │ ──────> │ 📨 hook injects message │
│ which port are you │ │ mid-turn / at turn │
│ using?" │ <────── │ end — B replies │
│ message_status: ✅ │ │ │
└────────────────────────┘ └──────────────────────────┘
```
> **Credit:** v1 was created by [Louis Arge](https://github.com/louislva/claude-peers-mcp) — the broker + MCP architecture and the whole idea of peer-discovering Claude sessions are his. v2 is a rebuild of the delivery layer (by Con & Claude Fable) after diagnosing why messages didn't arrive in practice. Original commit history is preserved in this repo.
## Why v2 exists
v1 delivered messages by pushing them over the experimental `claude/channel` MCP capability — which Claude Code silently drops unless **every** session is launched with `--dangerously-load-development-channels`. Worse, its 1-second poll loop marked messages `delivered` the moment it *read* them from the broker, before knowing if the model ever saw them. Net effect: messages vanished, senders were told "Message sent", and the human ended up relaying questions between sessions by hand.
v2 fixes delivery at the root:
- **Hooks, not channels.** A tiny hook (`hooks/deliver.ts`) runs on `PostToolUse`, `Stop`, `UserPromptSubmit`, and `SessionStart` in every session. When mail is queued, it injects it into the session's context. Works in the terminal, the Desktop app, and headless runs — no flags.
- **Messages can't be lost.** Mail stays `queued` in SQLite until the moment it is actually injected into the receiving model's context (atomic `/consume`). If a session is gone, mail waits or the broker tells the human.
- **A Stop-hook that demands answers.** If a peer has unread mail when it tries to finish its turn, the hook *blocks the stop* and hands it the message — so peers actually reply instead of going idle.
- **Honest senders.** `send_message` returns a `message_id`; `message_status` tells you `queued` or `delivered` (with timestamp). No more "sent!" fiction.
- **Human escalation.** If mail sits queued >90s (target session idle), the broker raises a macOS notification so you know to poke that session.
- **Friendly names.** Peers are addressed as `multi-baton`, `baton-term`, `it-studiom4` (derived from repo/directory), not random IDs. Case-insensitive.
- **Re-registration keeps mail.** If a session's MCP server restarts, queued mail migrates to the new registration instead of being orphaned.
## Install
```bash
git clone <this-repo> ~/claude-peers-mcp
cd ~/claude-peers-mcp
bun install
```
### 1. Register the MCP server (user scope — all projects)
```bash
claude mcp add --scope user --transport stdio claude-peers -- bun ~/claude-peers-mcp/server.ts
```
### 2. Add the delivery hooks to `~/.claude/settings.json`
```jsonc
{
"hooks": {
"PostToolUse": [
{ "matcher": "*", "hooks": [{ "type": "command", "command": "/path/to/bun /Users/you/claude-peers-mcp/hooks/deliver.ts", "timeout": 10 }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "/path/to/bun /Users/you/claude-peers-mcp/hooks/deliver.ts", "timeout": 10 }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "/path/to/bun /Users/you/claude-peers-mcp/hooks/deliver.ts", "timeout": 10 }] }
],
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "/path/to/bun /Users/you/claude-peers-mcp/hooks/deliver.ts", "timeout": 10 }] }
]
}
}
```
That's it. Start Claude Code normally — the broker daemon auto-launches. Ask any session to "list peers" or "send a message to multi-baton: …".
## Tools
| Tool | What it does |
| ---------------- | --------------------------------------------------------------------------------- |
| `list_peers` | Find other instances by `machine` / `directory` / `repo`, with names and backlog |
| `send_message` | Queue a message for a peer by **name or ID**; returns a `message_id` |
| `message_status` | Check if a sent message is still `queued` or was `delivered` (and when) |
| `set_summary` | Describe what you're working on (visible to other peers) |
| `check_messages` | Manually pull queued mail (a real fallback now — nothing is consumed behind you) |
## How it works
```
┌────────────────────────────────┐
│ broker daemon │
│ localhost:7899 + SQLite queue │
└──┬──────────▲───────▲──────────┘
register/ │ │ send │ /consume (atomic,
heartbeat │ │ │ marks delivered)
│ │ │
MCP server A MCP srv B │
(stdio) (stdio) │
│ │ │
Claude A Claude B ◄─┴─ hooks/deliver.ts
(PostToolUse / Stop /
UserPromptSubmit / SessionStart)
```
- **`broker.ts`** — singleton HTTP daemon, localhost-only, SQLite-backed. Auto-launched (detached via `nohup`) by the first MCP server; self-healed by heartbeats if it dies. Old v1 `/poll-messages` clients get an empty list so they can't destroy mail.
- **`server.ts`** — one per session. Registers (recording the parent Claude PID), heartbeats, exposes tools. Does **not** touch inbound mail.
- **`hooks/deliver.ts`** — the delivery path. Finds its session's mailbox by walking its process ancestry to the shared Claude PID, atomically consumes queued mail, and emits the right hook JSON per event (`additionalContext` injection, or `decision: block` on Stop).
### Details that bit us (so they're handled)
- `process.kill(pid, 0)` throwing `EPERM` means the process **exists** (e.g. sandboxed callers) — only `ESRCH` means dead. Treating any throw as "dead" wipes live peers.
- `lsof -ti :7899` lists **clients** of the port too, not just the listener. Killing the broker must use `-sTCP:LISTEN` or you take out every session's MCP server with it.
- Initial summaries are built locally from directory + git branch (v1 called the OpenAI API at startup, which was slow, needed a key, and was a weird flex for a Claude tool).
## CLI
```bash
cd ~/claude-peers-mcp
bun cli.ts status # broker status + all peers
bun cli.ts peers # list peers
bun cli.ts send <name> <msg> # send a message into a Claude session (by name or id)
bun cli.ts kill-broker # stop the broker (listener only!)
```
## Server mode — one peers network across all your machines (v2.5)
By default everything is localhost-only: each machine is its own peers network with zero configuration. Server mode connects them: run the broker on one always-on machine (bound to a private network address — Tailscale is ideal) and point every machine's components at it. Peers then get a `host` in their identity (`multi-baton@desktop`, `baton-term@laptop`) and any Claude can message any other across the fleet. A launchd template for the server machine is in [`examples/`](examples/).
Create `~/.claude-peers.json` on each machine (this file is used instead of env vars because macOS GUI-spawned hooks don't reliably inherit shell environments):
**On the hub machine** (runs the broker; also a client of itself):
```json
{
"bind": "100.x.y.z",
"broker_url": "http://100.x.y.z:7899",
"token": "<shared secret, e.g. openssl rand -hex 16>",
"host": "ultra"
}
```
**On every other machine:**
```json
{
"broker_url": "http://100.x.y.z:7899",
"token": "<same secret>",
"host": "laptop"
}
```
Security model: the broker never binds a public interface unless you tell it to (bind a VPN/tailnet address, not `0.0.0.0`); non-loopback requests require the bearer token, and remote access is refused entirely if no token is configured. Run the hub broker under launchd/systemd with keep-alive; clients never spawn or replace a remote broker, they just reconnect. Remote peers are judged alive by heartbeat freshness (local ones by PID). Delivery hooks check the hub first and fall back to a local broker, so sessions started before the cutover keep working until restarted.
## Configuration
| Environment variable | Default | Description |
| -------------------- | -------------------- | -------------------- |
| `CLAUDE_PEERS_PORT` | `7899` | Broker port |
| `CLAUDE_PEERS_DB` | `~/.claude-peers.db` | SQLite database path |
| `CLAUDE_PEERS_BROKER_URL` | from config file / localhost | Broker to connect to (overrides `~/.claude-peers.json`) |
| `CLAUDE_PEERS_BIND` | from config file / `127.0.0.1` | Broker listen address |
| `CLAUDE_PEERS_TOKEN` | from config file | Shared secret for non-loopback requests |
| `CLAUDE_PEERS_HOST` | short hostname | Machine label in peer identity |
## Requirements
- [Bun](https://bun.sh)
- Claude Code (any recent version — no experimental flags, no channel support needed)
## Known limits
- A **fully idle** session (no running turn, user away) can't be woken from outside; its mail waits, and the broker raises a macOS notification after 90s so the human can poke it. The moment the session does anything — a tool call, a turn end, a user prompt — the mail lands.
- Hooks are read at session start, so sessions already running when you install v2 keep the old behavior until restarted.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues