peer-relay
A deliberately dumb message relay that lets two separate Claude Code instances — run by two different developers, on two different machines — exchange direct questions and answers without sharing or merging their context.
It is an MCP server that behaves like a tiny mailbox. The only thing that ever crosses between the two instances is the literal text of a question and the literal text of an answer (plus a couple of small, self-written fields). Nothing else.
Relay, not knowledge base. peer-relay is intentionally dumb. No context syncing. No state mirroring. No background memory. No "let me read your repo for you."
---
Why
Two developers are working on related things. Dev A's Claude Code needs something only Dev B (or Dev B's codebase) knows — "why does the session token expire after 15 minutes?". Today you'd Slack the human, who asks their Claude, who answers, who pastes it back.
peer-relay lets Dev A's Claude ask Dev B's Claude directly and asynchronously, while keeping the two working contexts completely separate. A's repo, files, and conversation never touch B's, and vice-versa. Only the question and the answer move.
---
The trust model (what is and isn't stored)
This is the whole point, so it's worth being precise.
The relay stores ONLY these fields:
| Field | What it is | | -------------- | -------------------------------------------------------------------- | | id | An opaque message id. | | room_token | The shared pairing secret (so one store can host isolated pairs). | | asker_id | A short opaque label of who asked (e.g. alice) — for 1:1 routing. | | question | The literal question text. | | context_hint | Optional short free-text the asker writes themselves. | | answer | The literal answer text (once answered). | | sources | Optional array of short citation strings the answerer writes. | | asked_at / answered_at | ISO-8601 timestamps. |
It never stores, inspects, or fuses:
- file contents or file trees
- repository state, diffs, or branches
- conversation history or working memory
- anything auto-collected from either developer's environment
Why you can believe that
The guarantee is structural, not a pinky-promise in prose:
- The storage contract in
src/storage/types.tsnames every field that can exist. There is no genericmetadata,payload,context, orblobfield anywhere. - The SQLite schema in
src/storage/sqlite.tshas exactly those columns and nothing else — no JSON catch-all column, no key/value side table. The only multi-value field,sources, is constrained to a flat array of strings. context_hintandsourcesare written by the humans-in-the-loop Claudes themselves, in their own words. The server does not and cannot auto-collect them.
To make the relay store anything richer, you would have to change that interface and that schema — a visible, reviewable diff. That's the design.
---
Grounding answers (and not propagating nonsense)
The biggest risk in letting one Claude answer another is confident-but-unfounded claims crossing the wire and being treated as fact. peer-relay mitigates this at the prompt layer:
answer_questionstrongly prompts the answering Claude to ground its answer in concretesources— file paths,file:linerefs, commit hashes, PR numbers, decision docs.- It instructs the answerer to say so explicitly when it is unsure or answering from memory rather than a verifiable source.
check_answersreminds the asker to treat answers as claims from another developer, to prefer verifying cited sources, and to weight "from memory" answers accordingly.
Sources are encouraged but not required — sometimes the honest answer is "from memory, not verified."
---
MCP tools
| Tool | Signature | Purpose | | ---- | --------- | ------- | | ask_peer | (question: string, context_hint?: string) → { question_id } | Enqueue a question for the peer. context_hint is short orienting text you write. | | check_questions | () → [{ question_id, question, context_hint, asked_at }] | Peer polls for questions addressed to them. | | answer_question | (question_id: string, answer: string, sources?: string[]) → { ok } | Post an answer back, ideally with grounding sources. | | check_answers | () → [{ question_id, question, answer, sources, answered_at }] | Asker polls for answers to their questions. |
The flow is poll-based and asynchronous: A ask_peer → B check_questions → B answer_question → A check_answers.
---
Quick start (Docker)
The whole thing is one container plus a shared secret. One of you hosts it; both of you connect.
1. Host the relay
git clone https://github.com/jmgomezl/peer-relay.git
cd peer-relay
# pick a shared secret — BOTH devs will use this exact value
echo "PEER_RELAY_ROOM_TOKEN=$(openssl rand -hex 32)" > .env
docker compose up -d
That's it. The relay is now on http://localhost:8787/mcp, with a persistent volume for the mailbox and a built-in health check. cat .env to get the token to share with the other dev.
No Docker? Use Node ≥ 20 instead:
npm install && npm run build && PEER_RELAY_ROOM_TOKEN=... PEER_RELAY_HOST=0.0.0.0 npm start.
2. Make it reachable by the other developer
The other dev's Claude Code has to be able to hit that URL. Pick whichever matches your situation:
| Situation | What to use | | --------- | ----------- | | Two laptops, different networks (most common) | Expose it with a tunnel — zero server needed:<br>cloudflared tunnel --url http://localhost:8787 or ngrok http 8787<br>Share the https://… URL it prints. This also gives you HTTPS (see the security note below). | | Same office LAN / Tailscale / VPN | Use the host's LAN/Tailscale IP: http://<host-ip>:8787/mcp. | | A shared box / VPS / cloud | Run the same docker compose up -d there, open port 8787 (ideally behind an HTTPS reverse proxy), use its address. |
⚠️ Security: the server speaks plain HTTP and the room token is a bearer secret. Do not send it over the open internet unencrypted — put HTTPS in front (a tunnel does this for you, or use a reverse proxy like Caddy/Traefik). On a trusted LAN/VPN, plain HTTP is fine.
3. Each dev registers it in Claude Code
Both devs point at the same URL and same room token, but send a different x-peer-id. See examples/dev-a.mcp.json and examples/dev-b.mcp.json.
Dev A's .mcp.json (in their project root):
{
"mcpServers": {
"peer-relay": {
"type": "http",
"url": "https://your-relay-url/mcp",
"headers": {
"x-room-token": "the-shared-secret-from-step-1",
"x-peer-id": "alice"
}
}
}
}
Dev B's is identical except "x-peer-id": "bob". Restart Claude Code (or /mcp to reconnect) and the four peer-relay tools appear.
That's the entire pairing: same room token = same mailbox; distinct peer ids = the two sides.
4. Use it
In Dev A's Claude Code:
Ask my peer why the session token expires after 15 minutes — mention it's about the auth refactor in
src/auth/session.ts.
Claude calls ask_peer. Later, in Dev B's Claude Code, "check questions from my peer" surfaces it; B answers with sources; A's "check for answers" retrieves it.
Heads-up — polling, no push. The relay does not notify either side. Each dev has to ask their Claude to "check for questions/answers." That's intentional for v1; a question waits in the mailbox until the peer polls.
Managing the container
docker compose logs -f # watch it
docker compose down # stop (mailbox volume is kept)
docker compose down -v # stop and wipe the mailbox
The mailbox persists in the peer-relay-data volume across restarts. See .env.example for all env vars.
---
Pairing & auth model
- One shared room token authenticates the room. Present it as
x-room-token: <token>orAuthorization: Bearer <token>. A wrong/missing token gets401. - Peer id (
x-peer-id) says which side you are. In a room, a question asked byaliceis visible to everyone who is notalice— for a strict 1:1 pair, that's exactlybob. - That's all the auth there is in v1. No accounts, no OAuth. Keep your room token secret; rotate it by changing it on both sides.
---
What this is NOT
- ❌ Not a context-sync tool. It does not mirror, merge, or replicate either developer's working context, files, or conversation. The two contexts stay fully separate.
- ❌ Not a knowledge base. It doesn't index, embed, summarize, or "remember" anything for later retrieval. It's a queue of literal Q&A text, nothing more.
- ❌ Not an auto-context collector. It never reads your repo or environment to enrich a question. If the peer needs orientation, the asker writes a
context_hintby hand. - ❌ Not a team router. v1 is strictly one-to-one between two known devs. No fan-out, no channels-of-many.
- ❌ Not an oracle. Answers are claims from another Claude. Grounding via
sourcesis encouraged precisely because answers can be wrong.
---
Storage layer
Storage sits behind the Storage interface. The default implementation is SQLite (src/storage/sqlite.ts) for zero-setup local dev. To move to Postgres later, implement the same interface — no changes to the tools or transport. The narrow interface is also what keeps the trust model enforceable: there's simply no method to store anything beyond the documented fields.
---
Development
npm run dev # watch-mode server (tsx)
npm test # end-to-end test: A asks, B answers, A reads it back
npm run typecheck # tsc --noEmit
The e2e test (test/e2e.test.ts) drives a full ask→answer→read cycle through the queue against an in-memory SQLite store, plus room isolation and double-answer rejection.
---
Roadmap (explicitly out of scope for v1)
- On-chain / HCS audit trail as a separate signing layer (not in v1).
- Team-wide routing beyond 1:1.
- A web UI.
- Auth beyond the shared room token.
---
License
MIT — see LICENSE.











