7.3 KiB
Relay Server Design
Date: 2026-05-02
Status: Approved
Scope: Dev tooling — not shipped in any product artifact
Problem
Multi-agent development lifts (PM + Dev-A + Dev-B in parallel Claude Code sessions) require passing status updates, questions, and directives between terminals. Today the user manually copies and pastes every message block. This is error-prone, breaks flow, and scales poorly as lift complexity grows.
Goal
A lightweight MCP server running on localhost that gives all three Claude Code sessions native tools to post and read messages. The user stops being the message bus.
Repository layout
tools/relay/
├── package.json # private, not published; single dep: @modelcontextprotocol/sdk
├── tsconfig.json
├── server.ts # MCP SSE server entry point (~150 lines)
├── queue.ts # in-memory queue logic (~50 lines)
├── queue.test.ts # Node built-in test runner
└── start.sh # launcher script
Added to root .gitignore: tools/relay/node_modules/, tools/relay/dist/.
Tech stack
- Runtime: Node.js (v25, already installed at
/usr/bin/node) - Package manager: npm (bun has known compat gaps with the MCP SDK's SSE transport)
- Dependencies:
@modelcontextprotocol/sdk,tsx(devDependency, runs TypeScript directly — no compile step) - Transport: SSE (
SSEServerTransportfrom the SDK handles the HTTP layer — no Express or Hono needed) - Port:
7331(hardcoded; easy to remember, unlikely to collide)
Queue model
Three named inboxes: pm, dev-a, dev-b. Each is a FIFO array in memory.
Message shape:
interface RelayMessage {
id: string; // uuid v4
from: string; // sender role name
to: string; // recipient role name
kind: "status" | "question" | "directive" | "free";
body: string; // freeform, typically the existing markdown block format
ts: string; // ISO 8601
}
kind maps to the existing coordination protocol:
| kind | existing block |
|---|---|
status |
## STATUS UPDATE — DEV-* |
question |
## QUESTION TO PM — DEV-* |
directive |
## DIRECTIVE TO DEV-* |
free |
ad-hoc / unstructured |
Messages are consume-once: read_messages drains the inbox. There is no persistence — if the server restarts mid-lift, in-flight messages are lost. Acceptable for a dev tool; agents re-send on reconnect.
MCP tool surface
All three tools are exposed to every connected session.
post_message
post_message(from: "pm"|"dev-a"|"dev-b", to: "pm"|"dev-a"|"dev-b", kind: "status"|"question"|"directive"|"free", body: string) → { id: string }
Pushes one message onto the target's inbox. Returns the assigned message id. Errors if to or from is not a known role. Agents declare their own identity via from — the kickoff prompt tells each agent its role name.
read_messages
read_messages(for: "pm"|"dev-a"|"dev-b") → RelayMessage[]
Pops and returns all pending messages for that recipient, in FIFO order. After this call the inbox is empty.
list_pending
list_pending(for: "pm"|"dev-a"|"dev-b") → { count: number, kinds: string[] }
Returns count and kind breakdown of pending messages without consuming them. Lets an agent cheaply check "do I have anything to act on?" before committing to a read_messages call.
Server terminal output
Every post_message call prints a one-liner to stdout in the dedicated relay terminal:
[14:32:01] dev-b → pm [status] "Task P4 DONE, last commit abc1234..."
[14:33:15] pm → dev-b [directive] "PROCEED to task B1"
This log is the operational value of keeping the server in a dedicated terminal rather than backgrounding it.
Launcher script (start.sh)
start.sh accepts one optional flag:
| Flag | Behavior |
|---|---|
| (default) | --manual mode: prints three labeled prompt blocks (one per role) for copy-paste into fresh Claude Code sessions, then starts the server in the foreground |
--tmux |
Creates a new tmux window with four panes: relay server + PM + Dev-A + Dev-B, each pre-loaded with its kickoff command |
--kitty |
Same layout using kitty's launch --new-tab / --new-window |
Execution order (all modes):
cd tools/relay && npm install --silent(no-op ifnode_modulesis current)- Print the session snippet (copy-paste blocks or multiplexer launch)
- Foreground
npx tsx server.ts— the terminal that ranstart.shbecomes the relay terminal; no compile step needed
Port-already-in-use check before step 3: if :7331 is bound, print relay already running? kill it with: kill $(lsof -ti:7331) and exit 1.
Claude Code configuration
Add to project .claude/settings.json:
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
}
This is project-scoped — the relay tools only appear in Relicario Claude Code sessions. When the server is not running, Claude Code shows a yellow MCP connection warning but does not break. Agents gracefully fall back to asking the user to relay manually (existing behavior).
Kickoff prompt changes
One paragraph added near the top of each coordination prompt (v0.5.0-pm-prompt.md, v0.5.0-dev-a-prompt.md, v0.5.0-dev-b-prompt.md as template):
Relay server: A message-bus MCP server is running. You have three tools:
post_message(to, kind, body),read_messages(for),list_pending(for). Recipients:pm,dev-a,dev-b. Use these instead of asking the user to copy-paste. Before starting each task callread_messages(for="<your-role>"). After emitting any status, question, or directive block, callpost_messagewithkindset to the block type andbodyset to the formatted block.
The multi-agent-kickoff skill is updated to:
- Remind the user to run
tools/relay/start.shbefore opening the three sessions - Inject the relay paragraph automatically into every generated kickoff prompt
Error handling
| Scenario | Behavior |
|---|---|
Unknown to in post_message |
MCP error returned; message not queued |
| Server crash / restart | In-flight messages lost; agent re-sends |
| Port 7331 in use at startup | Startup exits 1 with a kill hint |
| Session connects before server starts | Claude Code shows MCP warning; agent falls back to manual relay |
No authentication. This is localhost-only, single-machine, dev-tool use.
Testing
queue.test.ts using Node's built-in node:test runner. No extra test dep.
Coverage:
post_message+read_messagesroundtrip (single and multiple messages)- Consume-once: second
read_messageson same inbox returns empty list_pendingdoes not drain inbox- FIFO ordering across multiple senders to the same inbox
- Unknown recipient returns an error
No integration test against the MCP SSE transport — that is the SDK's responsibility.
What this is not
- Not a product feature — never bundled with the extension or CLI
- Not persistent — no SQLite, no file queue, in-memory only
- Not authenticated — localhost dev tool, no threat model
- Not a general-purpose message bus — three hardcoded roles, no dynamic registration