# Agent: Change tokens **Page:** api/agent/changes [Download Raw Markdown](./api/agent/changes.md) --- # Agent: Change tokens Change tokens are opaque per-topic hints: a short, opaque string per work list that moves whenever that list could have changed. Use them to avoid polling. The recommended client loop reads the tokens, lists the work lists, then later re-reads the tokens and re-lists only the topics whose token moved, and every topic when `scope` changes. The lists are authoritative; tokens are just hints. The first endpoint below returns one snapshot as JSON. The second opens a Server-Sent Events stream that emits a `snapshot` frame at start, `changed` frames for moved topics, and a fresh `snapshot` when `scope` changes. The SSE stream sends no `id:` lines, so after a reconnect, start again from the new snapshot and re-list the topics that differ from what you hold. Tokens are opaque. Compare a token with the one you last saw; when it differs, re-list that topic. Never parse a token, and never order tokens. Every token is computed from what the caller can see, so work in another realm or of another account never moves one. ## Read change tokens ### `GET /api/v1/agent/changes` Returns one change token per work list topic: `sessions`, `todos`, `workflow_runs`, `loops`, `tasks`, `gates`, `agents`, and `rules`. The `X-Hoody-Realm` header (or `?realm=` query alias) selects the realm: `"global"` (not tied to a realm) or a 24-hex realm id; omitted, the agent's current realm. A realm this login does not serve is `404 not_found`. An agent pinned to one realm acts only in that realm: any other value is `400 realm_scope_unsupported`. A container header is unconditionally `400 realm_scope_unsupported`. The `X-Hoody-Cwd` and `X-Hoody-Config-Dir` headers scope `sessions` and `todos` as they scope `sessions.list` and `todos.list`, and with them `tasks`, `gates`, and `rules`, which are read over those visible sessions. `rules` also covers the files `hooks.getRules` reads for the same headers. One read is shared for 1 s by the requests with the same realm and scope headers, so an answer can be up to 1 s old. After the active realm or account switches, the new `scope` arrives within that second. ### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `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`; `todos.create` 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-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. | ### Response ```json { "scope": "active", "tokens": { "sessions": "01HQZ3K4N5P6R7S8T9V0W1X2Y3", "todos": "01HQZ3K4N5P6R7S8T9V0W1X2Y4", "workflow_runs": "01HQZ3K4N5P6R7S8T9V0W1X2Y5", "loops": "01HQZ3K4N5P6R7S8T9V0W1X2Y6", "tasks": "01HQZ3K4N5P6R7S8T9V0W1X2Y7", "gates": "01HQZ3K4N5P6R7S8T9V0W1X2Y8", "agents": "01HQZ3K4N5P6R7S8T9V0W1X2Y9", "rules": "01HQZ3K4N5P6R7S8T9V0W1X2ZA" } } ``` Fields: - `scope` (string, required): names the realm and account the tokens belong to (it never carries the account). When it changes, re-list every topic. - `tokens` (object, required): one opaque token per topic. Compare a token with the one you last saw; when it differs, re-list that topic. Never parse a token. - `tokens.sessions` (string): moves when a visible session is created, closed, renamed or re-persisted (model, agent, cost), when it attaches or detaches, and when a turn starts, parks on a gate or ends. Re-list: `sessions.list`. - `tokens.todos` (string): moves on any change to a visible todo. Re-list: `todos.list` (and `getTodo` for an open one). - `tokens.workflow_runs` (string): moves when a visible run starts, steps, finishes, is cancelled or is evicted. Re-list: `workflows.listRuns` (`workflows.getRun` for one). - `tokens.loops` (string): moves when a visible loop is created, deleted, paused, resumed, fires, finishes or spends. Countdowns (`next_in_ms`, `expires_in_ms`) do not move it. Re-list: `loops.list`. - `tokens.tasks` (string): moves when a helper task of a visible live session starts, makes progress or ends. Re-list: `tasks.list` for the sessions you show. - `tokens.gates` (string): moves when a gate of a visible live session parks or resolves: the session's own confirm or question, and on a `helper_gates` session each helper's. Re-list: `gates.list` (or `sessions.getSnapshot` for one session: `pending_gate`, `pending_helper_gates`). - `tokens.agents` (string): moves when the realm's agent list changes: a create, delete, rename, model, tools or turns edit, or a source edit. Re-list: `definitions.list`. - `tokens.rules` (string): moves when a rules layer of a visible live session changes (`hooks.setRules`), or a settings file `hooks.getRules` reads for the same scope headers, with or without a live session. Re-list: `hooks.getRules` / `hooks.list` / `sessions.listApplicableRules`. - `unavailable` (object): present only when a topic could not be read; maps topic to reason. That topic has no token in this answer. ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters. | Correct the request body or query parameters. | | `realm_scope_unsupported` | Realm scope unsupported | A per-request realm header was supplied to an active-only or global-no-realm RPC, which has no realm dimension to scope. | Omit the realm header on this route, or open a session to scope by realm. | | `invalid_realm` | Invalid realm selector | The realm selector is malformed (not `""`/`"global"`/a 24-hex id). | Pass a valid realm selector. | ```json { "code": "forbidden", "message": "request must arrive through the Hoody proxy" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `forbidden` | Forbidden (Source IP Guard) | The request did not come through the program's URL. Hoody Kit programs are reached through their URLs only. | Call the agent through its URL, for example with `hoody agent …`. | ```json { "code": "not_found", "message": "resource not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | ```json { "code": "rate_limited", "message": "request rate limit exceeded" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `rate_limited` | Too many requests | The per-client request rate limit was exceeded; the gateway throttled the request before dispatch. | Honor the `Retry-After` header and retry; reduce the request rate. | ```json { "code": "internal_error", "message": "internal server error" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `internal_error` | Internal error | An unexpected error occurred while handling the request. | Retry; if persistent, inspect the daemon logs. | ```json { "code": "restriction_unknown", "message": "the login's realm restriction could not be read \u2014 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 and cURL ```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 result = await client.agent.changes.get(); ``` With the optional `X-Hoody-Cwd` header: ```typescript const result = await client.agent.changes.get({ XHoodyCwd: '/home/user/work' }); ``` ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/changes" \ -H "Authorization: Bearer $HOODY_TOKEN" ``` With the optional `X-Hoody-Cwd` header: ```bash curl -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/changes" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "X-Hoody-Cwd: /home/user/work" ``` ## Stream change tokens ### `GET /api/v1/agent/changes/stream` Server-Sent Events stream over `changes.get`. The first frame is a `snapshot` carrying `{scope, tokens}`. After it, a `changed` frame with `{topic, token}` for each topic whose token moved, and a new `snapshot` frame when `scope` changes. Tokens are re-read every 2 s (one read per scope per second is shared by every stream). There are no `id:` lines, so after a reconnect, start again from the new snapshot and re-list the topics that differ from what you hold. Heartbeat comments arrive every 25 s. The stream ends with an `end` frame carrying `{reason}` when the tokens cannot be read. The stream counts toward the per-IP stream cap (`--http-max-streams-per-ip`, default 16): a Frame tab holds this stream, the focused session's `/stream`, and a `prompt:stream` while sending. The same scoping rules apply as for `changes.get`: the `X-Hoody-Realm` header (or `?realm=` query alias) selects the realm, with a `400 realm_scope_unsupported` only when an agent is pinned to a different realm, and a container header is unconditionally `400 realm_scope_unsupported`. `X-Hoody-Cwd` and `X-Hoody-Config-Dir` scope `sessions` and `todos`, and through them `tasks`, `gates`, and `rules`. `rules` also covers the files `hooks.getRules` reads for the same headers. ### Parameters | Name | In | Type | Required | Description | |------|-----|------|----------|-------------| | `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`; `todos.create` 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-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. | ### Response The body is an SSE event stream (`text/event-stream`). Each frame is one or more `event:` and `data:` lines followed by a blank line. Heartbeats are SSE comment lines (`: heartbeat`) every 25 s. ```text event: snapshot data: {"scope":"active","tokens":{"sessions":"01HQZ3K4N5P6R7S8T9V0W1X2Y3","todos":"01HQZ3K4N5P6R7S8T9V0W1X2Y4","workflow_runs":"01HQZ3K4N5P6R7S8T9V0W1X2Y5","loops":"01HQZ3K4N5P6R7S8T9V0W1X2Y6","tasks":"01HQZ3K4N5P6R7S8T9V0W1X2Y7","gates":"01HQZ3K4N5P6R7S8T9V0W1X2Y8","agents":"01HQZ3K4N5P6R7S8T9V0W1X2Y9","rules":"01HQZ3K4N5P6R7S8T9V0W1X2ZA"}} event: changed data: {"topic":"sessions","token":"01HQZ3K4N5P6R7S8T9V0W1X2Y4"} : heartbeat event: end data: {"reason":"tokens_unreadable"} ``` Frame shapes: - `snapshot`: carries `{scope, tokens}` shaped like the `changes.get` 200 body. Sent at start and again whenever `scope` changes. - `changed`: carries `{topic, token}` for one moved topic. - `end`: carries `{reason}` when the tokens cannot be read. - Heartbeat: an SSE comment line `: heartbeat` every 25 s. ```json { "code": "bad_request", "message": "invalid request" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `bad_request` | Bad request | The request was malformed or carried invalid parameters. | Correct the request body or query parameters. | | `realm_scope_unsupported` | Realm scope unsupported | A per-request realm header was supplied to an active-only or global-no-realm RPC, which has no realm dimension to scope. | Omit the realm header on this route, or open a session to scope by realm. | | `invalid_realm` | Invalid realm selector | The realm selector is malformed (not `""`/`"global"`/a 24-hex id). | Pass a valid realm selector. | ```json { "code": "forbidden", "message": "request must arrive through the Hoody proxy" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `forbidden` | Forbidden (Source IP Guard) | The request did not come through the program's URL. Hoody Kit programs are reached through their URLs only. | Call the agent through its URL, for example with `hoody agent …`. | ```json { "code": "not_found", "message": "resource not found" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `not_found` | Not found | The requested resource does not exist. | Verify the path and identifier. | ```json { "code": "rate_limited", "message": "request rate limit exceeded" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `rate_limited` | Too many requests | The per-client request rate limit was exceeded; the gateway throttled the request before dispatch. | Honor the `Retry-After` header and retry; reduce the request rate. | ```json { "code": "internal_error", "message": "internal server error" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `internal_error` | Internal error | An unexpected error occurred while handling the request. | Retry; if persistent, inspect the daemon logs. | ```json { "code": "service_unavailable", "message": "service unavailable" } ``` | Error Code | Title | Description | Resolution | |------------|-------|-------------|------------| | `service_unavailable` | Service unavailable | The daemon could not service the request (too busy, or a per-client stream concurrency cap was hit). | Honor `Retry-After` and retry. | | `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 and cURL ```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 stream = await client.agent.changes.stream(); ``` With the optional `X-Hoody-Cwd` header: ```typescript const stream = await client.agent.changes.stream({ XHoodyCwd: '/home/user/work' }); ``` ```bash curl -N -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/changes/stream" \ -H "Authorization: Bearer $HOODY_TOKEN" ``` With the optional `X-Hoody-Cwd` header: ```bash curl -N -X GET "https://{projectId}-{containerId}-agent-1.{server}.containers.hoody.com/api/v1/agent/changes/stream" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "X-Hoody-Cwd: /home/user/work" ```