# Agent: Session turns **Page:** api/agent/sessions/turns [Download Raw Markdown](./api/agent/sessions/turns.md) --- # Agent: Session turns A turn is one user message dispatched into an agent session. This page covers the four ways to dispatch a turn, how to cancel one (the active turn on the session, or a named turn), how to send commands to a session that may already be working (a message, an interrupt, or a stop) and how to read their receipts, how to stop every running item in the realm, how to send input into a running workflow, and how to read the durable turn receipts that survive a disconnect or a daemon restart. All routes below honor per-request realm, container, cwd, and config-dir scoping through the standard `X-Hoody-*` headers (also accepted as query parameters for realm). ## Dispatching a turn Four endpoints dispatch a turn, each with a different posture. Pick by what your caller can do, not by what feels familiar. | Endpoint | Blocks until done | Streams events | Retry-safe with `Idempotency-Key` | |----------|-------------------|----------------|-----------------------------------| | `POST /api/v1/agent/sessions/{id}/messages` | No (returns 202 immediately) | No (observe the session stream separately) | No | | `POST /api/v1/agent/sessions/{id}/prompt:sync` | Until the turn ends, a gate needs a person, or 290 s (then 503 with `details.turn_running`) | No | No, but repeated calls against a parked session return the same `pending_gate` | | `POST /api/v1/agent/sessions/{id}/prompt:stream` | No (opens SSE at once) | Yes (SSE starting just before dispatch) | No (replay returns 409 `replay_unavailable`) | | `POST /api/v1/agent/sessions/{id}/turns` | No (returns 202 receipt once admitted) | No (observe the session stream separately) | Yes (returns the same receipt for the same key) | ### `POST /api/v1/agent/sessions/{id}/messages` Fire-and-observe. Dispatches a user turn and returns immediately with `{job_id, session_id, turn_id}`. Observe `agent_done` on the session stream (its `turn_id` matches the response) to learn the outcome. The session is single-writer: a parked gate (409 `gate_parked`) or a running turn (409 `turn_in_flight`) refuses a new dispatch. Not retry-safe; for retries, dispatch through `POST /turns` with an `Idempotency-Key`. An input that carries neither an `Idempotency-Key` nor a `turn_id` (an interactive TUI turn) is forwarded and logged when the admission ledger cannot be read or written; no receipt and no ledger row. Keyed and gateway-dispatched inputs are refused with 503 `admission_write_failed` instead, because a lost row could let a retry run twice. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope: the .hoody project layer / record cwd / tool+workflow cwd. Required by routes that resolve a cwd (e.g. POST /todos; createTodo also accepts a body cwd). | | `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 (400) on routes with no container dimension. | | `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. | | `Idempotency-Key` | header | string | No | Opaque retry key (1–255 printable ASCII, no whitespace). A retry with the same key returns the same receipt and never re-runs the turn; the same key with a different request body is 422. | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `text` | string | Yes | Required (may be empty only with attachments). The user message text for this turn: empty or whitespace-only text with no attachments is refused (400 empty_prompt) and no turn runs. | | `tool_mode` | string | No | Optional per-turn tool mode override: standard or orchestrator. Any other value is refused 400 invalid_tool_mode. | | `dir_scope` | string | No | Optional per-turn directory-access scope override: home or full. Any other value is refused 400 invalid_dir_scope. | | `attachments` | array | No | Optional images for this turn, as INLINE base64 bytes. At most 4 per turn, each at most 4 MiB DECODED (~5.33 MiB of base64); the request body limit applies on top and is authoritative, so several individually-legal images can still exceed it. A violation is refused with 400 BEFORE the turn is accepted, nothing is silently dropped. `path` is not supported here: it would name a file on the CALLER's machine, and a path-bearing attachment is discarded outright on a container-bound session. If the active model cannot accept images the daemon reports that on the session stream. | #### Responses ```json { "job_id": "job-9f2c4e1ab07d3355", "session_id": "sess-9f2c4e1ab07d3355", "turn_id": "turn-9f2c4e1ab07d3355" } ``` ```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. | | `empty_prompt` | Empty prompt | The turn has no text (empty or only whitespace) and no attachments, so there is nothing to send to the model. No turn was started. details.field is "text". | Send the message text, or at least one image attachment. | | `invalid_tool_mode` | Invalid tool mode | tool_mode is not standard or orchestrator. Nothing was started. details.field is "tool_mode". | Send standard or orchestrator, or omit tool_mode. | | `invalid_dir_scope` | Invalid directory scope | dir_scope is not home or full. Nothing was started. details.field is "dir_scope". | Send home or full, or omit dir_scope. | | `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 …`. | | `realm_not_allowed` | Realm not allowed for this login | The agent is logged in with a token limited to some realms (GET /hoody/auth/status lists them in realm_ids), and this session's realm is not one of them, or is the global scope (a session restored from before the login, or a realm the login was narrowed away from). The session can still be read; nothing was run. | Continue in a session of a realm the login serves, or log the agent in with a token that covers this session's realm. | ```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": "gate_parked", "message": "a gate is parked on this session", "details": { "pending_gate": { "generation": 3, "id": "gate-9f2c4e1ab07d3355-3", "type": "confirm" } } } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `gate_parked` | Gate parked | A confirm or question gate is parked on this session, so a new turn is refused until it is answered. The parked gate is surfaced under `details.pending_gate`. | Answer the parked gate and retry; read it from `details.pending_gate`, from `pending_gate` on `GET /sessions/{id}`, or from the gate field of the stream frame that parked it. | | `turn_in_flight` | Turn in flight | A turn is already running on this session; the single serial turn slot is occupied. | Wait for the running turn to finish (observe `agent_done` on the session stream), then retry. | ```json { "code": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit; split a large payload into smaller requests. | ```json { "code": "idempotency_key_reused", "message": "this idempotency key was used for a different request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `idempotency_key_reused` | Idempotency key reused | The `Idempotency-Key` was already used for a DIFFERENT request on this session. A key binds to one request. | Use a fresh `Idempotency-Key` for a different request, or resend the identical request to get the original receipt. | ```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": "admission_unconfirmed", "message": "the daemon did not acknowledge the turn's admission within the wait", "details": { "result_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-9f2c4e1ab07d3355", "turn_id": "turn-9f2c4e1ab07d3355" } } ``` | 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. | | `admission_unconfirmed` | Admission unconfirmed | The turn was sent but the daemon's admission receipt did not arrive within the wait. It may still have been accepted: `details.turn_id` and `details.result_url` name the turn to poll. | `GET details.result_url`; retry with the SAME `Idempotency-Key` only after it reports the turn was not recorded (404). | | `admission_write_failed` | Admission write failed | The daemon could not durably record the turn's admission, so the turn was NOT run (fail-closed). | Retry; if it persists, check the daemon's session store (disk and permissions). | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it runs no work in any session until it can. Nothing was run. | Retry after the restriction is readable. | #### SDK usage ```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.startTurn('sess-9f2c4e1ab07d3355', { text: 'Summarize the last 10 commits in the repo.' }); ``` #### cURL ```bash curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/messages" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "text": "Summarize the last 10 commits in the repo." }' ``` ### `POST /api/v1/agent/sessions/{id}/prompt:sync` Dispatches a turn and waits at most 290 seconds. Returns when the turn ends, or as soon as a turn parks on a confirm or question gate with 200 `pending_gate` (no `status`; the turn is still running). At the 290 s mark it returns 200 `pending_gate` if a gate needs a person, and otherwise 503 `service_unavailable` with `details.turn_id` and `details.turn_running: true`; the turn is NOT cancelled, follow it on the stream or with `GET /sessions/{id}/turns/{turn_id}`. Repeated `prompt:sync` against a parked session returns the same `pending_gate` (no duplicate parked turns). Pass `X-Hoody-Gate-Policy: auto_approve` (or `?policy=auto_approve`) to adopt the headless posture and auto-answer confirm gates with `approved=true` (a confirm gate a tool-call rule raised is answered `approved=false`; question and plan gates still wait for `/answer` or `/plan`). The response is one of several outcome bodies. Branch on `pending_gate` first; when present the turn is still running and there is no `status`. Otherwise branch on `status`: `done`, `error`, `canceled`, or `quit`. A completion carries `turn_id`. A 290 s timeout returns `pending_gate` (gate waiting on a person) or 503 `service_unavailable` with `details.turn_id` and `details.turn_running: true`. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `policy` | query | string | No | auto_approve adopts the headless auto-answer posture (alias of the X-Hoody-Gate-Policy header); off by default; the gateway asks for no separate credentials to use it; access is decided by the container's proxy permission policy. | | `X-Hoody-Gate-Policy` | header | string | No | Confirm-gate posture: "auto_approve" adopts the headless auto-answer posture (off by default; the in:query alias is ?policy=). Any other value is rejected 400. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope: the .hoody project layer / record cwd / tool+workflow cwd. Required by routes that resolve a cwd (e.g. POST /todos; createTodo also accepts a body cwd). | | `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 (400) on routes with no container dimension. | | `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. | | `Idempotency-Key` | header | string | No | Opaque retry key (1–255 printable ASCII, no whitespace). The turn runs at most once per key: a retry answers 200 duplicate:true with the turn's outcome (or pending_turn while it still runs) and never re-runs it; the same key with a different request body is 422. | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `text` | string | Yes | Required (may be empty only with attachments). The user message text for this turn: empty or whitespace-only text with no attachments is refused (400 empty_prompt) and no turn runs. | | `tool_mode` | string | No | Optional per-turn tool mode override: standard or orchestrator. Any other value is refused 400 invalid_tool_mode. | | `dir_scope` | string | No | Optional per-turn directory-access scope override: home or full. Any other value is refused 400 invalid_dir_scope. | | `attachments` | array | No | Optional images for this turn, as INLINE base64 bytes. At most 4 per turn, each at most 4 MiB DECODED (~5.33 MiB of base64); the request body limit applies on top and is authoritative, so several individually-legal images can still exceed it. A violation is refused with 400 BEFORE the turn is accepted, nothing is silently dropped. `path` is not supported here: it would name a file on the CALLER's machine, and a path-bearing attachment is discarded outright on a container-bound session. If the active model cannot accept images the daemon reports that on the session stream. | #### Responses ```json { "status": "done", "session_id": "sess-9f2c4e1ab07d3355", "turn_id": "turn-9f2c4e1ab07d3355" } ``` A parked turn returns `{pending_gate}` with no `status`. Other outcomes carry `status`: `done`, `error`, `canceled`, or `quit`. A 290 s timeout returns `pending_gate` if a gate needs a person, otherwise `503 service_unavailable` with `details.turn_id` and `details.turn_running: 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. | | `empty_prompt` | Empty prompt | The turn has no text (empty or only whitespace) and no attachments, so there is nothing to send to the model. No turn was started. details.field is "text". | Send the message text, or at least one image attachment. | | `invalid_tool_mode` | Invalid tool mode | tool_mode is not standard or orchestrator. Nothing was started. details.field is "tool_mode". | Send standard or orchestrator, or omit tool_mode. | | `invalid_dir_scope` | Invalid directory scope | dir_scope is not home or full. Nothing was started. details.field is "dir_scope". | Send home or full, or omit dir_scope. | | `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 …`. | | `realm_not_allowed` | Realm not allowed for this login | The agent is logged in with a token limited to some realms (GET /hoody/auth/status lists them in realm_ids), and this session's realm is not one of them, or is the global scope (a session restored from before the login, or a realm the login was narrowed away from). The session can still be read; nothing was run. | Continue in a session of a realm the login serves, or log the agent in with a token that covers this session's realm. | ```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": "turn_in_flight", "message": "a turn is already running on this session" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `turn_in_flight` | Turn in flight | A turn is already running on this session; the single serial turn slot is occupied, so a new turn/workflow run is refused. | Wait for the running turn to finish (observe agent_done on the session stream), then retry. | | `approval_policy_active` | Approval policy active | This session requires an explicit decision on every action, so an automatic-answer alias (the auto_approve gate posture, arming YOLO, an allow rule) is refused. | Answer each gate through POST /sessions/{id}/confirm, or change the policy first (PUT /sessions/{id}/approval) if it is not locked. | ```json { "code": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit; split a large payload into smaller requests. | ```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. | 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", "details": { "turn_id": "turn-9f2c4e1ab07d3355", "turn_running": true } } ``` A 290 s timeout: the turn is still running, not cancelled. Follow it on the session stream or with `GET /sessions/{id}/turns/{turn_id}`. | 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), or the 290 s prompt-sync wait elapsed with the turn still running. | Honor `Retry-After` and retry; for a 290 s timeout, follow the named turn rather than retrying. | | `admission_unconfirmed` | Admission unconfirmed | The turn was sent but the daemon's admission receipt did not arrive within the wait. It may still have been accepted: details.turn_id / details.result_url name the turn to poll. The gateway keeps the turn slot and job rather than inventing or discarding acceptance. | GET details.result_url; retry with the SAME Idempotency-Key only after it reports the turn was not recorded (404). | | `admission_write_failed` | Admission write failed | The daemon could not durably record the turn's admission, so the turn was NOT run (fail-closed: nothing runs unrecorded). | Retry; if it persists, check the daemon's session store (disk / permissions). | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it runs no work in any session until it can. Nothing was run. | Retry after the restriction is readable. | ```json { "code": "idempotency_key_reused", "message": "this idempotency key was used for a different request", "details": { "turn_id": "turn-3f2a9c1e-12" } } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `idempotency_key_reused` | Idempotency key reused | The Idempotency-Key was already used for a DIFFERENT request on this session. A key binds to one request; reusing it for another is a client bug. details.turn_id names the turn the key admitted (GET /sessions/{id}/turns/{turn_id} reads it). | Use a fresh Idempotency-Key for a different request, or resend the identical request to get the original receipt. | #### SDK usage ```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 outcome = await client.agent.sessions.turns.run('sess-9f2c4e1ab07d3355', { text: 'Summarize the last 10 commits in the repo.' }); ``` #### cURL ```bash curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/prompt:sync" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "text": "Summarize the last 10 commits in the repo." }' ``` ### `POST /api/v1/agent/sessions/{id}/prompt:stream` Stream the response. Dispatches a turn and opens an SSE stream positioned at the `seq` captured just before dispatch, so the first frames are this turn's rather than the retained ring's backlog. This is an ordinary session stream at a cursor, NOT a per-turn stream. It does NOT close when the turn finishes, and it keeps delivering whatever else happens on the session (including turns another client dispatches) until the caller disconnects or the session ends. Stop reading at the `agent_done` whose `turn_id` matches the `X-Hoody-Turn-Id` response header and close the connection yourself; a client that waits for EOF waits forever. Pass `X-Hoody-Gate-Policy: auto_approve` (or `?policy=auto_approve`) to auto-answer confirm gates with `approved=true` for the life of the stream. Off by default. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `policy` | query | string | No | `auto_approve` auto-answers confirm gates for the life of the stream (alias of the `X-Hoody-Gate-Policy` header); off by default. | | `X-Hoody-Gate-Policy` | header | string | No | Confirm-gate posture: `auto_approve` adopts the headless auto-answer posture. Any other value is rejected with 400. | | `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). | | `X-Hoody-Realm` | header | string | No | Per-request realm selector (`global` or a 24-hex id). | | `realm` | query | string | No | Per-request realm selector (query alias of `X-Hoody-Realm`). | | `Idempotency-Key` | header | string | No | Opaque retry key (1–255 printable ASCII, no whitespace). The turn runs at most once per key: a retry is 409 replay_unavailable with the turn's receipt (details.turn), never a re-run and never a second stream; the same key with a different request body is 422. | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `text` | string | Yes | The user message text for this turn. | | `tool_mode` | string | No | Optional per-turn tool mode override. | | `dir_scope` | string | No | Optional per-turn directory-access scope override (`home` or `full`). | | `attachments` | array | No | Optional inline base64 images (at most 4 per turn, each at most 4 MiB decoded). | #### Responses Response is `text/event-stream`. Stop reading at the `agent_done` whose `turn_id` matches the `X-Hoody-Turn-Id` response header, then close the connection yourself. ``` event: agent_text_delta data: {"text": "I'll look that up.\n"} event: agent_tool_use data: {"name": "shell", "input": {"cmd": "git log --oneline -10"}} event: agent_text_delta data: {"text": "Last 10 commits:\n"} event: agent_done data: {"turn_id": "turn-9f2c4e1ab07d3355", "state": "completed", "outcome": "completed"} ``` ```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. | Omit the realm header on this route, or open a session to scope by realm. | | `empty_prompt` | Empty prompt | The turn has no text (empty or only whitespace) and no attachments, so there is nothing to send to the model. No turn was started. details.field is "text". | Send the message text, or at least one image attachment. | | `invalid_tool_mode` | Invalid tool mode | tool_mode is not standard or orchestrator. Nothing was started. details.field is "tool_mode". | Send standard or orchestrator, or omit tool_mode. | | `invalid_dir_scope` | Invalid directory scope | dir_scope is not home or full. Nothing was started. details.field is "dir_scope". | Send home or full, or omit dir_scope. | | `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 …`. | | `realm_not_allowed` | Realm not allowed for this login | The agent is logged in with a token limited to some realms (GET /hoody/auth/status lists them in realm_ids), and this session's realm is not one of them, or is the global scope (a session restored from before the login, or a realm the login was narrowed away from). The session can still be read; nothing was run. | Continue in a session of a realm the login serves, or log the agent in with a token that covers this session's realm. | ```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": "turn_in_flight", "message": "a turn is already running on this session" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `gate_parked` | Gate parked | A confirm or question gate is parked on this session, so a new turn is refused until it is answered. | Answer the parked gate and retry. | | `turn_in_flight` | Turn in flight | A turn is already running on this session; the single serial turn slot is occupied. | Wait for the running turn to finish, then retry. | | `approval_policy_active` | Approval policy active | This session requires an explicit decision on every action, so `auto_approve` is refused. | Answer each gate, or change the policy first if it is not locked. | | `replay_unavailable` | Replay unavailable | The `Idempotency-Key` names a turn that already ran (`details.turn` is its receipt); `prompt:stream` cannot re-stream the original events and never re-runs the turn. | Read `details.turn` or `GET details.turn.result_url` for the outcome, or attach `GET /sessions/{id}/stream` with a cursor you hold. | ```json { "code": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit; split a large payload into smaller requests. | ```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. | 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 | A transport error occurred before SSE opened. | Retry; if persistent, inspect the daemon logs. | ```json { "code": "admission_unconfirmed", "message": "the daemon did not acknowledge the turn's admission within the wait", "details": { "result_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-9f2c4e1ab07d3355", "turn_id": "turn-9f2c4e1ab07d3355" } } ``` | 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. | | `admission_unconfirmed` | Admission unconfirmed | The turn was sent but the daemon's admission receipt did not arrive within the wait. It may still have been accepted. | `GET details.result_url`; retry with the SAME `Idempotency-Key` only after it reports the turn was not recorded (404). | | `admission_write_failed` | Admission write failed | The daemon could not durably record the turn's admission, so the turn was NOT run (fail-closed). | Retry; if it persists, check the daemon's session store (disk and permissions). | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it runs no work in any session until it can. Nothing was run. | Retry after the restriction is readable. | | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `idempotency_key_reused` | Idempotency key reused | The Idempotency-Key was already used for a DIFFERENT request on this session. A key binds to one request; reusing it for another is a client bug. details.turn_id names the turn the key admitted (GET /sessions/{id}/turns/{turn_id} reads it). | Use a fresh Idempotency-Key for a different request, or resend the identical request to get the original receipt. | #### SDK usage ```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.startTurnAndStream('sess-9f2c4e1ab07d3355', { text: 'What is in this image?', attachments: [ { type: 'image', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=' } ] }, { policy: 'auto_approve' }); ``` #### cURL ```bash curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/prompt:stream?policy=auto_approve" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{ "text": "What is in this image?", "attachments": [{ "type": "image", "media_type": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=" }] }' ``` ### `POST /api/v1/agent/sessions/{id}/turns` Retry-safe dispatch. Dispatches a user turn and returns 202 with a durable receipt once the daemon has admitted it. Pass a standard `Idempotency-Key` header (1 to 255 printable ASCII, no whitespace) to make this dispatch retry-safe. A retry with the same key returns the same receipt with `duplicate: true` and never runs the turn twice, including while the original is still running; the same key with a different request body returns 422 `idempotency_key_reused`. A retry that cannot resolve the recorded turn's session is refused 409 (the busy refusal stands; nothing runs, because an unverified match is not a match). The session is single-writer: a parked gate (409 `gate_parked`) or a running turn (409 `turn_in_flight`) refuses a new dispatch and records nothing. If the daemon does not acknowledge within the wait the answer is 503 `admission_unconfirmed` carrying `turn_id` and `result_url`; the turn may still have been accepted, so poll `result_url` before retrying (acceptance is never invented and never rolled back). A ledger write failure is 503 `admission_write_failed` (fail-closed). A turn interrupted by a daemon restart replays as state `interrupted` with `effects_may_have_occurred` and is never re-run. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `Idempotency-Key` | header | string | No | Opaque retry key (1 to 255 printable ASCII, no whitespace). A retry with the same key returns the same receipt and never re-runs the turn; the same key with a different request body returns 422. | | `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). | | `X-Hoody-Realm` | header | string | No | Per-request realm selector (`global` or a 24-hex id). | | `realm` | query | string | No | Per-request realm selector (query alias of `X-Hoody-Realm`). | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `text` | string | Yes | The user message text for this turn. | | `tool_mode` | string | No | Optional per-turn tool mode override. | | `dir_scope` | string | No | Optional per-turn directory-access scope override (`home` or `full`). | | `attachments` | array | No | Optional inline base64 images (at most 4 per turn, each at most 4 MiB decoded). | #### Responses ```json { "turn_id": "turn-9f2c4e1ab07d3355", "job_id": "job-9f2c4e1ab07d3355", "state": "accepted", "duplicate": false, "accepted_at": "2025-04-12T14:32:11Z", "result_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-9f2c4e1ab07d3355", "stream_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/stream", "notices": [] } ``` ```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. | Omit the realm header on this route, or open a session to scope by realm. | | `empty_prompt` | Empty prompt | The turn has no text (empty or only whitespace) and no attachments, so there is nothing to send to the model. No turn was started. details.field is "text". | Send the message text, or at least one image attachment. | | `invalid_tool_mode` | Invalid tool mode | tool_mode is not standard or orchestrator. Nothing was started. details.field is "tool_mode". | Send standard or orchestrator, or omit tool_mode. | | `invalid_dir_scope` | Invalid directory scope | dir_scope is not home or full. Nothing was started. details.field is "dir_scope". | Send home or full, or omit dir_scope. | | `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 …`. | | `realm_not_allowed` | Realm not allowed for this login | The agent is logged in with a token limited to some realms (GET /hoody/auth/status lists them in realm_ids), and this session's realm is not one of them, or is the global scope (a session restored from before the login, or a realm the login was narrowed away from). The session can still be read; nothing was run. | Continue in a session of a realm the login serves, or log the agent in with a token that covers this session's realm. | ```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": "turn_in_flight", "message": "a turn is already running on this session" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `gate_parked` | Gate parked | A confirm or question gate is parked on this session, so a new turn is refused until it is answered. | Answer the parked gate and retry. | | `turn_in_flight` | Turn in flight | A turn is already running on this session; the single serial turn slot is occupied. | Wait for the running turn to finish, then retry. | ```json { "code": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit; split a large payload into smaller requests. | ```json { "code": "idempotency_key_reused", "message": "this idempotency key was used for a different request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `idempotency_key_reused` | Idempotency key reused | The `Idempotency-Key` was already used for a DIFFERENT request on this session. A key binds to one request. | Use a fresh `Idempotency-Key` for a different request, or resend the identical request to get the original receipt. | ```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. | 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. | | `admission_unconfirmed` | Admission unconfirmed | The turn was sent but the daemon's admission receipt did not arrive within the wait. It may still have been accepted: details.turn_id / details.result_url name the turn to poll. The gateway keeps the turn slot and job rather than inventing or discarding acceptance. | GET details.result_url; retry with the SAME Idempotency-Key only after it reports the turn was not recorded (404). | | `admission_write_failed` | Admission write failed | The daemon could not durably record the turn's admission, so the turn was NOT run (fail-closed: nothing runs unrecorded). | Retry; if it persists, check the daemon's session store (disk / permissions). | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it runs no work in any session until it can. Nothing was run. | Retry after the restriction is readable. | #### SDK usage ```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 receipt = await client.agent.sessions.turns.create('sess-9f2c4e1ab07d3355', { text: 'Summarize the last 10 commits in the repo.' }, { IdempotencyKey: '01HX5G2EXAMPLE1234567890AB' }); ``` #### cURL ```bash curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01HX5G2EXAMPLE1234567890AB" \ -d '{ "text": "Summarize the last 10 commits in the repo." }' ``` ## Cancelling a turn ### `POST /api/v1/agent/sessions/{id}/cancel` Cancels the active turn on the session (the historical "Esc" behaviour), or, when a `turn_id` is supplied, cancels only that named turn. With `turn_id`, the cancel targets ONLY that turn, the one returned when it was dispatched. It cancels a running turn, cancels a queued turn the moment it starts, and does nothing for a turn that already ended, so a late Stop can never stop a newer turn, including one another client started on the same session. The reply's `matched` is `true` when the id named the turn this gateway is running; `false` for an id that already ended or is not this gateway's (a benign race; nothing was settled). Without `turn_id`, the cancel stops whatever turn is running on the shared session, settles the session's in-flight dispatch jobs, and aborts an in-flight in-session direct tool run. Either form spares the session and background tasks (distinct from `close`). #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `turn_id` | query | string | No | Cancel only this turn (alternative to the body field). | | `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). | | `X-Hoody-Realm` | header | string | No | Per-request realm selector (`global` or a 24-hex id). | | `realm` | query | string | No | Per-request realm selector (query alias of `X-Hoody-Realm`). | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `turn_id` | string | No | The turn to cancel, as returned when it was dispatched. Cancels only that turn. Omit (or send `{}`) to cancel whatever is running. | #### Responses ```json { "status": "ok", "turn_id": "turn-9f2c4e1ab07d3355", "matched": true } ``` `matched` is present on a turn-scoped cancel: `false` when the named turn no longer held the slot, in which case nothing was cancelled. ```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. | 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": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit; split a large payload into smaller requests. | ```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. | 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. | #### SDK usage ```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 }); // Cancel a specific turn by id. const result = await client.agent.sessions.turns.cancel('sess-9f2c4e1ab07d3355', { turn_id: 'turn-9f2c4e1ab07d3355' }); // Cancel whatever turn is currently running on the session. await client.agent.sessions.turns.cancel('sess-9f2c4e1ab07d3355', {}); ``` #### cURL ```bash # Cancel a specific turn. curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/cancel" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "turn_id": "turn-9f2c4e1ab07d3355" }' # Cancel whatever turn is currently running. curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/cancel" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' ``` ## Commands: reaching a session that may already be working Reach a live session, working or idle, with a message, an interrupt, or a stop. Returns 202 with the command's receipt once the agent has recorded it. The `Idempotency-Key` header is required: a retry with the same key and the same command returns the stored receipt (with `duplicate: true`) and admits nothing; the same key with a different command is 422 `idempotency_key_reused`. A command's receipt is kept while the command waits and for 24 hours after it settled (`committed`, `superseded`, `refused`, or `failed`); after that `GET /sessions/{id}/commands/{command_id}` returns 404 and the same key admits a new command. Each session remembers at most 4096 keys: a command with a fresh key beyond that is 429 `idempotency_keys_exhausted`. A stop is never refused because of the remembered-key limit, and its key is remembered like any other. A `message` reaches the session at its next step: before its next model request, before its next tool call (a tool call the model asked for in the same response that has not started is not run, and its result says so), or when the turn would end. An idle session starts a turn for it. Commands that are waiting when the session reaches a step are delivered together, in the order they were sent, as one user message. `on_gate` `deny` (the default) declines any confirmation or question the session is waiting on ("declined: the user sent a new message") so the message is read at once; `on_gate` `wait` leaves the gate waiting and the message waits behind it. Commands reach live sessions only: a closed session is 409 `session_closed`, an unknown session is 404, and a headless or internal session (a TODO run) is 409 `command_unsupported`. A configured `UserPromptSubmit` hook sees each message before delivery and can refuse it (state `refused`, reason `hook`). Stream events announce delivered commands (`event.command_committed`) and the ones that ended otherwise (`event.command_settled`). ### `POST /api/v1/agent/sessions/{id}/commands` Sends a command to a live session. Kind `message` delivers `text` to the session at its next step. An idle session starts a turn for the message. `text` is required, non-empty, and at most 32 KiB (more is 400 `bad_request`). Kind `interrupt` stops the work the session was running when the interrupt was sent, then delivers `text` as a new turn. Background tasks, background workflow runs, and background shells keep running (a `stop` ends them). The receipt says whether work was running (`interrupted`). `text` is required, non-empty, and at most 32 KiB. Kind `stop` stops all of the session's work, pauses its loops, and supersedes every message and interrupt sent before it that has not been delivered. A stop carries no `text`. The receipt's `stopped` lists what the stop acted on and how each ended. With `close: true` the session is closed once its work is stopped, and later commands are 409 `session_closed`. `order` (optional, at least 0) sequences one caller's commands: a message or interrupt with a lower order than a stop already received is superseded at once (`reason: order`). `from` and `trigger` are attribution only (they change how the message is introduced to the model); the agent does not verify them. The text waiting on one session is at most 128 KiB (more is 429 `command_queue_full` with `Retry-After`). #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `Idempotency-Key` | header | string | Yes | Retry key, required on this route; the SDK and CLI send one automatically. 1–255 printable ASCII, no whitespace. A retry with the same key and command returns the stored receipt and admits nothing; the same key with a different command is 422. The key is remembered while the command waits and for 24 hours after it settled. Past 4096 remembered keys a new key is 429 `idempotency_keys_exhausted`; a stop is never refused because of the remembered-key limit. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope: the .hoody project layer / record cwd / tool+workflow cwd. Required by routes that resolve a cwd (e.g. POST /todos; createTodo also accepts a body cwd). | | `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 (400) on routes with no container dimension. | | `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. | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `kind` | string | Yes | `message`, `interrupt`, or `stop`. | | `text` | string | No | The message text (`message` and `interrupt`: required, non-empty, at most 32 KiB). Delivered verbatim. A `stop` takes none. | | `close` | boolean | No | A `stop` only: close the session once its work is stopped. | | `on_gate` | string | No | A `message` only: `deny` (default) declines a confirmation or question the session is waiting on so the message is read now; `wait` leaves it waiting. | | `order` | integer | No | Optional, at least 0: a sequence number for one caller's commands. A message or interrupt with a lower order than a stop already received is superseded. | | `from` | object | No | Who sends it (`kind` enum: `user`, `bot`, `delegate`, `system`; optional `id` of at most 256 bytes). Attribution only, never authorization. | | `trigger` | string | No | What caused it (enum: `human`, `wake`, `clear`). Recorded on the receipt. Attribution only, never authorization. | ```json { "kind": "message", "text": "Also update the README when you are done." } ``` #### Responses ```json { "admitted_at": "2026-10-08T14:03:11.204Z", "command_id": "cmd_3f2a9c1e5b7d40a1c2e8f6d9", "kind": "message", "state": "queued" } ``` A receipt carries the command's `state`: `queued`, `committed`, `superseded`, `refused`, or `failed`. A `committed` receipt also carries `turn_id` and `how` (`next_step`, `new_turn`, or `stop`). A `stop` receipt's `stopped` lists what it acted on (kind `session`, `task`, `workflow_run`, `bash_job`, or `loop`) and the outcome per item (`stopped`, `already_done`, or `failed`). ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters (for example, a missing `Idempotency-Key`, an empty `text` on a `message` or `interrupt`, or `text` larger than 32 KiB). | Correct the request body or headers. | | `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 | |------------|-------|-------------|------------| | `realm_not_allowed` | Realm not allowed for this login | The agent is logged in with a token limited to some realms (GET /hoody/auth/status lists them in realm_ids), and this session's realm is not one of them, or is the global scope (a session restored from before the login, or a realm the login was narrowed away from). The session can still be read; nothing was run. | Continue in a session of a realm the login serves, or log the agent in with a token that covers this session's realm. | | `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": "session_closed", "message": "the session is being closed" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `session_closed` | Session closed | The session is closed (not live), or a stop sent with close is closing it. Nothing was admitted. | Attach the session with POST /sessions (attach) and send the command again, or start another session. | | `command_unsupported` | Commands unsupported | This session does not take commands: a headless run or an internal session (a TODO run). Nothing was admitted. | Cancel it with POST /sessions/{id}/cancel instead. | ```json { "code": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit (default 8 MiB); split a large payload into smaller requests. | ```json { "code": "idempotency_key_reused", "message": "the idempotency key was used for a different command (cmd_3f2a9c1e5b7d40a1c2e8f6d9)" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `idempotency_key_reused` | Idempotency key reused | The Idempotency-Key was already used for a DIFFERENT command on this session (another kind, text, close, on_gate, order, from or trigger). Nothing was admitted. The message names the command the key admitted. | Use a fresh Idempotency-Key for a different command, or resend the identical command to get its receipt. | ```json { "code": "command_queue_full", "message": "too much text is queued on this session; retry after event.command_committed" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `command_queue_full` | Command queue full | The text of the commands still waiting on this session would pass 128 KiB with this one. Nothing was admitted. `Retry-After` says when to retry. | Wait for event.command_committed (the queued commands reached the session), then send again with the same Idempotency-Key. | | `idempotency_keys_exhausted` | Too many remembered keys | The session already remembers 4096 Idempotency-Keys (each one while its command waits and for 24 hours after the command settled). Nothing was admitted. A stop is never refused because of the remembered-key limit. | Send the command later, when older keys have expired. | | `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. | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it runs no work in any session until it can. Nothing was run. | Retry after the restriction is readable. | | `admission_write_failed` | Admission write failed | The agent could not record the command, so it was NOT admitted. | Retry with the same Idempotency-Key; if it persists, check the agent's storage (disk space, permissions). | | `admission_unconfirmed` | Admission unconfirmed | The command was sent but the agent did not acknowledge it within the wait. It may have been admitted. | Resend the identical request with the same Idempotency-Key: if the command was admitted it answers the stored receipt (duplicate true), and nothing is admitted twice. | #### SDK usage ```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 receipt = await client.agent.sessions.commands.send('sess-9f2c4e1ab07d3355', { kind: 'message', text: 'Also update the README when you are done.' }, { IdempotencyKey: '01HX5G2EXAMPLE1234567890AB' }); // Track a stop; the receipt's stopped tells you what it ended. const stop = await client.agent.sessions.commands.send('sess-9f2c4e1ab07d3355', { kind: 'stop', close: true }, { IdempotencyKey: '01HX5G2EXAMPLE1234567890CD' }); ``` #### cURL ```bash # Send a message. curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/commands" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01HX5G2EXAMPLE1234567890AB" \ -d '{ "kind": "message", "text": "Also update the README when you are done." }' # Stop everything and close the session. curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/commands" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 01HX5G2EXAMPLE1234567890CD" \ -d '{ "kind": "stop", "close": true }' ``` ### `GET /api/v1/agent/sessions/{id}/commands/{command_id}` Returns the receipt of a command sent to this session. `state` is `queued` until the command is delivered, then `committed` (with `turn_id` and `how`), `superseded`, `refused`, or `failed`. The session must be live: a closed session is 409 `session_closed`. A command id this session never admitted is 404, and so is one whose receipt is no longer kept (a receipt is kept while its command waits and for 24 hours after it settled). `Cache-Control: no-store`. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `command_id` | path | string | Yes | The command id. | | `X-Hoody-Cwd` | header | string | No | Per-request working-directory scope: the .hoody project layer / record cwd / tool+workflow cwd. Required by routes that resolve a cwd (e.g. POST /todos; createTodo also accepts a body cwd). | | `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 (400) on routes with no container dimension. | | `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. | #### Responses ```json { "admitted_at": "2026-10-08T14:03:11.204Z", "command_id": "cmd_3f2a9c1e5b7d40a1c2e8f6d9", "kind": "message", "state": "queued" } ``` A `committed` receipt carries `turn_id` (the running turn when `how` is `next_step`, or the turn it started when `how` is `new_turn`; absent for a `stop`) and `committed_at`. A `superseded` receipt carries `superseded_by` (the `command_id` of the stop that superseded it); `reason` is `stop` (a stop sent after it) or `order` (a stop with a higher order). A `refused` receipt carries `reason` (`hook`, `closed`, or `unsupported`) and an optional `detail` (for example, the hook's message). A `failed` receipt carries `reason: write_failed`. A `stop` receipt's `stopped` lists what it acted on and how each ended; `interrupted` appears on an `interrupt` receipt (whether running work was stopped for it). ```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 command id is not for this session, or its receipt is no longer kept (the keep window is while the command waits and 24 hours after it settled). | Verify the path and identifier; resend the command if its keep window elapsed. | ```json { "code": "session_closed", "message": "the session is being closed" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `session_closed` | Session closed | The session is closed (not live), or a stop sent with close is closing it. Nothing was admitted. | Attach the session with POST /sessions (attach) and send the command again, or start another session. | ```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 | |------------|-------|-------------|------------| | `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. | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it refuses realm-scoped requests until it can. Nothing was read or changed. | Retry after the restriction is readable. | #### SDK usage ```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 }); // Poll the receipt returned by POST /commands. const r = await client.agent.sessions.commands.get('sess-9f2c4e1ab07d3355', 'cmd_3f2a9c1e5b7d40a1c2e8f6d9'); const state = r.data.state; ``` #### cURL ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/commands/cmd_3f2a9c1e5b7d40a1c2e8f6d9" \ -H "Authorization: Bearer $HOODY_TOKEN" ``` ## Stopping everything in the realm ### `POST /api/v1/agent/stop` Stops, in one call, all running work in the realm the caller may see. It covers running or parked session turns (every live session, including system, resident, and headless ones; each is stopped like `POST /sessions/{id}/cancel` with the `turn_id` of the turn that was running when the call listed it, so a turn that starts later is never stopped, only reported under `started_during_call`), background tasks, workflow runs, todo runs (the lease is released as cancelled, as `cancelTodoRun` does), detached local background bash jobs, detached background bash jobs on bound containers, loops (PAUSED, never deleted: resume one with `PATCH /sessions/{id}/loops/{loopId}` `{"paused":false}`), and this gateway's detached headless and gated tool jobs. Work it does not reach is named in `not_covered`. Each stop runs under a 2 s timeout (10 s for a container bash job, whose kill is a container exec that allows 3 s for SIGTERM before SIGKILL; at most 8 of those run at once and at most 32 per call), so a stuck item fails instead of holding the call. Each stopped item is then re-read until it has stopped, for at most 5 s, and reported stopped only when a re-read saw it stopped. Finally the work is listed again: anything running that was not in the first list started during the call and is reported under `started_during_call`, not stopped. At most 256 items are acted on per call, in the fixed kind order of the items' `kind` enum, then by id. When the cap is hit, `complete` is `false` and `next_cursor` names where the call stopped: send it back as `after` to continue with the items behind it. The cursor is opaque: send it back unchanged. A call with `after` does not act on items at or before the cursor; those still running are listed under `behind_cursor` and make `complete` false (call again without `after` to reach them). `complete` is `true` only when the call reached every item and nothing started meanwhile; otherwise call again. With nothing running the call answers 200 with empty lists and `complete: true`, so it is safe to repeat. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `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. | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `after` | string | No | A `next_cursor` from an earlier call, unchanged (the cursor is opaque): act only on the items after it. Any other value is 400 `bad_request`. | #### Responses ```json { "behind_cursor": [], "complete": true, "items": [ { "id": "5f0c2a91d3b44e7f", "kind": "session", "outcome": "stopped" }, { "id": "loop-3", "kind": "loop", "outcome": "stopped" } ], "not_covered": [ { "kind": "sync_request", "reason": "synchronous and streaming tool and headless requests end when their HTTP request is closed" } ], "started_during_call": [] } ``` ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters (for example, a non-opaque value in `after`). | 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": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit (default 8 MiB); split a large payload into smaller requests. | ```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 (GET /hoody/auth/status reports restriction "unknown"), so it refuses realm-scoped requests until it can. Nothing was read or changed. | Retry after the restriction is readable. | #### SDK usage ```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 }); // Stop everything running in the realm (start from the first item). const result = await client.agent.stopAllWork({}); // Continue with the items behind an earlier next_cursor. const next = await client.agent.stopAllWork({ after: result.data.next_cursor }); ``` #### cURL ```bash # Stop everything running in the realm. curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/stop" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{}' # Continue with the items behind an earlier next_cursor. curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/stop" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "after": "eyJraW5kIjoic2Vzc2lvbiIsImlkIjoiNWYwYzJhOTFkM2I0NGU3ZiJ9" }' ``` ## Sending input to a running workflow ### `POST /api/v1/agent/sessions/{id}/workflow/messages` Injects user feedback or input into a running workflow on the session. The message is forwarded to the live session's `workflowMsgChan` (not `commandChan`), so the running workflow receives it as input without being treated as a slash command. Use this only when a workflow is running on the session. #### 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). | | `X-Hoody-Realm` | header | string | No | Per-request realm selector (`global` or a 24-hex id). | | `realm` | query | string | No | Per-request realm selector (query alias of `X-Hoody-Realm`). | #### Request body | Field | Type | Required | Description | |-------|------|----------|-------------| | `text` | string | No | Feedback or input text fed to the running workflow. | #### Responses ```json { "status": "ok" } ``` When a gate is parked, the response carries `deferred: true` and a `note` explaining that the message may not have taken effect yet (it is applied only between turns). ```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. | 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 …`. | | `realm_not_allowed` | Realm not allowed for this login | The agent is logged in with a token limited to some realms (GET /hoody/auth/status lists them in realm_ids), and this session's realm is not one of them, or is the global scope (a session restored from before the login, or a realm the login was narrowed away from). The session can still be read; nothing was run. | Continue in a session of a realm the login serves, or log the agent in with a token that covers this session's realm. | ```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": "payload_too_large", "message": "request body exceeds the configured size limit" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `payload_too_large` | Payload too large | The request body exceeds the size limit (default 8 MiB). | Reduce the request body below the configured limit; split a large payload into smaller requests. | ```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. | 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. | | `restriction_unknown` | Login restriction unknown | The agent cannot read yet which realms its login may serve (GET /hoody/auth/status reports restriction "unknown"), so it runs no work in any session until it can. Nothing was run. | Retry after the restriction is readable. | | `admission_unconfirmed` | Admission unconfirmed | The message was sent but the daemon's answer did not arrive within the wait. It may still be delivered to the running workflow. | Watch the session stream before resending: a resent message replaces an undelivered one (the workflow keeps only the latest). | | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `no_active_workflow` | No active workflow | No workflow is running in this session, so there is nothing to feed the message to. It was not delivered, and it is not kept for a workflow started later. | Start a workflow (POST /sessions/{id}/workflow) and send the message while it runs. | #### SDK usage ```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.workflows.sendMessage('sess-9f2c4e1ab07d3355', { text: 'Use the staged branch for the next attempt.' }); ``` #### cURL ```bash curl -X POST "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/workflow/messages" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "text": "Use the staged branch for the next attempt." }' ``` ## Reading turn receipts ### `GET /api/v1/agent/sessions/{id}/turns` Lists the durable receipts of the session's turns, newest first. Each receipt is the same record returned by `GET /sessions/{id}/turns/{turn_id}`. This is how a client that lost its turn ids after a disconnect finds them again. Works for a live or dormant session. Pass `?limit` to cap the count (1 to 1000; the default is all retained rows). Keyed and client-identified rows are never pruned. Responses carry `Cache-Control: no-store`. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `limit` | query | integer | No | Return at most this many receipts, newest first (1 to 1000). A cap, not a page size: there is no next page. | | `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). | | `X-Hoody-Realm` | header | string | No | Per-request realm selector (`global` or a 24-hex id). | | `realm` | query | string | No | Per-request realm selector (query alias of `X-Hoody-Realm`). | #### Responses ```json { "session_id": "sess-9f2c4e1ab07d3355", "turns": [ { "turn_id": "turn-9f2c4e1ab07d3355", "job_id": "job-9f2c4e1ab07d3355", "state": "completed", "duplicate": false, "outcome": "completed", "effects_may_have_occurred": false, "accepted_at": "2025-04-12T14:32:11Z", "terminal_at": "2025-04-12T14:33:02Z", "result_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-9f2c4e1ab07d3355", "stream_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/stream", "notices": [] }, { "turn_id": "turn-3a2f1e0d9c8b7a6b", "job_id": "job-3a2f1e0d9c8b7a6b", "state": "failed", "duplicate": false, "outcome": "failed", "error_code": "no_credentials", "effects_may_have_occurred": false, "accepted_at": "2025-04-12T14:18:47Z", "terminal_at": "2025-04-12T14:19:01Z", "result_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-3a2f1e0d9c8b7a6b", "stream_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/stream", "notices": [] } ] } ``` ```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. | 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. | 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 (GET /hoody/auth/status reports restriction "unknown"), so it refuses realm-scoped requests until it can. Nothing was read or changed. | Retry after the restriction is readable. | #### SDK usage ```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 }); // Most recent 50 receipts. const recent = await client.agent.sessions.turns.list('sess-9f2c4e1ab07d3355', { limit: 50 }); const turns = recent.data.turns; // All retained receipts. const all = await client.agent.sessions.turns.list('sess-9f2c4e1ab07d3355'); ``` #### cURL ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns?limit=50" \ -H "Authorization: Bearer $HOODY_TOKEN" ``` ### `GET /api/v1/agent/sessions/{id}/turns/{turn_id}` Returns the durable record of a turn: state, terminal outcome, `error_code` once terminal, and `effects_may_have_occurred` for a turn interrupted by a restart after a tool may have acted. Works for a live or dormant session. A turn the ledger never recorded returns 404 `turn_not_found`. Responses carry `Cache-Control: no-store`. The `state` field is one of `accepted`, `dispatched`, `completed`, `failed`, `cancelled`, or `interrupted`. `outcome` and `error_code` are absent until the turn is terminal. #### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `id` | path | string | Yes | The session id. | | `turn_id` | path | string | Yes | The turn 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). | | `X-Hoody-Realm` | header | string | No | Per-request realm selector (`global` or a 24-hex id). | | `realm` | query | string | No | Per-request realm selector (query alias of `X-Hoody-Realm`). | #### Responses ```json { "turn_id": "turn-9f2c4e1ab07d3355", "job_id": "job-9f2c4e1ab07d3355", "state": "completed", "duplicate": false, "outcome": "completed", "effects_may_have_occurred": false, "accepted_at": "2025-04-12T14:32:11Z", "terminal_at": "2025-04-12T14:33:02Z", "result_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-9f2c4e1ab07d3355", "stream_url": "/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/stream", "notices": [] } ``` ```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. | 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": "turn_not_found", "message": "turn not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | | `turn_not_found` | Turn not found | No ledger row for this turn id on this session. | Use the `turn_id` from the dispatch receipt. | ```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. | 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 (GET /hoody/auth/status reports restriction "unknown"), so it refuses realm-scoped requests until it can. Nothing was read or changed. | Retry after the restriction is readable. | #### SDK usage ```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 receipt = await client.agent.sessions.turns.get('sess-9f2c4e1ab07d3355', 'turn-9f2c4e1ab07d3355'); ``` #### cURL ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/sessions/sess-9f2c4e1ab07d3355/turns/turn-9f2c4e1ab07d3355" \ -H "Authorization: Bearer $HOODY_TOKEN" ```