peer-relay
by jmgomezl
README.md
# 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](https://modelcontextprotocol.io) 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.ts`](src/storage/types.ts) names every field that can exist. There is no generic `metadata`, `payload`, `context`, or `blob` field anywhere.
- The SQLite schema in [`src/storage/sqlite.ts`](src/storage/sqlite.ts) has 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_hint` and `sources` are 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_question` **strongly** prompts the answering Claude to ground its answer in concrete `sources` — file paths, `file:line` refs, 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_answers` reminds 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
```bash
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`](examples/dev-a.mcp.json) and [`examples/dev-b.mcp.json`](examples/dev-b.mcp.json).
Dev A's `.mcp.json` (in their project root):
```json
{
"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
```bash
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`](.env.example) for all env vars.
---
## Pairing & auth model
- **One shared room token** authenticates the room. Present it as `x-room-token: <token>` or `Authorization: Bearer <token>`. A wrong/missing token gets `401`.
- **Peer id** (`x-peer-id`) says which side you are. In a room, a question asked by `alice` is visible to everyone who is *not* `alice` — for a strict 1:1 pair, that's exactly `bob`.
- 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_hint` by 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 `sources` is encouraged precisely because answers can be wrong.
---
## Storage layer
Storage sits behind the [`Storage`](src/storage/types.ts) interface. The default implementation is SQLite ([`src/storage/sqlite.ts`](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
```bash
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`](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](LICENSE).
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues