louislva/claude-peers-mcp · architecture note

How claude-peers wires Claude Codes together

Two roles, one wire protocol. Every Claude Code session spawns its own server.ts MCP process; the first one to start also auto-launches a singleton broker.ts HTTP daemon on 127.0.0.1:7899 backed by a SQLite file at ~/.claude-peers.db. Peers talk only to the broker, never directly to each other — the broker is the address book and the message queue. The "instant push" feel comes from each MCP server polling /poll-messages once a second and re-emitting any new rows as a notifications/claude/channel JSON-RPC message, which Claude Code splices into the model's context mid-conversation.

Mental model: the broker is a tiny localhost message bus with a SQLite outbox; each MCP server is a stdio bridge that turns Claude's tool calls into HTTP and turns broker rows into channel notifications. There is no socket from Claude to Claude — only Claude → MCP → HTTP → SQLite → HTTP → MCP → Claude.

Topology

Terminal A · poker-engine Terminal B · eel localhost · machine-wide Claude Code A user-facing CLI MCP server (stdio) server.ts · per session Claude Code B user-facing CLI MCP server (stdio) server.ts · per session Broker daemon broker.ts 127.0.0.1:7899 Bun.serve · POST routes SQLite (WAL) ~/.claude-peers.db stdio JSON-RPC stdio JSON-RPC HTTP HTTP bun:sqlite

End-to-end flow

1
server.ts :453-499 · main() → ensureBroker → register

When Claude Code spawns the MCP server over stdio, main() first probes GET /health. If the broker isn't up, it shells out bun broker.ts with proc.unref() so the daemon outlives this MCP process, then polls for up to six seconds until /health answers. Then it gathers context (cwd, git_root via git rev-parse --show-toplevel, parent tty) and POST /registers with the OS pid. The broker generates an 8-char alphanumeric ID and returns it.

show source · server.ts
// server.ts — startup
async function main() {
  await ensureBroker();          // spawn bun broker.ts if /health is dead

  myCwd     = process.cwd();
  myGitRoot = await getGitRoot(myCwd);

  const reg = await brokerFetch<RegisterResponse>("/register", {
    pid:      process.pid,
    cwd:      myCwd,
    git_root: myGitRoot,
    tty,
    summary:  initialSummary,
  });
  myId = reg.id;                  // e.g. "k3p9wf2x"
}
2
shared/summarize.ts :10-69 · optional auto-summary

Best-effort, non-blocking. If OPENAI_API_KEY is set, the server asks the OpenAI Chat Completions API (model name in source: gpt-5.4-nano) for a 1–2 sentence guess at what this developer is doing, computed from cwd, current git branch, files in git diff --name-only HEAD, and the last five commits. Startup races the summary against a 3 s timeout — if it loses, it registers with an empty summary and patches it in via /set-summary later. Other peers see this string when they call list_peers.

show source · shared/summarize.ts
// shared/summarize.ts
export async function generateSummary(context) {
  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) return null;        // silent fallback

  const res = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}`, ... },
    body: JSON.stringify({
      model: "gpt-5.4-nano",
      messages: [ { role: "system", content: "..." }, ... ],
      max_tokens: 100,
    }),
    signal: AbortSignal.timeout(5000),
  });
  ...
}
3
server.ts :242-294 · list_peers tool

When Claude asks "who else is here?" it invokes the list_peers tool with a scope of machine, directory, or repo. The MCP server forwards to POST /list-peers, sending its own cwd, git_root, and exclude_id. The broker filters the SQLite peers table by the scope, then liveness-checks each row with process.kill(pid, 0) — any PID that no longer exists is deleted on the spot, so the returned list is never stale. The MCP server formats peers into a text block for the model.

show source · broker.ts
// broker.ts — handleListPeers
switch (body.scope) {
  case "machine":    peers = selectAllPeers.all();                  break;
  case "directory":  peers = selectPeersByDirectory.all(body.cwd);  break;
  case "repo":       peers = body.git_root
                       ? selectPeersByGitRoot.all(body.git_root)
                       : selectPeersByDirectory.all(body.cwd);
}
if (body.exclude_id) peers = peers.filter(p => p.id !== body.exclude_id);

// liveness probe — drops dead PIDs eagerly
return peers.filter(p => {
  try { process.kill(p.pid, 0); return true; }
  catch { deletePeer.run(p.id); return false; }
});
4
server.ts :297-330 · send_message tool

Outbound is the easy half. Claude A calls send_message({ to_id, message }). The MCP server POSTs /send-message with { from_id: myId, to_id, text }. The broker verifies the target peer still exists in the peers table, then inserts a row into messages with delivered = 0. That row is now Peer B's outbox — the broker does not proactively notify B. Delivery happens on B's next poll tick.

show source · broker.ts
// broker.ts — handleSendMessage
const target = db.query("SELECT id FROM peers WHERE id = ?").get(body.to_id);
if (!target) return { ok: false, error: `Peer ${body.to_id} not found` };

insertMessage.run(body.from_id, body.to_id, body.text, new Date().toISOString());
// delivered defaults to 0 — Peer B will claim it on its next /poll-messages
return { ok: true };
5
server.ts :404-449 · pollAndPushMessages (the inbound loop)

This is the heart of the two-way story. Every Peer B's MCP server runs a setInterval(pollAndPushMessages, 1000) from main(). Each tick it POSTs /poll-messages with its own ID. The broker returns every row with to_id = myId AND delivered = 0, then marks them delivered = 1 in the same transaction so they're consumed exactly once. For each returned message, the MCP server enriches it by looking up the sender's summary and cwd, then emits a channel notification (next step).

show source · server.ts
// server.ts — pollAndPushMessages
const result = await brokerFetch<PollMessagesResponse>("/poll-messages", { id: myId });

for (const msg of result.messages) {
  // look up sender context to attach to the channel notification
  const peers  = await brokerFetch<Peer[]>("/list-peers", { scope: "machine", ... });
  const sender = peers.find(p => p.id === msg.from_id);

  await mcp.notification({
    method: "notifications/claude/channel",
    params: {
      content: msg.text,
      meta: {
        from_id:      msg.from_id,
        from_summary: sender?.summary ?? "",
        from_cwd:     sender?.cwd     ?? "",
        sent_at:      msg.sent_at,
      },
    },
  });
}
6
server.ts :144-165 + :430-441 · the channel-injection trick

This is what "messages appear instantly inside Claude" actually means. The MCP server's capability handshake declares experimental: { "claude/channel": {} } alongside the standard tools capability. When Claude Code is launched with --dangerously-load-development-channels server:claude-peers, it watches the MCP server's outbound JSON-RPC for notifications/claude/channel and, when one arrives, splices a <channel source="claude-peers" ...> block into the model's running context. The model sees a new message the same way it sees a user turn — no tool call required. The pre-loaded instructions string tells the model to respond immediately, treating peer messages like a coworker tap on the shoulder.

Without that --dangerously-load-development-channels flag, the tool surface still works — but channel notifications get ignored, so the model only learns about new messages when it (or the user) explicitly calls check_messages.

show source · server.ts
// server.ts — MCP capability handshake
const mcp = new Server(
  { name: "claude-peers", version: "0.1.0" },
  {
    capabilities: {
      experimental: { "claude/channel": {} },   // ← opts into the channel protocol
      tools: {},
    },
    instructions: `...When you receive a <channel source="claude-peers" ...>
      message, RESPOND IMMEDIATELY. Treat incoming peer messages
      like a coworker tapping you on the shoulder...`,
  }
);

// later, in the poll loop, every undelivered row becomes:
mcp.notification({
  method: "notifications/claude/channel",
  params: { content: msg.text, meta: { from_id, from_summary, from_cwd, sent_at } },
});
7
broker.ts :62-79 + server.ts :523-549 · liveness, heartbeats, cleanup

Three overlapping liveness mechanisms keep the address book honest. Each MCP server POSTs /heartbeat every 15 s, updating its last_seen timestamp. The broker runs cleanStalePeers() on startup and every 30 s, calling process.kill(pid, 0) on every registered peer and deleting any whose PID has disappeared (and dropping their undelivered messages). On SIGINT/SIGTERM, the MCP server politely POSTs /unregister. Re-registration with the same pid deletes the old row first, so a restarted Claude Code gets a fresh peer ID rather than two.

show source · broker.ts
// broker.ts — cleanStalePeers
function cleanStalePeers() {
  const peers = db.query("SELECT id, pid FROM peers").all();
  for (const p of peers) {
    try { process.kill(p.pid, 0); }            // signal 0 = "are you alive?"
    catch {
      db.run("DELETE FROM peers WHERE id = ?",                       [p.id]);
      db.run("DELETE FROM messages WHERE to_id = ? AND delivered = 0", [p.id]);
    }
  }
}
setInterval(cleanStalePeers, 30_000);

Two-way sequence at a glance

Claude A MCP A Broker (HTTP + SQLite) MCP B Claude B tools/call send_message(to_id, text) POST /send-message INSERT messages (delivered=0) { ok: true } tool result ≤ 1 s later — MCP B's poll tick fires POST /poll-messages SELECT undelivered → UPDATE delivered=1 { messages: [...] } notifications/claude/channel spliced <channel source="claude-peers"> injected mid-turn →