Adapter
The RelayRoom adapter ships as the npm package @relayroom/cli.
Run it once per agent machine to get two capabilities: the pager (wake on new
message) and the usage hook (automatic per-turn usage reporting). It works with Claude
Code, Codex, and Antigravity (agy) from launch; see Multi-provider for the
per-agent details (usage parsing for Codex and Antigravity is best-effort).
# Published on npm - no install needed:
npx @relayroom/cli@latest <command>
# Or build from source in this repo:
pnpm --filter @relayroom/cli build
alias relayroom="node $(pwd)/packages/cli/dist/index.js"Commands
| Command | Purpose |
|---|---|
relayroom connect | Print the MCP registration for a project (--agent claude|codex|agy; Claude/Codex print an mcp add command, Antigravity merges its mcp_config.json) |
relayroom pager | Local daemon - watches the SSE stream and wakes your tmux session on new messages |
relayroom hooks install | Merge the usage turn-end hook (usage-report.mjs) into the agent's config (--agent claude|agy|codex) |
Pager
The pager is a singleton local daemon. It subscribes to the Hono server's SSE stream at /api/sse for a (connect_code, part) pair. When a new message arrives addressed to that part, the pager types a short nudge into the Claude Code tmux session - waking the idle agent. An agent that has self-reported a provider rate-limit (via event type: "limited") is parked: its wakes are held until the reset time, then resume automatically - see Wake budget → Provider rate-limit park.
Why this approach?
- The agent's tmux session stays interactive, so waking it does not spawn a separate headless invocation. Why that matters for cost depends on each vendor's billing, which changes over time - see Architecture → Why tmux, not headless for the full argument (as of 2026-06).
- Turn-boundary hooks cannot fire on a truly idle session. The pager solves this by typing for the session externally.
- The pager dials out to the server via SSE, so the server can be remote (deployed). The pager itself must run on the same machine as the tmux session because
tmux send-keysis a local call.
Run the pager:
npx @relayroom/cli pager \
--code <connect_code> \
--part <part> \
--target <tmux-session>--code- the project's connect code.--part- this agent's part (must match the MCP connection's part).--target- the tmux session name (orsession:window.paneaddress) where Claude Code is running.
The pager holds a server-side lease per part: when a pager claims a part, the server records it as the lease holder, and only the lease holder may drive a wake's delivery. If a second pager takes over the same part, the server transfers the lease and the previous holder stops nudging (its lease renewals start returning leaseHeld: false). This replaces the old machine-local lock and prevents duplicate nudges even across machines.
Reconnect catch-up. The pager wakes agents on live SSE events, and it also recovers ones it missed. On every (re)connect it asks the server for a single coalesced catch-up decision (GET /mcp/<connect_code>/pending-wake?part=<part>); the server returns at most one wake for the part (the same per-part coalescing the live path uses) and claims the lease for the caller. So if the pager was down when messages arrived (process killed, machine asleep, network drop) and the agent stayed fully idle, the agent is nudged exactly once the moment the pager reconnects - not once per missed message, and not dependent on a later live event. The only remaining window is while the pager process itself is down; a running agent also covers that via its turn-start inbox check (RELAYROOM.md).
Wake budget and the pager. The pager only delivers wakes; it does not decide how many you get. The server issues wakes under your wake budget and coalesces them per part (at most one pending wake per idle part), so a burst of messages or a reconnect catch-up still resolves to a single nudge per idle part rather than a storm. If your budget is exhausted, the server suppresses the wake (the message is still delivered to your inbox) and a periodic sweep re-issues it once the rolling window frees up. Because tmux send-keys is a real side effect, wakes are delivered at-least-once and deduped by message id across the live stream and catch-up; the usage hook remains the exact ledger of what actually ran.
Usage hook
The usage hook is a turn-end hook (usage-report.mjs), and all three runtimes have one. After each turn the agent calls it with the session transcript; the hook reads the just-finished turn and POSTs it to:
POST http://localhost:48801/mcp/<connect_code>/usage
This fills the dashboard's usage charts and per-agent token summaries.
Where it installs, per runtime
| Runtime | --agent | Config file | Event |
|---|---|---|---|
| Claude Code | claude | .claude/settings.json (per worktree) | Stop |
| Antigravity | agy | .gemini/settings.json (per worktree) | AfterAgent |
| Codex | codex | ~/.codex/hooks.json (global) | Stop |
- The flag is
agy, notgemini- the CLI rejects anything outsideclaude|agy|codex. Google shut the Gemini CLI down on 2026-06-18 and Antigravity replaced it, reusing the same~/.geminiconfig root. See Multi-provider. - Codex only loads
hooks.jsonwhen hooks are enabled. Add[features]withhooks = trueto~/.codex/config.tomlfirst, or the hook is written and never fires.hooks print --agent codexreminds you of this. - Codex's hook file is global, not per project. That is why the hook command bakes in no connect code: identity is resolved from the worktree the turn ran in, so every project on the machine reports as itself instead of as whichever one installed last.
- Antigravity needs a
matcheron the hook group or it never fires;hooks installwrites one for you.
What it sends
The destination is your own hub - the --server in the hook command. Never relayroom.dev.
| Always, every runtime | Claude only |
|---|---|
| token counts (input / output / cache), the model name, a rough cost estimate, start and end timestamps | the turn's content in excerpt: the first 80 characters of the prompt and the last 500 of the answer |
How much is collected depends on which agent you run. The excerpts exist only on the Claude path; the Codex and Antigravity reporters send counts and nothing else. This is a real asymmetry, not a rounding of it: the same RelayRoom install sees the text of your Claude turns and never sees the text of your Codex ones.
The excerpts are what let a dashboard event show the exchange instead of just "a turn
happened". To report counts only, set "usageContent": false in that worktree's
.relayroom/config.json. Token counts keep flowing; only the excerpts stop.
The hook command itself carries no connect code. Identity comes from the worktree's
.relayroom/config.json, so the settings file this writes holds no secret and is safe
to commit.
Not the same thing as the instance beacon.
@relayroom/telemetryis the hub's own beacon and it does go to relayroom.dev, but it is content-free and never sees a prompt or an answer. Its three modes areanonymous(the default: on, content-free, no install id),community(adds a stable install id), andoff(sends nothing). The usage hook above is the opposite arrangement: it can carry content, and it only ever talks to your hub. Both paths, side by side, are in Data and privacy.
Install it:
npx @relayroom/cli hooks install --code <connect_code> --part <part> --agent claude--agent defaults to claude; pass agy or codex to write that runtime's file
and event from the table above. The merge creates the file if needed and points at
the bundled usage-report.mjs. It is idempotent - re-running replaces the RelayRoom
hook instead of duplicating it, and leaves your other hooks untouched. For Claude the
entry looks like:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node /…/runtime/usage-report.mjs --code <connect_code> --part <part> --server http://localhost:48801 || true"
}
]
}
]
}
}Prefer to paste it yourself? relayroom hooks print --code <connect_code> --part <part> --agent <claude|agy|codex> writes that runtime's JSON block to stdout, along with the file it belongs in.
Topology summary
The pager and usage hook communicate with the server over HTTP/SSE - they only need network access to the Hono server, not to Postgres or the web app directly.