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 and Codex, and still with Antigravity (agy) for worktrees already on it, though
the connect guide no longer offers that one; 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) |
Wake delivery: pager or channel
There are two ways a wake reaches an idle agent, and the pager is the default.
| How a wake arrives | Availability | |
|---|---|---|
| Pager (default) | tmux send-keys types a short nudge into the session | Always. Depends on no preview feature, no flag, no allowlist |
| Channel (opt-in) | Claude Code queues an event and processes it at a turn boundary | Claude Code only, interactive sessions only |
Turn channels on with ./rr.sh up --channel, or relayroom channel on followed by
a relaunch. The flag is sugar over the same persisted setting, so a self-update or
a respawn does not silently switch it back off.
The console looks different, and that is expected. Under the pager a wake arrives as text typed into your session; under channels it is injected as an event and never interleaves with what you are typing. The agent behaves the same either way. If you have just upgraded from 0.6.0, this is the change you are seeing - not a fault.
Two limits worth knowing before you opt in:
- Headless (
claude -p) has no channel path at all. Claude ignores the development-channels flag non-interactively, so a headless deployment always uses the pager. - Channels are a research preview, and
--channelsaccepts only allowlisted plugins. RelayRoom registers its channel as a plain MCP server, which that allowlist cannot express, so it is loaded through a development flag instead.
If channels are on but cannot start, delivery falls back to the pager and says
so. It does not fail silently and it does not turn your setting off - nobody
turns off what a person chose. ./rr.sh status reports the reason:
channel: ON for this worktree, but NOT delivering - fell back to pager at 04:12 (...).
claude accepted the launch and dropped notifications; wakes arrive by send-keys instead.
The setting is left ON: nobody turns off what a person chose.
The tmux status bar shows a red ○ !Channel while that is true. Repeated
fallbacks are counted, and ./rr.sh up --no-channel stops the retry.
Wake delivery over herdr
This is separate from Claude Channels above - a different question. Channels
are about what kind of nudge Claude receives; this is about which multiplexer
the pager types into. A herdr worktree uses the pager, over herdr's socket instead
of tmux send-keys.
A wake is staged, verified, and only then submitted. The pager sends the text, checks that it actually reached the pane's input box, and sends Enter only after that check passes.
That order exists because of a measurement: herdr's own agent.prompt was seen
answering a permission dialog on the user's behalf - its trailing Enter went to the
dialog rather than to the prompt.
So when a permission dialog is holding the pane, nothing is submitted and nothing is answered. The wake stays queued, you get one notification per blocked stretch rather than one per attempt, and the workspace is marked for as long as it lasts. The agent gets the message once the pane is free.
If the herdr socket is unreachable, the pager falls back to tmux delivery and
logs it. Wakes keep arriving, so nothing looks broken - but the part is not running
where it was asked to run. Note the asymmetry: ./rr.sh up --use-herdr with an
unreachable socket is a hard error and refuses to start, while the pager falls
back rather than going silent. Delivery continuing is worth more than delivery
being tidy.
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.