Broker HTTP / WS API

Reference for the listen API that the broker exposes for dashboards, the CLI, and custom integrations.

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 remote

All 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

MethodPathPurpose
GET/healthLiveness probe + workspace/startup status. Unauthenticated.
GET/api/sessionBroker version, protocol version, persist/ephemeral mode, uptime.
POST/api/session/renewRenew the broker lease (persist mode only).
GET/api/configRelaycast workspace key and workspace memberships.
GET/api/metricsBroker metrics. Optional ?agent=<name> filter.
GET/api/statusAggregate broker status.
GET/api/crash-insightsRecent crash diagnostics.
GET/api/history/statsStub message-history counters.
POST/api/preflightCheck that each { name, cli } agent can be spawned.
POST/api/shutdownGraceful broker shutdown.

Agent lifecycle

MethodPathPurpose
POST/api/spawnSpawn an agent as a child of the broker.
GET/api/spawnedList running agents.
DELETE/api/spawned/{name}Release / kill an agent. Optional body { "reason": "..." }.
POST/api/spawned/{name}/modelChange an agent's model. Body { "model": "...", "timeoutMs"?: number }.
POST/api/spawned/{name}/subscribeSubscribe an agent to channels. Body { "channels": ["..."] }.
POST/api/spawned/{name}/unsubscribeUnsubscribe an agent from channels. Body { "channels": ["..."] }.
POST/api/agents/by-name/{name}/interruptInterrupt 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

MethodPathPurpose
POST/api/input/{name}Send raw bytes to an agent's PTY stdin. Body { "data": "..." }.
GET/api/input/{name}/streamOpen a websocket for low-latency PTY stdin streaming.
POST/api/resize/{name}Resize an agent's PTY. Body { "rows": <u16>, "cols": <u16> }.
POST/api/sendInject 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

MethodPathPurpose
GET/api/spawned/{name}/snapshotCapture 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:

ParamDefaultDescription
formatplainEither 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:

StatusWhen
200Snapshot captured successfully.
400format is neither plain nor ansi.
404No worker is registered under {name}.
409Worker exists but its runtime cannot service snapshot_pty (e.g. it is a headless / non-PTY worker).
500Internal channel closed before the snapshot could be returned (worker crashed, etc.).
504Worker 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 | cat

Flags:

FlagDescription
<name>Worker to snapshot. Required positional.
--formatplain (default) or ansi. plain writes the rendered screen as UTF-8; ansi writes the decoded reproduction bytes.
--broker-urlOverride the broker base URL. Falls back to RELAY_BROKER_URL, then to .agent-relay/connection.json in the current directory.
--api-keyOverride the API key. Falls back to RELAY_BROKER_API_KEY, then to the value in .agent-relay/connection.json.
--state-dirDirectory 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 an agent-relay passthrough client 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, or agent-relay drive client 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).

MethodPathPurpose
GET/api/spawned/{name}/delivery-modeRead the current inbound delivery mode. Returns { "mode": "auto_inject" | "manual_flush" }.
PUT/api/spawned/{name}/delivery-modeSet the inbound delivery mode. Body { "mode": "auto_inject" | "manual_flush" }.
GET/api/spawned/{name}/pendingSnapshot the per-worker pending queue (FIFO, head first).
POST/api/spawned/{name}/flushDrain 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

MethodPathPurpose
GET/wsWebSocket: subscribe to broker events.
GET/api/events/replayHTTP-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) and delivery_active (in-flight delivery progress). If you disconnect, you cannot replay them.

Event kinds emitted on /ws

Durable (replayable via ?sinceSeq=...):

kindWhen it fires
agent_spawnedAn agent was successfully spawned.
agent_exit / agent_exitedWorker process exited.
agent_idleWorker has been quiet past idleThresholdSecs.
agent_restarting / agent_restartedWorker is being restarted under the restart policy.
agent_releasedWorker was released by /api/spawned/{name} DELETE.
agent_permanently_deadWorker exhausted restart attempts.
worker_readyWorker finished startup and is ready for input.
worker_errorWorker emitted an error frame.
relay_inboundInbound relay message routed to an agent.
delivery_ackMessage delivery acknowledged.
delivery_verifiedEcho verification confirmed the message was delivered.
delivery_failedMessage delivery failed.
delivery_droppedDelivery was dropped (e.g. agent gone).
delivery_retryDelivery is being retried.
delivery_queuedInbound 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_changedA worker's inbound delivery mode flipped via PUT /api/spawned/{name}/delivery-mode. Payload carries previous_mode and mode.
agent_pending_drainedThe 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):

kindWhen it fires
worker_streamA chunk of stdout from a wrapped CLI. Payload contains stream ("stdout") and chunk (the raw bytes — typically still ANSI-escaped).
delivery_activeHigh-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).