The broker daemon (agent-relay-broker) exposes an HTTP + WebSocket
listen API once it's running. This is the surface that the dashboard,
the agent-relay CLI, the SDKs, and any custom integration use to
spawn agents, inject input into PTYs, observe live events, and
shut the broker down.
Base URL and port
The listen API binds to the port you pass to agent-relay up (default
3888), on 127.0.0.1 by default. Bind to a non-loopback address
with --api-bind if you need remote access:
agent-relay up --port 3888 # local only
agent-relay up --port 3888 --api-bind 0.0.0.0 # accept remoteAll routes below live under http://<host>:<port>. The port is also
written to .agent-relay/connection.json so SDK clients and admin CLI
subcommands can discover it without flags.
Authentication
All /api/* routes require an API key on every request, supplied as
either header:
X-API-Key: <token>
Authorization: Bearer <token>
The expected token is read from RELAY_BROKER_API_KEY (when set at
broker startup) or the auto-generated value stored in
.agent-relay/connection.json. If neither is set, the broker runs
unauthenticated — protected routes accept any request. In
production, always set it.
The only route exempt from auth is GET /health.
Routes
Health and configuration
| Method | Path | Purpose |
|---|---|---|
GET | /health | Liveness probe + workspace/startup status. Unauthenticated. |
GET | /api/session | Broker version, protocol version, persist/ephemeral mode, uptime. |
POST | /api/session/renew | Renew the broker lease (persist mode only). |
GET | /api/config | Relaycast workspace key and workspace memberships. |
GET | /api/metrics | Broker metrics. Optional ?agent=<name> filter. |
GET | /api/status | Aggregate broker status. |
GET | /api/crash-insights | Recent crash diagnostics. |
GET | /api/history/stats | Stub message-history counters. |
POST | /api/preflight | Check that each { name, cli } agent can be spawned. |
POST | /api/shutdown | Graceful broker shutdown. |
Agent lifecycle
| Method | Path | Purpose |
|---|---|---|
POST | /api/spawn | Spawn an agent as a child of the broker. |
GET | /api/spawned | List running agents. |
DELETE | /api/spawned/{name} | Release / kill an agent. Optional body { "reason": "..." }. |
POST | /api/spawned/{name}/model | Change an agent's model. Body { "model": "...", "timeoutMs"?: number }. |
POST | /api/spawned/{name}/subscribe | Subscribe an agent to channels. Body { "channels": ["..."] }. |
POST | /api/spawned/{name}/unsubscribe | Unsubscribe an agent from channels. Body { "channels": ["..."] }. |
POST | /api/agents/by-name/{name}/interrupt | Interrupt an agent (not yet implemented — returns 501). |
POST /api/spawn
Body (fields accept both camelCase and snake_case):
{
"name": "Alice",
"cli": "claude",
"model": "sonnet",
"args": ["--no-color"],
"task": "Read the README and summarize it",
"channels": ["#general"],
"cwd": "/Users/me/project",
"team": "demo",
"shadowOf": null,
"shadowMode": null,
"continueFrom": null,
"idleThresholdSecs": 0,
"skipRelayPrompt": false,
"restartPolicy": null,
"agentToken": null
}name and cli are required. Returns { "success": true, ... } on
success or { "success": false, "error": "..." } with a non-2xx
status on failure.
PTY interaction
| Method | Path | Purpose |
|---|---|---|
POST | /api/input/{name} | Send raw bytes to an agent's PTY stdin. Body { "data": "..." }. |
GET | /api/input/{name}/stream | Open a websocket for low-latency PTY stdin streaming. |
POST | /api/resize/{name} | Resize an agent's PTY. Body { "rows": <u16>, "cols": <u16> }. |
POST | /api/send | Inject a relay message into an agent. |
TypeScript SDK equivalents: client.sendInput(name, data),
client.openInputStream(name), client.resizePty(name, rows, cols),
and client.sendMessage(input).
POST /api/input/{name}
This is the keystroke channel. The data string is written to the
target agent's stdin verbatim — escape characters are passed through.
curl -X POST localhost:3888/api/input/Alice \
-H "X-API-Key: $RELAY_BROKER_API_KEY" \
-d '{"data":"hello\n"}'Returns 404 if the agent isn't found.
GET /api/input/{name}/stream
This websocket is for interactive terminal input where one HTTP request per
keypress is too expensive. It uses the same broker API-key auth as REST
routes (X-API-Key or Authorization: Bearer ...). The broker validates
that the target exists and is PTY-backed before sending:
{ "type": "pty_input_ready", "name": "Alice" }Clients may send raw text websocket frames, binary frames containing UTF-8 input, or JSON frames when an explicit envelope is easier. Binary frames are decoded as UTF-8 and then processed the same way as raw text frames:
{ "type": "pty_input", "data": "hello\n" }The broker forwards one frame at a time and waits for the PTY write to be accepted before reading the next frame, preserving per-agent ordering and applying websocket/TCP backpressure. Each accepted frame receives:
{ "type": "pty_input_ack", "name": "Alice", "bytes_written": 6 }If the agent is missing, exits, or cannot accept PTY input, the stream emits
pty_input_error with code, message, and statusCode, then closes.
POST /api/send
{
"to": "Alice",
"message": "Please review PR #837",
"from": "Bob",
"thread": "thr_abc",
"workspaceId": "ws_demo",
"mode": "wait"
}The message text field accepts any of message, text, body, or
content. The mode field accepts wait (default — queue and
inject when the agent is idle) or steer (inject immediately,
even mid-response). Returns 504 on a 30s broker timeout; 404 if
the target agent isn't registered.
Snapshots
| Method | Path | Purpose |
|---|---|---|
GET | /api/spawned/{name}/snapshot | Capture the current visible PTY screen of a running worker. |
GET /api/spawned/{name}/snapshot
The broker proxies the request to the worker subprocess, which walks its
alacritty_terminal VT grid and returns a rendered payload.
Query parameters:
| Param | Default | Description |
|---|---|---|
format | plain | Either plain (UTF-8, one line per row, trailing blanks trimmed) or ansi (a base64-encoded byte stream that reproduces the grid on a fresh terminal when written raw). |
Response — format=plain:
{
"format": "plain",
"rows": 24,
"cols": 80,
"cursor": [3, 12],
"screen": "line one\nline two\n…\n"
}Response — format=ansi:
{
"format": "ansi",
"rows": 24,
"cols": 80,
"cursor": [3, 12],
"screen": "G1swbRtbSBtbMko…"
}cursor is a [row, col] pair, 1-indexed (matching how the
worker's internal grid talks about cells). The ansi screen field
is base64-encoded because the bytes contain control characters;
decode it and write the result straight to a terminal to redraw the
captured screen.
Status codes:
| Status | When |
|---|---|
200 | Snapshot captured successfully. |
400 | format is neither plain nor ansi. |
404 | No worker is registered under {name}. |
409 | Worker exists but its runtime cannot service snapshot_pty (e.g. it is a headless / non-PTY worker). |
500 | Internal channel closed before the snapshot could be returned (worker crashed, etc.). |
504 | Worker accepted the request but did not respond within the broker's snapshot timeout (5s). |
Example:
curl -s \
-H "X-API-Key: $RELAY_BROKER_API_KEY" \
"http://127.0.0.1:3888/api/spawned/reviewer/snapshot?format=plain"TypeScript SDK equivalent:
const snapshot = await client.snapshot('reviewer', 'plain');agent-relay-broker dump-pty <name>
Admin CLI that wraps the snapshot route — useful for "what does this worker's screen look like right now?" debugging without going through the dashboard.
# Plain text snapshot (default)
agent-relay-broker dump-pty reviewer
# ANSI reproduction bytes — pipe to a terminal to redraw
agent-relay-broker dump-pty reviewer --format ansi | catFlags:
| Flag | Description |
|---|---|
<name> | Worker to snapshot. Required positional. |
--format | plain (default) or ansi. plain writes the rendered screen as UTF-8; ansi writes the decoded reproduction bytes. |
--broker-url | Override the broker base URL. Falls back to RELAY_BROKER_URL, then to .agent-relay/connection.json in the current directory. |
--api-key | Override the API key. Falls back to RELAY_BROKER_API_KEY, then to the value in .agent-relay/connection.json. |
--state-dir | Directory containing connection.json when discovering the broker. Defaults to .agent-relay/ (or the current directory). |
The command prints the screen to stdout and exits 0 on success. On error (unknown worker, broker unreachable, invalid format) it prints a diagnostic to stderr and exits non-zero.
Inbound delivery mode
Per-agent inbound delivery mode controls how the broker dispatches inbound relay messages to a spawned worker. Two modes are supported:
auto_inject(default) — inbound messages auto-inject into the worker's PTY. Use this for headless agents that should react to incoming traffic on their own, or for anagent-relay passthroughclient session where the human types alongside the broker.manual_flush— inbound messages are queued in a per-worker pending buffer instead of being injected. A human operator, SDK client, oragent-relay driveclient decides when to drain the queue. Useful when you've taken over an agent's PTY interactively and don't want background traffic racing your keystrokes.
Mode is broker-side state only — the worker process never observes it.
Mode resets to auto_inject when the broker restarts and the pending queue
is dropped (no on-disk persistence).
| Method | Path | Purpose |
|---|---|---|
GET | /api/spawned/{name}/delivery-mode | Read the current inbound delivery mode. Returns { "mode": "auto_inject" | "manual_flush" }. |
PUT | /api/spawned/{name}/delivery-mode | Set the inbound delivery mode. Body { "mode": "auto_inject" | "manual_flush" }. |
GET | /api/spawned/{name}/pending | Snapshot the per-worker pending queue (FIFO, head first). |
POST | /api/spawned/{name}/flush | Drain the pending queue and inject every message into the worker. FIFO order. |
TypeScript SDK equivalents: client.getInboundDeliveryMode(name),
client.setInboundDeliveryMode(name, mode), client.getPending(name), and
client.flushPending(name).
PUT /api/spawned/{name}/delivery-mode
{ "mode": "manual_flush" }On a manual_flush → auto_inject transition the broker auto-drains the pending
queue into the worker (via the normal inject path) before returning,
so flipping back to auto_inject never strands queued messages. The response
reports how many messages were flushed:
{ "mode": "auto_inject", "flushed": 3 }An auto_inject → manual_flush flip or a same-mode noop returns "flushed": 0.
Status codes: 200 on success, 400 on a body that isn't
{ "mode": "auto_inject" } or { "mode": "manual_flush" }, 404 if the
agent is not registered.
GET /api/spawned/{name}/pending
Returns the pending queue without modifying it.
{
"pending": [
{
"from": "Bob",
"body": "please review #837",
"target": "#general",
"priority": 2,
"mode": "wait",
"thread_id": "thr_abc",
"workspace_id": "ws_demo",
"workspace_alias": "Demo",
"queued_at_ms": 1715812345678,
"event_id": "evt_abc"
},
{
"from": "Carol",
"body": "ping",
"target": "Alice",
"priority": 2,
"mode": "wait",
"queued_at_ms": 1715812360123
}
]
}Each entry preserves the full routing metadata captured at queue time
— target (#channel, DM recipient, or "thread"), priority,
mode (wait / steer), and optional thread_id / workspace_id
/ workspace_alias / event_id when the inbound carried them. A
later drain reproduces the original delivery byte-for-byte; a
channel-targeted message stays channel-targeted.
The queue is bounded at 256 entries per worker — when full, the oldest entry is evicted with a broker-side warning.
POST /api/spawned/{name}/flush
Drains the queue and injects each message into the worker in FIFO order.
The inbound delivery mode is not changed; a caller still in manual_flush
mode will continue queuing newly-arriving messages.
{ "flushed": 7 }Status: 200 on success, 404 if the agent is not registered.
Event stream
| Method | Path | Purpose |
|---|---|---|
GET | /ws | WebSocket: subscribe to broker events. |
GET | /api/events/replay | HTTP-based replay of events since a sequence number. |
GET /ws
Upgrade to WebSocket and you'll receive every broker event as a JSON text frame. The broker pings every 30s; respond with pong to stay connected.
To resume after a disconnect without missing durable events, include the last sequence number you saw:
ws://localhost:3888/ws?sinceSeq=12345
If the requested sequence is older than the replay buffer's window, the first frame you receive will be:
{ "kind": "replay_gap", "requestedSinceSeq": 12345, "oldestAvailable": 14000, "seq": 14999 }Note: Two event kinds are ephemeral and never stored in the replay buffer:
worker_stream(PTY output chunks — high frequency) anddelivery_active(in-flight delivery progress). If you disconnect, you cannot replay them.
Event kinds emitted on /ws
Durable (replayable via ?sinceSeq=...):
kind | When it fires |
|---|---|
agent_spawned | An agent was successfully spawned. |
agent_exit / agent_exited | Worker process exited. |
agent_idle | Worker has been quiet past idleThresholdSecs. |
agent_restarting / agent_restarted | Worker is being restarted under the restart policy. |
agent_released | Worker was released by /api/spawned/{name} DELETE. |
agent_permanently_dead | Worker exhausted restart attempts. |
worker_ready | Worker finished startup and is ready for input. |
worker_error | Worker emitted an error frame. |
relay_inbound | Inbound relay message routed to an agent. |
delivery_ack | Message delivery acknowledged. |
delivery_verified | Echo verification confirmed the message was delivered. |
delivery_failed | Message delivery failed. |
delivery_dropped | Delivery was dropped (e.g. agent gone). |
delivery_retry | Delivery is being retried. |
delivery_queued | Inbound delivery parked in the per-worker pending queue because the worker is in manual_flush inbound delivery mode. Payload carries event_id, from, target, and reason: "inbound_delivery_manual_flush". |
agent_inbound_delivery_mode_changed | A worker's inbound delivery mode flipped via PUT /api/spawned/{name}/delivery-mode. Payload carries previous_mode and mode. |
agent_pending_drained | The per-worker pending queue was drained. Payload carries count and reason (delivery_mode_transition for the auto-drain on manual_flush → auto_inject, explicit_flush for POST .../flush). |
Ephemeral (broadcast only, no replay):
kind | When it fires |
|---|---|
worker_stream | A chunk of stdout from a wrapped CLI. Payload contains stream ("stdout") and chunk (the raw bytes — typically still ANSI-escaped). |
delivery_active | High-frequency progress events for in-flight deliveries. |
TypeScript SDK equivalent for one worker's PTY output:
for await (const chunk of client.subscribeWorkerStream('Alice')) {
process.stdout.write(chunk);
}Worked example: control a spawned agent end-to-end
KEY="$RELAY_BROKER_API_KEY"
# 1. Start the broker
agent-relay up --port 3888 &
# 2. Spawn Alice running claude
curl -sX POST localhost:3888/api/spawn \
-H "X-API-Key: $KEY" \
-d '{"name":"Alice","cli":"claude"}'
# 3. Stream her PTY output (filter for her worker_stream frames)
websocat ws://localhost:3888/ws \
-H "X-API-Key: $KEY" \
| jq -r 'select(.kind=="worker_stream" and .name=="Alice") | .chunk'
# 4. Send a keystroke to Alice's CLI
curl -sX POST localhost:3888/api/input/Alice \
-H "X-API-Key: $KEY" \
-d '{"data":"hello\n"}'
# 5. Inject a relay message
curl -sX POST localhost:3888/api/send \
-H "X-API-Key: $KEY" \
-d '{"to":"Alice","from":"Bob","message":"please review #837"}'
# 6. Snapshot what's on her screen right now
curl -s "localhost:3888/api/spawned/Alice/snapshot?format=plain" \
-H "X-API-Key: $KEY"
# 7. Release her
curl -sX DELETE localhost:3888/api/spawned/Alice \
-H "X-API-Key: $KEY"Worked example: take over an agent with inbound delivery mode
The four inbound-delivery-mode routes back the agent-relay drive and
agent-relay passthrough clients. The typical drive-mode flow looks like this:
KEY="$RELAY_BROKER_API_KEY"
# 1. Flip Alice into manual_flush mode. Now inbound relay traffic
# will queue instead of injecting.
curl -sX PUT localhost:3888/api/spawned/Alice/delivery-mode \
-H "X-API-Key: $KEY" \
-d '{"mode":"manual_flush"}'
# 2. Send some messages while Alice is in manual_flush mode —
# these land in her pending queue, not her PTY.
curl -sX POST localhost:3888/api/send \
-H "X-API-Key: $KEY" \
-d '{"to":"Alice","from":"Bob","message":"please review #837"}'
curl -sX POST localhost:3888/api/send \
-H "X-API-Key: $KEY" \
-d '{"to":"Alice","from":"Carol","message":"ping"}'
# 3. Inspect what's queued.
curl -s localhost:3888/api/spawned/Alice/pending \
-H "X-API-Key: $KEY"
# 4. Drain the queue into Alice's PTY when ready.
curl -sX POST localhost:3888/api/spawned/Alice/flush \
-H "X-API-Key: $KEY"
# 5. Flip Alice back to auto_inject mode. Any messages still in the
# queue are drained automatically before this call returns.
curl -sX PUT localhost:3888/api/spawned/Alice/delivery-mode \
-H "X-API-Key: $KEY" \
-d '{"mode":"auto_inject"}'Error envelope
Failed responses return a consistent envelope:
{
"error": {
"code": "agent_not_found",
"message": "no agent named Alice",
"statusCode": 404
}
}Common codes: agent_not_found (404), invalid_request (400),
unsupported_operation (400), unauthorized (401), request_failed
(400), internal_error (500).