# Agent: Session streaming **Page:** api/agent/sessions/streaming [Download Raw Markdown](./api/agent/sessions/streaming.md) --- # Agent: Session streaming Four endpoints cover a session's event surface. They differ on whether they attach (consume the exclusive attach slot), what they return, and which transport they use. Choose based on what your client needs: - `stream` attaches to a live session over WebSocket or SSE and receives events as they happen. The connection consumes the exclusive attach slot. - `replay` returns the gateway's buffered event tail without attaching. Use it to catch up after a reload, then resume `stream` with `?since=...&incarnation=...`. - `state` returns a consistent snapshot plus the stream watermark, without attaching. Use it after a reload that kept no cursor, or to recover a parked gate's identity. - `transcript` reads the persisted conversation without attaching. Use it for a non-live session, or for history older than the replay ring. A typical reconnect: with a cursor, open `stream?since=…&incarnation=…`. Without one, read `replay` (or `state` for the watermark), apply the returned events, then open `stream?since=…&incarnation=…` and drop any frame whose `seq` you already applied. The cursor is never past the last frame the client holds, so a turn that ends between two reads is still delivered once. All endpoints are container-scoped. The base URL for each is `https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com`. ## Live event stream ### `GET /api/v1/agent/sessions/{id}/stream` Attach to a session's event stream over WebSocket (primary) or SSE. Each frame carries a gateway-stamped `seq` and the `incarnation` that stamped it. Resume from the replay ring with `?since=&incarnation=`. A gap past eviction emits an SSE `event: lagged` frame (code `replay_gap`) then streams from `min_seq`. WS clients can post inline `confirm`, `answer`, `cancel`, and `workflow_message` frames to drive a parked gate. An incarnation mismatch on a resume cursor is treated as invalid: the gateway emits `replay_gap` and streams the full retained ring instead of resuming into a different history. Always pass the `incarnation` you last saw. ### Parameters | Name | In | Type | Required | Description | |------|----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `since` | query | integer | No | Resume from this gateway int64 seq (also accepted as the `Last-Event-ID` header). | | `incarnation` | query | string | No | The incarnation the `since` cursor belongs to. When it differs from the live session's, the cursor is treated as invalid and the full retained ring is sent. | | `Last-Event-ID` | header | string | No | SSE resume cursor, the gateway int64 seq to resume from. Sent automatically by an SSE client on reconnect. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope. | | `X-Hoody-Config-Dir` | header | string | No | Per-request `--config-dir` override selecting which on-disk `.hoody` install a stateless read/write resolves against. | | `X-Hoody-Container` | header | string | No | Per-request bound remote container (omitted = local). Rejected on routes with no container dimension. | | `X-Hoody-Realm` | header | string | No | Per-request realm selector: `global` or a 24-hex id. Rejected on active-only / no-realm routes. | | `realm` | query | string | No | In-query alias of `X-Hoody-Realm`, read only when the header is absent. | ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/67e89abc123def456789abc1/stream?since=42&incarnation=inc-20240115103000123456" \ -H "Authorization: Bearer " \ -N ``` ```typescript import { HoodyClient } from 'hoody-sdk'; const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN }); await client.agent.sessions.connect('67e89abc123def456789abc1', { since: 42, incarnation: 'inc-20240115103000123456' }); ``` ### Response WebSocket upgrade accepted. The response has no body. The connection switches to the WebSocket frame protocol: each frame is a JSON object of the form `{"seq": , "incarnation": "", "turn_id"?: "", "gate"?: {"id": "", "generation": , "type": "confirm" | "question"}, "event": {...}}`. Event stream (SSE transport). Each SSE frame has the `event:` line stripped of the leading `event.` prefix; the `data:` line carries the full envelope JSON. ```text event: text_delta data: {"seq":43,"incarnation":"inc-20240115103000123456","event":{"type":"event.text_delta","text":"Let me check the build output."}} event: agent_done data: {"seq":44,"incarnation":"inc-20240115103000123456","turn_id":"67e89abc123def456789abc2","event":{"type":"event.agent_done","turn_id":"67e89abc123def456789abc2"}} ``` ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `realm_scope_unsupported` | Realm scope unsupported | A per-request realm header was supplied to an active-only or global-no-realm RPC, which has no realm dimension to scope. | Omit the realm header on this route, or open a session to scope by realm. | | `bad_request` | Bad request | The request was malformed or carried invalid parameters. | Correct the request body or query parameters. | | `invalid_realm` | Invalid realm selector | The realm selector is malformed (not ""/"global"/a 24-hex id). | Pass a valid realm selector. | ```json { "code": "forbidden", "message": "request must arrive through the Hoody proxy" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `forbidden` | Forbidden (Source IP Guard) | The request did not come through the program's URL. Hoody Kit programs are reached through their URLs only. | Call the agent through its URL, for example with `hoody agent …`. | ```json { "code": "not_found", "message": "resource not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | ```json { "code": "rate_limited", "message": "request rate limit exceeded" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `rate_limited` | Too many requests | The per-client request rate limit was exceeded; the gateway throttled the request before dispatch. | Honor the `Retry-After` header and retry; reduce the request rate. | ```json { "code": "service_unavailable", "message": "service unavailable" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `service_unavailable` | Service unavailable | The daemon could not service the request (too busy, or a per-client stream concurrency cap was hit). | Honor `Retry-After` and retry. | ## Replay buffered events ### `GET /api/v1/agent/sessions/{id}/replay` Replay a live session's buffered event tail. Returns the gateway's own ring snapshot, not a daemon reply, with `min_seq` and `max_seq` plus the `incarnation` they belong to for resuming the stream. While a gate is parked, the response includes a `pending_gate` whose `id` is the addressable identity to echo on `POST /sessions/{id}/confirm` or `/answer`. For a non-live session, use `GET /sessions/{id}/transcript` instead. ### Parameters | Name | In | Type | Required | Description | |------|----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope. | | `X-Hoody-Config-Dir` | header | string | No | Per-request `--config-dir` override. | | `X-Hoody-Container` | header | string | No | Per-request bound remote container (omitted = local). Rejected on routes with no container dimension. | | `X-Hoody-Realm` | header | string | No | Per-request realm selector: `global` or a 24-hex id. Rejected on active-only / no-realm routes. | | `realm` | query | string | No | In-query alias of `X-Hoody-Realm`, read only when the header is absent. | ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/67e89abc123def456789abc1/replay" \ -H "Authorization: Bearer " ``` ```typescript import { HoodyClient } from 'hoody-sdk'; const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN }); const replay = await client.agent.sessions.replay('67e89abc123def456789abc1'); ``` ### Response ```json { "session_id": "67e89abc123def456789abc1", "incarnation": "inc-20240115103000123456", "min_seq": 1, "max_seq": 42, "events": [ {"seq": 1, "incarnation": "inc-20240115103000123456", "event": {"type": "event.turn_started"}}, {"seq": 2, "incarnation": "inc-20240115103000123456", "event": {"type": "event.text_delta", "text": "I'll look at the build output."}} ], "pending_gate": { "id": "01HMW7D3Q4K5J6V7Y8Z9X0C1BV", "generation": 1, "type": "confirm", "tool_name": "bash", "gate_cause": "approval_policy", "risk": "destructive", "human_only": true } } ``` ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters. | Correct the request body or query parameters. | | `realm_scope_unsupported` | Realm scope unsupported | A per-request realm header was supplied to an active-only or global-no-realm RPC, which has no realm dimension to scope. | Omit the realm header on this route, or open a session to scope by realm. | | `invalid_realm` | Invalid realm selector | The realm selector is malformed (not ""/"global"/a 24-hex id). | Pass a valid realm selector. | ```json { "code": "forbidden", "message": "request must arrive through the Hoody proxy" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `forbidden` | Forbidden (Source IP Guard) | The request did not come through the program's URL. Hoody Kit programs are reached through their URLs only. | Call the agent through its URL, for example with `hoody agent …`. | ```json { "code": "not_found", "message": "resource not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | ```json { "code": "rate_limited", "message": "request rate limit exceeded" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `rate_limited` | Too many requests | The per-client request rate limit was exceeded; the gateway throttled the request before dispatch. | Honor the `Retry-After` header and retry; reduce the request rate. | ```json { "code": "internal_error", "message": "internal server error" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `internal_error` | Internal error | An unexpected error occurred while handling the request. | Retry; if persistent, inspect the daemon logs. | ```json { "code": "service_unavailable", "message": "service unavailable" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `service_unavailable` | Service unavailable | The daemon could not service the request (too busy, or a per-client stream concurrency cap was hit). | Honor `Retry-After` and retry. | ## Recoverable state ### `GET /api/v1/agent/sessions/{id}/state` Read a session's recoverable state without attaching. Returns one consistent snapshot: the daemon's state object paired with this gateway's stream watermark (`incarnation` and `seq`). The watermark is read before the daemon snapshot, so any event that lands during the read is replayed, never lost. While a gate is parked, the response includes `pending_gate_ref` whose `id` is the addressable identity to echo on `POST /sessions/{id}/confirm` or `/answer` (the daemon's own numeric `gate_id` is diagnostic only and is never what those routes take). Works for a live or dormant session; a dormant one carries no `incarnation` or `seq`. Response is `Cache-Control: no-store` and is active-realm-scoped. Two ids describe one parked gate and only one is answerable. `pending_gate_ref.id` is the string `POST /sessions/{id}/confirm` and `/answer` take. `pending_gate.gate_id` is the daemon's per-kind number for diagnostics. Echoing the number is refused. ### Parameters | Name | In | Type | Required | Description | |------|----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope. | | `X-Hoody-Config-Dir` | header | string | No | Per-request `--config-dir` override. | | `X-Hoody-Container` | header | string | No | Per-request bound remote container (omitted = local). Rejected on routes with no container dimension. | | `X-Hoody-Realm` | header | string | No | Per-request realm selector: `global` or a 24-hex id. Rejected on active-only / no-realm routes. | | `realm` | query | string | No | In-query alias of `X-Hoody-Realm`, read only when the header is absent. | ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/67e89abc123def456789abc1/state" \ -H "Authorization: Bearer " ``` ```typescript import { HoodyClient } from 'hoody-sdk'; const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN }); const snapshot = await client.agent.sessions.getSnapshot('67e89abc123def456789abc1'); ``` ### Response ```json { "session_id": "67e89abc123def456789abc1", "live": true, "epoch": "epoch-20240115103000123456", "state": "parked", "current_turn_id": "67e89abc123def456789abc2", "queued_turn_ids": [], "approval": {"policy": "ask", "session_allow": []}, "pending_gate": { "type": "confirm", "gate_id": 3, "generation": 1, "tool_name": "bash", "gate_cause": "approval_policy", "human_only": true, "lease_required": true, "params": {"command": "rm -rf build"} }, "last_detach": null, "incarnation": "inc-20240115103000123456", "seq": 42, "pending_gate_ref": { "id": "01HMW7D3Q4K5J6V7Y8Z9X0C1BV", "generation": 1, "type": "confirm", "tool_name": "bash", "gate_cause": "approval_policy", "human_only": true } } ``` ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters. | Correct the request body or query parameters. | | `realm_scope_unsupported` | Realm scope unsupported | A per-request realm header was supplied to an active-only or global-no-realm RPC, which has no realm dimension to scope. | Omit the realm header on this route, or open a session to scope by realm. | | `invalid_realm` | Invalid realm selector | The realm selector is malformed (not ""/"global"/a 24-hex id). | Pass a valid realm selector. | ```json { "code": "forbidden", "message": "request must arrive through the Hoody proxy" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `forbidden` | Forbidden (Source IP Guard) | The request did not come through the program's URL. Hoody Kit programs are reached through their URLs only. | Call the agent through its URL, for example with `hoody agent …`. | ```json { "code": "not_found", "message": "resource not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | ```json { "code": "rate_limited", "message": "request rate limit exceeded" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `rate_limited` | Too many requests | The per-client request rate limit was exceeded; the gateway throttled the request before dispatch. | Honor the `Retry-After` header and retry; reduce the request rate. | ```json { "code": "internal_error", "message": "internal server error" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `internal_error` | Internal error | An unexpected error occurred while handling the request. | Retry; if persistent, inspect the daemon logs. | ```json { "code": "restriction_unknown", "message": "the login's realm restriction could not be read — retry" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve, so it refuses realm-scoped requests until it can. Nothing was read or changed. | Retry after the restriction is readable. | ## Persisted transcript ### `GET /api/v1/agent/sessions/{id}/transcript` Read a session's persisted conversation without attaching. Returns the message projection from the session record: `text`, `thinking`, `tool_use`, and `tool_result` blocks (and on an `outcome_claims` session, an `outcome_claims` block following each `report_outcome` tool result), plus turn boundaries. The endpoint never attaches, never resumes, and never consumes the exclusive attach slot. For a live session the content is fresh to the last persist point; the mid-turn streaming tail is served by `GET /sessions/{id}/replay` (live ring) and `GET /sessions/{id}/stream` instead. `X-Hoody-Config-Dir` selects the record store; `X-Hoody-Cwd` is not a match filter here. `after_turn=N` skips completed turns 1..N (exclusive cursor, 0 = full transcript, past-end values clamp; the response echoes the applied value). `turn_boundaries` are response-local after a slice: for local boundary index `i`, the 0-based turn index `POST /sessions/{id}/trim turn_idx` consumes is `after_turn + i`. Messages past the last boundary (a persisted mid-turn tail) are always included and never counted in `turns_total` or `turns_returned`. ### Parameters | Name | In | Type | Required | Description | |------|----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `after_turn` | query | integer | No | Exclusive completed-turn skip cursor: return content strictly after completed turn N (0 = full transcript; values past the end clamp; negative or non-integer = 400). | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope. | | `X-Hoody-Config-Dir` | header | string | No | Per-request `--config-dir` override selecting the record store. | | `X-Hoody-Realm` | header | string | No | Per-request realm selector: "global" (not tied to a realm) or a 24-hex realm id (also accepted as ?realm=). On a session route it names the realm the session is looked up in: a session in another realm, or in a realm this login does not serve, is 404 not_found. Rejected (400 realm_scope_unsupported) on active-only / no-realm routes, and for any other realm on an agent pinned to one realm. | | `realm` | query | string | No | Per-request realm selector — the in:query alias of the X-Hoody-Realm header (read only when the header is absent): "global" (not tied to a realm) or a 24-hex realm id. On a session route it names the realm the session is looked up in: a session in another realm, or in a realm this login does not serve, is 404 not_found. Rejected (400 realm_scope_unsupported) on active-only / no-realm routes, and for any other realm on an agent pinned to one realm. | ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/67e89abc123def456789abc1/transcript?after_turn=0" \ -H "Authorization: Bearer " ``` ```typescript import { HoodyClient } from 'hoody-sdk'; const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN }); const transcript = await client.agent.sessions.getTranscript('67e89abc123def456789abc1', { after_turn: 0 }); ``` ### Response ```json { "session_id": "67e89abc123def456789abc1", "live": false, "realm": "_global", "name": "Fix the build", "model": "claude-opus-4", "cwd": "/home/user/project", "turns_total": 2, "turns_returned": 2, "after_turn": 0, "turn_boundaries": [1, 4], "messages": [ { "role": "user", "blocks": [ {"kind": "text", "text": "Please fix the build."} ] }, { "role": "assistant", "blocks": [ {"kind": "thinking", "text": "Let me check the build output first."}, {"kind": "text", "text": "I'll look at the error."}, {"kind": "tool_use", "tool_id": "toolu_01abc", "tool_name": "bash", "input": {"command": "npm run build"}}, {"kind": "tool_result", "tool_id": "toolu_01abc", "tool_name": "bash", "output": "Error: ENOENT: no such file or directory", "is_error": true}, {"kind": "text", "text": "The issue is a missing config file."} ] } ], "started_at": "2024-01-15T10:00:00Z", "last_request_at": "2024-01-15T10:05:00Z" } ``` ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters. | Correct the request body or query parameters. | | `realm_scope_unsupported` | Realm scope unsupported | A per-request realm header was supplied to an active-only or global-no-realm RPC, which has no realm dimension to scope. | Omit the realm header on this route, or open a session to scope by realm. | | `invalid_realm` | Invalid realm selector | The realm selector is malformed (not ""/"global"/a 24-hex id). | Pass a valid realm selector. | ```json { "code": "forbidden", "message": "request must arrive through the Hoody proxy" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `forbidden` | Forbidden (Source IP Guard) | The request did not come through the program's URL. Hoody Kit programs are reached through their URLs only. | Call the agent through its URL, for example with `hoody agent …`. | ```json { "code": "not_found", "message": "resource not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | ```json { "code": "rate_limited", "message": "request rate limit exceeded" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `rate_limited` | Too many requests | The per-client request rate limit was exceeded; the gateway throttled the request before dispatch. | Honor the `Retry-After` header and retry; reduce the request rate. | ```json { "code": "internal_error", "message": "internal server error" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `internal_error` | Internal error | An unexpected error occurred while handling the request. | Retry; if persistent, inspect the daemon logs. | ```json { "code": "restriction_unknown", "message": "the login's realm restriction could not be read — retry" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve, so it refuses realm-scoped requests until it can. Nothing was read or changed. | Retry after the restriction is readable. |