@@ -70,7 +84,7 @@ Each endpoint declares one `(resource, action)` pair. On every request the serve
### Permission reference
-The table lists every `(resource, action)` pair the server enforces. `POST /v1/ag-ui` exists only when `ag_ui.enabled` is true in `10xgraph.json`. The `/v1/evals/*` routes are mounted only when `MODE` is not `production`.
+The table lists every `(resource, action)` pair the server enforces. `POST /v1/ag-ui` exists only when `ag_ui.enabled` is true in `10xgraph.json`.
| Endpoint | Resource | Action |
| --- | --- | --- |
@@ -109,16 +123,9 @@ The table lists every `(resource, action)` pair the server enforces. `POST /v1/a
| `GET /v1/files/{id}/url` | `files` | `read` |
| `GET /v1/config/multimodal` | `config` | `read` |
| `GET /ping` | (none) | (none) |
-| `GET /v1/evals/runs` | (none) | (none) |
-| `GET /v1/evals/runs/{run_id}` | (none) | (none) |
-
-The last three rows are the complete public allowlist. Every other route must carry a `RequirePermission` guard, and the server refuses to start if one does not.
-
-
## WebSocket close codes
@@ -142,11 +149,12 @@ These are the status codes the server returns. Routes document their own specifi
| `401` | Missing, invalid or expired credentials |
| `403` | Insufficient permissions, or access to a thread you do not own |
| `404` | File, thread, message or eval run not found |
-| `409` | A run on this thread is still active after 30 seconds (`message` is `thread is busy`), or a `resume` named an `interrupt_id` that does not match the open interrupt. Standard error body with code `HTTPException`. See [One run per thread](/docs/reference/rest-api/graph#one-run-per-thread) |
+| `409` | A run on this thread is still active after 30 seconds (`message` is `thread is busy`), or a resume does not fit the thread: the `interrupt_id` does not match, the thread is not paused, or a plain message was sent to a thread paused at an `interrupt()`. The busy answer carries `Retry-After: 5`. The same status answers a repeated `Idempotency-Key` while its run is going (with `Retry-After: 5`), a finished key on AG-UI or WebSocket, and a stored thread whose node no longer exists (`GRAPH_NODE_MISSING_001`). Standard error body with code `HTTPException`. See [One run per thread](/docs/reference/rest-api/graph#one-run-per-thread) |
| `409` | Another run updated the thread while yours was in flight. Body is `{"error": "state_conflict", "detail": "...", "thread_id": ...}`. Reload the thread and retry |
| `413` | Request body over `MAX_REQUEST_SIZE` (default 10 MB), or upload over `MEDIA_MAX_SIZE_MB` (default 25) |
| `415` | Uploaded content type not allowed |
-| `422` | Request validation error (malformed body or invalid parameter), a request over the [size limits](/docs/reference/rest-api/graph) (500 messages, 100 blocks per message, 1,000,000 characters of text), or a resume answer that fails the interrupt's `response_schema` |
-| `429` | Rate limit exceeded. Body has `error.code` `RATE_LIMIT_EXCEEDED`; the `Retry-After` and `X-RateLimit-*` headers say when to retry. The same status is returned when a user has more than `runs.max_concurrent_per_user` runs in flight (default 10); that body has code `HTTPException` and no `Retry-After` |
-| `500` | Unexpected server error |
-| `503` | Checkpointer or storage temporarily unavailable |
+| `422` | Request validation error (malformed body or invalid parameter), a request over the [size limits](/docs/reference/rest-api/graph) (500 messages, 100 blocks per message, 1,000,000 characters of text), an `initial_state`, `config` or `resume` nested deeper than 64 levels or over the [size caps](#request-size-limits), an `Idempotency-Key` that is malformed or reused with a different body, an upload with no `file` part, or a resume answer that fails the interrupt's `response_schema` |
+| `429` | Rate limit exceeded. Body has `error.code` `RATE_LIMIT_EXCEEDED`; the `Retry-After` and `X-RateLimit-*` headers say when to retry. The same status is returned when a user has more than `runs.max_concurrent_per_user` runs in flight (default 10); that body has code `HTTPException` and `Retry-After: 5`. The model provider rate limiting the server also returns `429`, with code `PROVIDER_RATE_LIMITED` and a `Retry-After` taken from the provider (5 seconds if it sends none) |
+| `499` | The client closed the request while it waited for a busy thread. The response is never read |
+| `500` | Unexpected server error. Body has code `INTERNAL_SERVER_ERROR`; in production the message is generic |
+| `503` | Checkpointer or storage temporarily unavailable (code `SERVICE_UNAVAILABLE` for a database or cache driver error), with `Retry-After`. Readiness answers 503 while the server is shutting down |
diff --git a/src/content/docs/reference/rest-api/evals.md b/src/content/docs/reference/rest-api/evals.md
deleted file mode 100644
index 592f3f4..0000000
--- a/src/content/docs/reference/rest-api/evals.md
+++ /dev/null
@@ -1,163 +0,0 @@
----
-title: Eval endpoints
-seoTitle: "Eval endpoints REST API reference"
-description: Reference for the eval run listing and detail endpoints that serve 10xgraph eval reports over HTTP, including their public, unauthenticated status.
-section: Reference
-group: "REST API"
-order: 390
-label: Evals
-updated: "2026-10-08"
----
-
-The eval endpoints serve the JSON reports written by [`10xgraph eval`](/docs/reference/api-cli/commands#eval) over HTTP, so the playground's Evals inspector can browse them. They read `eval_reports/*.json` from the server's working directory; they do not run evaluations.
-
-Base path: `/v1/evals`
-
-
-
----
-
-## GET /v1/evals/runs
-
-List every run found under `eval_reports/`, newest first.
-
-**Response:**
-
-```json
-{
- "data": {
- "runs": [
- {
- "id": "eval_20260721_101500",
- "name": "Weather agent suite",
- "run": "#3",
- "rate": 91.7,
- "status": "fail",
- "cases": 12,
- "ago": "14m ago"
- }
- ]
- },
- "metadata": {"request_id": "...", "timestamp": "...", "message": "OK"}
-}
-```
-
-| Field | Type | Description |
-| --- | --- | --- |
-| `id` | string | Run id. This is the report filename without its `.json` extension, and is what you pass to the detail endpoint. |
-| `name` | string | Eval set name (falls back to the eval set id) |
-| `run` | string | Run number within this eval set, as `#N`, counted chronologically per eval set |
-| `rate` | number | Pass rate as a percentage, one decimal place |
-| `status` | string | `pass` only when the pass rate is 100 percent, otherwise `fail` |
-| `cases` | integer | Total cases in the run |
-| `ago` | string | Relative age, for example `just now`, `14m ago`, `3h ago`, `2d ago` |
-
-Reports that are missing, unreadable, or not valid JSON are skipped silently. When `eval_reports/` does not exist the list is empty.
-
----
-
-## GET /v1/evals/runs/`{run_id}`
-
-Full drilldown for one run.
-
-**Path parameters:**
-
-| Parameter | Type | Description |
-| --- | --- | --- |
-| `run_id` | string | Run id from the listing. An unknown id returns `404`. |
-
-**Response:**
-
-```json
-{
- "data": {
- "title": "Weather agent suite · run #3",
- "sub": "weather-suite · gpt-4o-mini · 2026-07-21 10:15 · 42.3s",
- "rate": 91.7,
- "status": "fail",
- "threshold": 80,
- "stats": [
- {"label": "cases", "value": "12"},
- {"label": "passed", "value": "11", "tone": "ok"},
- {"label": "failed", "value": "1", "tone": "bad"},
- {"label": "avg score", "value": "0.93"},
- {"label": "avg latency", "value": "3.5s"},
- {"label": "total tokens", "value": "18.4k tok"}
- ],
- "cases": [
- {
- "id": "case-paris",
- "name": "Weather in Paris",
- "type": "eval",
- "score": 0.96,
- "status": "pass",
- "lat": "3.1s",
- "cost": "1.4k tok",
- "input": "What is the weather in Paris?",
- "expected": "\u2014",
- "actual": "The weather in Paris is 24 degrees and sunny.",
- "rubric": [
- {"key": "accuracy", "value": 0.96, "tone": "accent"},
- {"key": "weighted score", "value": 0.96, "tone": "accent"}
- ],
- "conversation": null
- }
- ],
- "regression": null
- },
- "metadata": {"request_id": "...", "timestamp": "...", "message": "OK"}
-}
-```
-
-| Field | Type | Description |
-| --- | --- | --- |
-| `title` | string | Eval set label plus run number |
-| `sub` | string | Eval set id, model (when recorded), run timestamp in UTC, and total duration, joined with `·` |
-| `rate` | number | Pass rate as a percentage |
-| `status` | string | `pass` or `fail` |
-| `threshold` | integer | Mean of the distinct criterion thresholds recorded in the report, as a rounded percentage. Defaults to `80` when no thresholds are recorded. |
-| `stats` | array | Summary tiles: cases, passed, failed (failures plus errors), average score, average latency, total tokens |
-| `cases` | array | One entry per case, see below |
-| `regression` | object or `null` | Comparison against the previous run of the same eval set, or `null` when this is the first run of that set |
-
-### Case
-
-| Field | Type | Description |
-| --- | --- | --- |
-| `id` | string | Eval case id |
-| `name` | string | Case name (falls back to the id) |
-| `type` | string | `sim` for a user-simulation case (the case carries `turns` metadata), otherwise `eval` |
-| `score` | number | Mean criterion score, rounded to two decimals. Falls back to `1.0`/`0.0` from the pass flag when the case has no criteria. |
-| `status` | string | `pass` or `fail` |
-| `lat` | string | Case duration, for example `3.1s` |
-| `cost` | string | Token total, for example `1.4k tok`. Token counts only; the report records no dollar cost. |
-| `input` | string | First user message in the case, or a single em dash placeholder character when there is none |
-| `expected` | string | Always a single em dash placeholder character; the report schema records no per-case expected value |
-| `actual` | string | The agent's response, or the em dash placeholder when empty |
-| `rubric` | array or `null` | One row per criterion plus a `weighted score` row. `null` when the case has no criteria. |
-| `conversation` | array or `null` | Parsed turns for `sim` cases, each `{role, text}` with role `sim` or `agent`. `null` for `eval` cases. |
-
-### Regression
-
-Present only when an earlier run of the same eval set exists. It compares case-by-case against the immediately preceding run.
-
-| Field | Type | Description |
-| --- | --- | --- |
-| `note` | object | Which two runs are being compared, as `current`, `prev` and `suite` strings |
-| `summary` | array | Pass-rate drift in percentage points, newly failing count, newly passing count, average score drift |
-| `rows` | array | One row per case: `name`, `delta` (previous and current score with an arrow), `dir` (`up`, `down`, `flat`), `flip` (for example `pass → fail`), and `stay` (true when pass/fail did not change) |
-
-Cases that exist in only one of the two runs are omitted from `rows`.
-
----
-
-## See also
-
-- [`10xgraph eval`](/docs/reference/api-cli/commands#eval)
-- [Run evals](/docs/testing/run-evals)
diff --git a/src/content/docs/reference/rest-api/files.md b/src/content/docs/reference/rest-api/files.md
index 0734bc5..42dda78 100644
--- a/src/content/docs/reference/rest-api/files.md
+++ b/src/content/docs/reference/rest-api/files.md
@@ -15,6 +15,7 @@ Base path: `/v1/files`. Every route needs authentication when `auth` is configur
| Method and path | Permission | Purpose |
| --- | --- | --- |
| `POST /v1/files/upload` | `files:upload` | Upload a file, receive a `file_id`. |
+| `GET /v1/files` | `files:read` | List your files, newest first. |
| `GET /v1/files/{file_id}` | `files:read` | Download the raw bytes. |
| `GET /v1/files/{file_id}/info` | `files:read` | Read metadata only. |
| `GET /v1/files/{file_id}/url` | `files:read` | Get a direct access URL. |
@@ -90,6 +91,21 @@ The uploader is recorded as the file's owner. Every read path checks it, and a f
---
+## GET /v1/files
+
+Lists the files the caller uploaded, newest first. Files with no recorded owner are not listed. With auth off there is no user, so every file is listed.
+
+| Query parameter | Default | Description |
+| --- | --- | --- |
+| `limit` | `50` | Page size, 1 to 200. |
+| `offset` | `0` | Files to skip. |
+
+The response `data` has `files` (each with `file_id`, `filename`, `mime_type`, `size_bytes`, `url`, `created_at`), `has_more`, `offset` and `limit`.
+
+It works on every store, cloud (S3, GCS) included. A cloud store keeps a per-owner index, so files stored before `10xgraph` 1.0, or stored with no owner, are not listed. Listing with auth off on a cloud store, and any custom store that cannot enumerate its keys, answers `501`. The same index serves the per-user quotas and the release of a thread's files when the thread is deleted.
+
+---
+
## GET /v1/files/{file_id}
Returns the raw bytes of a stored file with its recorded `Content-Type`. Types that could run script in a browser (HTML, XML, SVG, JavaScript) are served as `application/octet-stream` downloads with `Content-Disposition: attachment`, and every response carries `X-Content-Type-Options: nosniff`.
@@ -200,7 +216,8 @@ Uploads fail with `400`, `413` or `415`; reads fail with `404`. A missing permis
| Status | Description |
| --- | --- |
| `400` | Missing filename or empty file. |
-| `413` | File exceeds `MEDIA_MAX_SIZE_MB` (or the media service rejects its size). The limit is checked as data arrives in 1 MiB chunks, so uploads are rejected before the whole file is buffered. |
+| `422` | The multipart body has no `file` part. Auth is checked before the body is parsed, so an unauthenticated request never gets this far. |
+| `413` | File exceeds `MEDIA_MAX_SIZE_MB` (or the media service rejects its size). The limit is checked as data arrives in 1 MiB chunks, so uploads are rejected before the whole file is buffered. The same status answers an image over `MEDIA_MAX_IMAGE_PIXELS`, a PDF over `MEDIA_MAX_PDF_MB` or `MEDIA_MAX_PDF_PAGES`, and an upload past the per-user quota (`MEDIA_MAX_FILES_PER_USER`, `MEDIA_MAX_USER_STORAGE_MB`). |
| `415` | Content type not allowed by `MEDIA_ALLOWED_CONTENT_TYPES`. |
| `404` | `file_id` not found or owned by another user. |
diff --git a/src/content/docs/reference/rest-api/graph.md b/src/content/docs/reference/rest-api/graph.md
index 7bc3e6f..aec0c1a 100644
--- a/src/content/docs/reference/rest-api/graph.md
+++ b/src/content/docs/reference/rest-api/graph.md
@@ -43,24 +43,23 @@ Run the graph and return the complete result when all nodes finish.
"messages": [
{
"role": "user",
- "content": "What is the capital of France?"
+ "content": [{"type": "text", "text": "What is the capital of France?"}]
}
- ],
- "config": {
- "thread_id": "my-thread-1"
- }
+ ]
}
```
+`content` is a list of content blocks, not a plain string. A string gets `422`. The example sends no `config.thread_id`, so the server starts a new thread and returns its id in `data.meta.thread_id`. Send that id as `config.thread_id` in the next request to continue the thread. With the default `runs.thread_ids` of `server`, naming an id that does not exist answers `403`.
+
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `messages` | array | yes, unless `resume` is set | One or more messages to append to state before invoking. |
-| `resume` | any | no | Answer for a thread paused by [`interrupt()`](/docs/guides/add-human-approval). The paused node runs again and `interrupt()` returns this value; `null` means cancelled. Send it only to resume a paused thread. |
+| `resume` | any | no | Answer for a thread paused by [`interrupt()`](/docs/guides/add-human-approval). The paused node runs again and `interrupt()` returns this value; `null` means cancelled. Send it only to resume a paused thread. Nested no deeper than 64 levels. |
| `interrupt_id` | string | no | The `id` of the interrupt `resume` answers. Take it from `execution_meta.interrupt_data.interrupt.id` in the thread state. If the thread is not paused, or is paused at a different interrupt, the request gets `409` before anything runs. Ignored without `resume`. |
| `on_busy` | string | no | `wait` (default) or `cancel`. What to do when another run is active on the thread (see [One run per thread](#one-run-per-thread)). |
-| `initial_state` | object | no | Initial state for the run. Custom fields and `current_node` are allowed; built-in fields are not (see input rules below). |
-| `config` | object | no | Run configuration |
-| `config.thread_id` | string | no | Id of an existing thread to continue. When absent or blank, the server starts a new thread, generates its id and persists it; the id comes back in `data.meta.thread_id`. With the default `runs.thread_ids` of `server`, an id that does not exist answers `403`. See [`runs.thread_ids`](/docs/reference/api-cli/configuration#thread_ids). |
+| `initial_state` | object | no | Initial state for the run. Custom fields and `current_node` are allowed; built-in fields are not (see input rules below). Nested no deeper than 64 levels. |
+| `config` | object | no | Run configuration. Nested no deeper than 64 levels. |
+| `config.thread_id` | string | no | Id of an existing thread to continue. Must match `^[A-Za-z0-9._:@-]{1,128}$`, else `422`. When absent or blank, the server starts a new thread, generates its id and persists it; the id comes back in `data.meta.thread_id`. With the default `runs.thread_ids` of `server`, an id that does not exist answers `403`. See [`runs.thread_ids`](/docs/reference/api-cli/configuration#thread_ids). |
| `config.remote_tools` | - | - | Server-owned: dropped from client config, along with `user`, `user_id`, `authz`, `collection` and `_`-prefixed keys. A client cannot choose a memory collection; it is ignored, not refused. See [Collections](/docs/concepts/memory-and-store#collections-keeping-data-apart). |
| `recursion_limit` | integer | no | Maximum graph iterations. Default `25`, range `1`-`100`. |
| `response_granularity` | string | no | `full`, `partial`, or `low`. Default `low`. |
@@ -72,6 +71,7 @@ Run the graph and return the complete result when all nodes finish.
- `context_summary` or `execution_meta` in `initial_state`. Clients can never write these built-in fields.
- A `current_node` in `initial_state` that is not a node in the graph.
- A state value whose type does not match the state field. The error names the field.
+- An `initial_state`, `config` or `resume` value nested deeper than 64 levels. The answer is `422` with code `VALIDATION_ERROR`.
Custom state fields are allowed. If the operator set `runs.state_keys` in `10xgraph.json`, only the listed keys are accepted. The server replaces any `message_id` you send with its own. The same rules apply to `POST /v1/graph/stream` and the WebSocket.
@@ -103,7 +103,7 @@ Messages may carry image and document content blocks referencing an uploaded `fi
### One run per thread
-The server runs one graph run at a time on a thread. A second run on a busy thread waits up to 30 seconds for the first to finish, then the request is rejected with `409` in the standard error body:
+The server runs one graph run at a time on a thread. A second run on a busy thread waits up to 30 seconds for the first to finish, then the request is rejected with `409` in the standard error body and a `Retry-After: 5` header:
```json
{
@@ -116,14 +116,35 @@ On invoke and stream, this answer comes before the response starts. A new thread
Set `on_busy` to `"cancel"` to stop the running run (the same mechanism as [`POST /v1/graph/stop`](#post-v1graphstop)) and start the new one. The 30-second wait limit and the `409` still apply if the old run does not stop in time.
+On invoke, a run that passes its recursion limit (`GraphRecursionError`) answers `422`, not `500`.
+
+### Idempotency keys
+
+Send an `Idempotency-Key` header (1 to 255 printable ASCII characters, else `422`) on `POST /v1/graph/invoke`, `POST /v1/graph/stream` and [`POST /v1/ag-ui`](/docs/reference/rest-api/ag-ui), or `idempotency_key` in the [WebSocket](#ws-v1graphws) run frame, so a gateway retry after a timeout does not run the tools twice. The header is opt-in: a request without it behaves as before. The key is scoped to the authenticated user.
+
+| Situation | Answer |
+| --- | --- |
+| First request with the key | Runs normally. |
+| Same key while that run is still going | `409` with `Retry-After: 5`. |
+| Same key after the run finished, invoke | The stored result, as the first call returned it. |
+| Same key after the run finished, stream | The final state line plus the `done` line, replayed as NDJSON. |
+| Same key after the run finished, AG-UI and WebSocket | `409`. Their event output is not stored. |
+| Same key with a different body | `422`. |
+| The first run failed | Nothing is stored, so the key can be retried. |
+| A result over 1 MiB | Not stored; later requests with the key answer `409`. |
+
+A finished run is remembered for `IDEMPOTENCY_TTL_SECONDS` (default 86400). Records live in Redis when it is configured, otherwise in the process, so without Redis they are per worker. If Redis cannot be reached the request runs without idempotency and a warning is logged.
+
### Resume answers
-A request with `resume` can fail two ways, both before the graph runs:
+A resume that does not fit the thread fails before the graph runs:
| Status | Cause |
| --- | --- |
-| `409` | `interrupt_id` was sent and the thread is not paused, or is paused at a different interrupt. |
-| `422` | The answer fails the interrupt's `response_schema`. |
+| `409` | `interrupt_id` was sent and the thread is not paused, or is paused at a different interrupt (`INTERRUPT_001`). |
+| `409` | `resume` was sent to a thread that is not paused at an `interrupt()` (`INTERRUPT_003`, `NothingToResumeError`). |
+| `409` | A plain new message was sent to a thread that is paused at an `interrupt()` (`INTERRUPT_004`, `InterruptPendingError`). Resume it with `resume`. |
+| `422` | The answer fails the interrupt's `response_schema` (`INTERRUPT_002`). |
The schema check supports `type`, `enum`, `properties`, `required` and `items`. Other keywords are ignored. A `null` answer (cancel) is never checked. See [Interrupts](/docs/concepts/interrupts#bind-a-resume-to-its-interrupt).
@@ -135,13 +156,11 @@ Run the graph and stream one JSON-encoded `StreamChunk` per line.
**Request body:** Same as `/v1/graph/invoke`, including `resume`, `interrupt_id`, `on_busy` and the size limits.
-A busy thread (`409`) and an `interrupt_id` mismatch (`409`) are answered before the stream starts, as a normal HTTP error. Failures once the stream is running arrive as an `error` chunk. With `MODE=production`, the text in an `error` chunk's `data` (`reason`, `error` or `message`) is replaced by the same generic message invoke uses, such as `An error occurred in a graph node.`; development shows the details. The built-in text for an unexpected tool failure (`Tool '' failed with an internal error (reference ).`) is kept, because it holds no detail.
+A repeated `Idempotency-Key` is answered the same way (see [Idempotency keys](#idempotency-keys)). A busy thread (`409`, with `Retry-After: 5`), a run over the per-user cap (`429`, with `Retry-After: 5`), a resume that does not fit the thread (`409`) and a client that left while waiting for a busy thread (`499`, never read) are answered before the stream starts, as a normal HTTP error. Failures once the stream is running arrive as an `error` chunk. With `MODE=production`, the text in an `error` chunk's `data` (`reason`, `error` or `message`) is replaced by the same generic message invoke uses, such as `An error occurred in a graph node.`; development shows the details. The built-in text for an unexpected tool failure (`Tool '' failed with an internal error (reference ).`) is kept, because it holds no detail.
-**Response:** `Content-Type: text/event-stream`
+**Response:** `Content-Type: application/x-ndjson`
-Despite the content type, the body is **not** SSE-framed. There are no `data:` prefixes and no
-blank-line separators. It is newline-delimited JSON, so split the body on newlines rather than
-using `EventSource` or an SSE client library.
+The body is newline-delimited JSON, not SSE. There are no `data:` prefixes. Split the body on newlines rather than using `EventSource` or an SSE client library. While the run is silent, the server sends a blank line every 15 seconds so a proxy's idle timeout does not cut the connection. Skip empty lines.
```
{"event": "message", "message": {"role": "assistant", "content": [{"type": "text", "text": "The capital"}]}, "thread_id": "t1", ...}
@@ -163,7 +182,11 @@ using `EventSource` or an SSE client library.
| `timestamp` | `number` | UNIX timestamp of chunk creation. |
Tool activity arrives as ordinary `message` chunks whose `content` carries `tool_call` and
-`tool_result` blocks. There are no separate `tool_call` or `done` event types.
+`tool_result` blocks. There is no separate `tool_call` event type.
+
+A run that is refused because the graph is closing ends with a terminal `error` chunk whose `data` has `code: "SERVICE_UNAVAILABLE"`, a `reason` and `retry_after_seconds` (the same hint as the HTTP 503 `Retry-After`). No `done` chunk follows. Treat it as "retry on another replica", not as a failed run.
+
+A stream that finishes without an error ends with a terminal `updates` chunk whose `data` is `{"status": "done"}`. A client that waits for it can tell a finished stream from a cut one. If the server shuts down while the stream is open, the stream ends with a final chunk that says the run was interrupted and that the thread is saved: send a new request on the same `thread_id` to continue.
---
@@ -349,7 +372,7 @@ Turn-based streaming over a WebSocket. Same payloads as `POST /v1/graph/stream`,
}
```
-`messages` must be non-empty, unless the frame carries `resume`. `config.thread_id` is optional; omit it, or send `"new"`, to start a new thread. The server picks the id and sends it on each chunk. A `thread_id` that does not exist gets an `error` chunk, the same as an unauthorized one, unless `runs.thread_ids` is `client`.
+`messages` must be non-empty, unless the frame carries `resume`. A frame may carry `idempotency_key` (see [Idempotency keys](#idempotency-keys)); a repeat of the key (while the run is going or after it finished) or a different body with the same key gets an `error` chunk, and the connection stays open. `config.thread_id` is optional; omit it, or send `"new"`, to start a new thread. The server picks the id and sends it on each chunk. A `thread_id` that does not exist gets an `error` chunk, the same as an unauthorized one, unless `runs.thread_ids` is `client`.
**Answer an `interrupt()` pause.** A `fresh` frame accepts `resume` (any JSON value, `null` to cancel) and an optional `interrupt_id`, with the same meaning as on invoke. No `messages` are needed:
diff --git a/src/content/docs/reference/rest-api/live.md b/src/content/docs/reference/rest-api/live.md
index 58c09cf..4feee95 100644
--- a/src/content/docs/reference/rest-api/live.md
+++ b/src/content/docs/reference/rest-api/live.md
@@ -61,7 +61,7 @@ WebSocket handshakes bypass the HTTP rate-limit and request-size middleware (Sta
"websocket": {
"max_connections": 100,
"max_connections_per_user": 5,
- "realtime_models": ["gemini-live-2.5-flash-preview"]
+ "realtime_models": ["gemini-3.8-live"]
}
}
```
@@ -74,7 +74,7 @@ A missing key gets the default above. Set a key to `0` or `null` for unlimited.
1. Client opens the WebSocket connection.
2. Client sends a JSON init frame as the first message.
-3. Server starts the realtime session and begins pumping events.
+3. Server starts the realtime session. Its first event is `session_started`, which carries the thread id. Then it begins pumping events.
4. Client sends upstream frames (audio binary, control JSON) throughout the session.
5. Server sends downstream frames (audio binary, event JSON) as the model generates.
6. Either side closes the connection to end the session.
@@ -88,7 +88,7 @@ The first frame from the client must be a JSON text frame. It configures the ses
```json title="init frame"
{
"thread_id": "session-001",
- "model": "gemini-live-2.5-flash-preview",
+ "model": "gemini-3.8-live",
"voice": "Puck",
"modalities": ["AUDIO"],
"system_prompt": "You are a terse voice assistant.",
@@ -112,7 +112,7 @@ The first frame from the client must be a JSON text frame. It configures the ses
Fields not present in the init frame keep the agent's compiled values. An invalid init frame (not JSON, or JSON that is not an object) closes the socket with code `1003`.
-**Thread handling.** `thread_id` is optional. When it is absent, blank or `"new"` the server generates an id for the session and persists the thread record before the first event is pumped; the generated id is not echoed back as a separate frame, so a client that needs to resume later should supply its own id. `/v1/graph/live` accepts a client-made id even when `runs.thread_ids` is `server`, because the realtime client makes its own. The caller then owns that thread. When `thread_id` **is** present it is ownership-checked before the session starts (only when auth is configured): a thread owned by another user is rejected with a fatal `error` event (`code: "not_authorized"`) and close code `1008`.
+**Thread handling.** `thread_id` is optional. When it is absent, blank or `"new"` the server generates an id for the session and persists the thread record before the first event is pumped; the generated id is sent in the `session_started` event, so a client that needs to resume later keeps it and sends it back. With the default `runs.thread_ids` of `server`, an id the server never issued is refused like a thread owned by someone else. Set `runs.thread_ids` to `client` to let the caller name a new thread. When `thread_id` **is** present it is ownership-checked before the session starts (only when auth is configured): a thread owned by another user is rejected with a fatal `error` event (`code: "not_authorized"`) and close code `1008`.
---
@@ -194,6 +194,14 @@ Format: 16-bit signed integer, little-endian, mono, 24000 Hz
All `RealtimeEvent` types other than `audio_delta` are serialized to JSON text frames. The `type` field discriminates the event.
+**Session started**
+
+```json
+{"type": "session_started", "thread_id": "01J9..."}
+```
+
+The first server event of every session. `thread_id` is the id from the init frame, or the one the server created when the init frame had none (absent, blank or `"new"`). Keep it and send it as `thread_id` in the init frame of a reconnect to continue the thread.
+
**Input transcript (partial)**
```json
@@ -295,7 +303,7 @@ Codes the bridge itself emits, in addition to any provider code passed through:
| `code` | `fatal` | Meaning | Followed by close |
|---|---|---|---|
| `not_live` | `true` | The configured graph is not a live agent. Use `WS /v1/graph/ws`. | `1008` |
-| `not_authorized` | `true` | The `thread_id` in the init frame belongs to another user. | `1008` |
+| `not_authorized` | `true` | The `thread_id` in the init frame belongs to another user, or (with `runs.thread_ids: server`, the default) was never issued by the server. | `1008` |
| `session_expired` | `true` | The token expired or was revoked during the session. Reconnect with a fresh token. | `1008` |
| `invalid_config` | `true` | The init frame produced an invalid `RealtimeConfig` (for example an unusable `modalities` value). | server close |
diff --git a/src/content/docs/reference/rest-api/memory-store.md b/src/content/docs/reference/rest-api/memory-store.md
index 28c681f..31ffe41 100644
--- a/src/content/docs/reference/rest-api/memory-store.md
+++ b/src/content/docs/reference/rest-api/memory-store.md
@@ -14,6 +14,8 @@ Base path: `/v1/store`
## Request fields shared by every endpoint
+The `memory_id` path parameter must match `^[A-Za-z0-9._:@-]{1,128}$` (letters, digits and `. _ : @ -`). Any other value gets `422` with code `VALIDATION_ERROR`.
+
Every request body accepts two optional fields besides the endpoint-specific ones, and the server decides whose memories a request touches from the authenticated user, not from the body.
| Field | Type | Description |
@@ -168,6 +170,7 @@ This is a `POST` endpoint (not `GET`) because the request carries a `config` obj
```json
{
"limit": 50,
+ "offset": 0,
"config": {}
}
```
@@ -175,6 +178,7 @@ This is a `POST` endpoint (not `GET`) because the request carries a `config` obj
| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `limit` | integer | no | `100` | Maximum memories to return; must be between 1 and 1000 |
+| `offset` | integer | no | `0` | Memories to skip, for paging. Send `next_offset` from the previous response. |
**Response (200 OK):**
@@ -192,7 +196,9 @@ This is a `POST` endpoint (not `GET`) because the request carries a `config` obj
"thread_id": null,
"timestamp": "2026-10-08T10:30:00Z"
}
- ]
+ ],
+ "has_more": false,
+ "next_offset": null
},
"metadata": {
"request_id": "req_xyz",
@@ -202,11 +208,13 @@ This is a `POST` endpoint (not `GET`) because the request carries a `config` obj
}
```
+`has_more` is `true` when more memories follow this page. Then `next_offset` holds the `offset` to send next; on the last page it is `null`.
+
**Error codes:**
- `401` Unauthorized: missing or invalid auth token
- `403` Forbidden: insufficient permission (not `store:read`)
-- `422` Unprocessable Entity: `limit` is outside the 1 to 1000 range
+- `422` Unprocessable Entity: `limit` is outside the 1 to 1000 range, or `offset` is negative or above the server maximum
**Example:**
diff --git a/src/content/docs/reference/rest-api/observability.md b/src/content/docs/reference/rest-api/observability.md
index bedcaf7..75fc7d8 100644
--- a/src/content/docs/reference/rest-api/observability.md
+++ b/src/content/docs/reference/rest-api/observability.md
@@ -8,7 +8,7 @@ label: Observability
updated: "2026-10-08"
---
-`GET /v1/observability/{thread_id}` returns a reconstructed trace for a run on a thread: a span tree, a flat event list, aggregated token usage, and call counts. It reads from an in-memory store that is only available outside production, so use it for development and the playground, not for production tracing.
+`GET /v1/observability/{thread_id}` returns a reconstructed trace for a run on a thread: a span tree, a flat event list, aggregated token usage, and call counts. It reads from an in-memory store that is only available outside production, so use it for development and the playground, not for production tracing. It is turned off in production mode: nothing is recorded and the response carries `enabled: false`.
| Property | Value |
| --- | --- |
@@ -189,7 +189,7 @@ Events are returned **newest first**, so a UI can render the list without revers
## Availability
-Traces come from an in-process telemetry store that records chunks as runs execute. It is bound **only outside production**: when `MODE=production` the store is not created and this endpoint returns an empty payload (`run_count: 0`, `run: null`) rather than an error.
+Traces come from an in-process telemetry store that records chunks as runs execute. It is bound **only outside production**: when `MODE=production` the store is never created, nothing is recorded, and this endpoint returns an empty payload (`run_count: 0`, `run: null`, `enabled: false`, `reason: "disabled_in_production"`) rather than an error. The other `reason` values are `store_unavailable` and `no_runs`.
The store is in-memory and not durable, and a restart clears it. It keeps at most 200 threads, 20 runs per thread, and 2000 records per run, dropping the oldest first. Deleting a thread through the threads API also clears its traces. It is a development and playground aid, not a production observability system. For production tracing, configure OpenTelemetry (`OTEL_ENABLED`) or the `observability` block in `10xgraph.json`.
@@ -197,7 +197,7 @@ Both `POST /v1/graph/invoke` and `POST /v1/graph/stream` record runs. Invoke has
### Errors and empty results
-An unknown thread or an unknown `run_id` is not an error: the endpoint returns 200 with `run: null` (and `run_count` and `run_ids` for any runs that do exist). A missing or invalid token returns 401, and a token without `graph:read` returns 403.
+An unknown thread or an unknown `run_id` is not an error: the endpoint returns 200 with `run: null` (and `run_count` and `run_ids` for any runs that do exist). A missing or invalid token returns 401, and a token without `graph:read` returns 403. With auth on, the thread id also goes to the authorization backend, so by default a user can read traces only for their own threads.
---
diff --git a/src/content/docs/reference/rest-api/ping.md b/src/content/docs/reference/rest-api/ping.md
index 35b3419..1bfcd64 100644
--- a/src/content/docs/reference/rest-api/ping.md
+++ b/src/content/docs/reference/rest-api/ping.md
@@ -63,7 +63,10 @@ curl -i http://127.0.0.1:8000/health/ready
| Status | Meaning |
| --- | --- |
| `200` | Every configured component is reachable. `status` is `ready`. |
-| `503` | At least one check is `fail`. `status` is `not_ready`. |
+| `503` | At least one check is `fail`. `status` is `not_ready`. The checkpointer check fails when the database schema version is below what the code expects (an upgrade whose migration has not run). |
+| `503` | The server is shutting down. `status` is `draining` and `checks` is empty. No component is checked. |
+
+Once the process gets SIGTERM, the server marks itself as draining. Readiness answers `503` from then on, so the load balancer stops sending new requests while open runs finish. `/ping` stays `200` until the process exits.
The response is a plain JSON object, not the standard `data` and `metadata` envelope.
diff --git a/src/content/docs/reference/rest-api/threads.md b/src/content/docs/reference/rest-api/threads.md
index e2f0ecd..3d4861c 100644
--- a/src/content/docs/reference/rest-api/threads.md
+++ b/src/content/docs/reference/rest-api/threads.md
@@ -110,7 +110,21 @@ curl -X DELETE http://localhost:8000/v1/threads/t1 \
{"success": true, "message": "Thread deleted successfully", "data": null}
```
-The inner `data` is whatever the checkpointer returns from its clean-up. The server also deletes the thread's telemetry traces when a telemetry store is bound, and evicts the thread's cached ownership entry, so the `thread_id` can be reused by a different user afterwards.
+If file, trace or ownership-cache cleanup fails after the thread is deleted, the answer is `success: false` with the message `Thread deleted, but cleanup did not finish: ` and `data` like this. The thread itself is gone. A delete also releases the files linked to that thread by their own metadata, even when no message references them (local and memory media stores; the cloud store cannot list its files, so it is skipped). A file left with no thread is deleted only with `media.delete_with_thread` on, which is the default.
+
+```json
+{"success": false, "message": "Thread deleted, but cleanup did not finish: cache", "data": {"deleted": true, "cleanup_failed": ["cache"], "result": {}}}
+```
+
+`cleanup_failed` lists the steps that failed. Check `success` before you treat the delete as fully clean. On success, the inner `data` is whatever the checkpointer returns from its clean-up. The server also deletes the thread's telemetry traces when a telemetry store is bound, and evicts the thread's cached ownership entry, so the `thread_id` can be reused by a different user afterwards.
+
+## Thread and message ids
+
+Thread, message and memory ids must match `^[A-Za-z0-9._:@-]{1,128}$`: 1 to 128 letters, digits and `. _ : @ -`, with no spaces or slashes. Other ids in the path or body get `422` `VALIDATION_ERROR`. See [Conventions](/docs/reference/rest-api/conventions#resource-ids).
+
+## Writes while a run is active
+
+These routes answer `409` (code `HTTPException`, message `thread is busy`, `Retry-After: 5`) at once while a run holds the thread, instead of waiting: `PUT /v1/threads/{thread_id}/state`, `DELETE /v1/threads/{thread_id}/state`, `POST /v1/threads/{thread_id}/messages`, `DELETE /v1/threads/{thread_id}/messages/{message_id}`, `DELETE /v1/threads/{thread_id}` and `POST /v1/graph/fix`. A write that raced a run would be overwritten by it, or would bring a deleted thread back. Retry after the run ends, or stop it with `POST /v1/graph/stop`.
## Get thread state
diff --git a/src/content/docs/server/ag-ui.md b/src/content/docs/server/ag-ui.md
index a7ebe7c..cde15a7 100644
--- a/src/content/docs/server/ag-ui.md
+++ b/src/content/docs/server/ag-ui.md
@@ -101,7 +101,7 @@ The AG-UI endpoint is mounted whenever enabled, regardless of `MODE`. Treat it w
### Persistent checkpointing
-AG-UI clients identify the conversation by `threadId`, which the server maps to a 10xGraph thread. AG-UI accepts a `threadId` the frontend made, even when `runs.thread_ids` is `"server"`, because CopilotKit creates its own. The caller owns a new thread, and an existing thread is owner-checked as usual. The server reads the checkpoint to determine which messages are new and only sends those to the graph. With an in-memory checkpointer:
+AG-UI clients identify the conversation by `threadId`, which the server maps to a 10xGraph thread. AG-UI follows `runs.thread_ids` like REST. With the default `"server"`, a `threadId` the server never issued gets `403`; send a blank or `"new"` `threadId` to have the server create the thread, and read the id from `RUN_STARTED`. CopilotKit makes its own ids by default, so a CopilotKit frontend needs `"runs": {"thread_ids": "client"}` in `10xgraph.json`. An existing thread is owner-checked as usual. The server reads the checkpoint to determine which messages are new and only sends those to the graph. With an in-memory checkpointer:
- Threads are lost when the server restarts
- State is not shared across replicas
diff --git a/src/content/docs/server/auth.mdx b/src/content/docs/server/auth.mdx
index ede9227..bb38a37 100644
--- a/src/content/docs/server/auth.mdx
+++ b/src/content/docs/server/auth.mdx
@@ -45,7 +45,7 @@ JWT is the easiest path: a stateless, standard bearer token that carries the use
JWT_ALGORITHM=HS256
```
- The server refuses a short HS* secret at startup when `MODE=production`, and warns otherwise. RFC 7518 requires HMAC secrets to be at least 32 bytes for HS256.
+ `JWT_ALGORITHM` must be one of `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512` or `EdDSA`. `none`, an empty value or an unknown value stops startup. Startup also stops if PyJWT is not installed, and the `auth` value must be exactly `"jwt"`. The server refuses a short HS* secret at startup when `MODE=production`, and warns otherwise. RFC 7518 requires HMAC secrets to be at least 32 bytes for HS256.
3. **Enable JWT in 10xgraph.json**
@@ -73,7 +73,7 @@ JWT is the easiest path: a stateless, standard bearer token that carries the use
-Anyone who holds `JWT_SECRET_KEY` can mint a token for any `user_id`. Never commit it, never bake it into a container image, and inject it at deploy time from a secret manager. Use HTTPS everywhere and rotate secrets intentionally with all issuers and consumers in sync.
+Anyone who holds `JWT_SECRET_KEY` can mint a token for any `user_id`. Never commit it, never bake it into a container image, and inject it at deploy time from a secret manager. Use HTTPS everywhere. To rotate without logging everyone out, list the old key in `JWT_SECRET_KEYS` while new tokens use `JWT_SECRET_KEY` (see [Rotating the signing key](/docs/reference/api-cli/auth#rotating-the-signing-key)). `JWT_MAX_TOKEN_AGE_SECONDS` caps how old a token may be.
@@ -249,19 +249,14 @@ RuntimeError: Refusing to start: the following routes are not protected:
This is intentional: a forgotten guard becomes a deploy-time error, not a silent open endpoint.
-### Eval endpoints are unauthenticated
-
-`/v1/evals/runs` and `/v1/evals/runs/{run_id}` serve eval results without auth. For that reason they are **not mounted when `MODE=production`**, and the generated `.dockerignore` keeps `eval_reports/` out of the image. On any other deployment, block these routes at your ingress or delete the eval files before serving.
-
## Production recommendations
1. Always set `"auth"` in production; never leave it `null`
2. Use HTTPS everywhere
3. Keep `JWT_SECRET_KEY` outside version control; inject it at deploy time
4. Use short-lived tokens and rotate secrets intentionally
-5. Block or remove the eval endpoints on public deployments
-6. Set `MODE=production` to disable `/docs` and `/redoc` (enable only if intentional)
-7. Test both successful and rejected requests before release, asserting on the error code
+5. Set `MODE=production` to disable `/docs` and `/redocs` (enable only if intentional)
+6. Test both successful and rejected requests before release, asserting on the error code
## See also
diff --git a/src/content/docs/server/backup-and-restore.md b/src/content/docs/server/backup-and-restore.md
index ae20f5d..df86e4a 100644
--- a/src/content/docs/server/backup-and-restore.md
+++ b/src/content/docs/server/backup-and-restore.md
@@ -57,7 +57,7 @@ The `version` column in `states` is the linchpin of optimistic concurrency contr
### Schema versioning
-The current schema version is **3**. If you pass a custom `schema=` to `PgCheckpointer`, the tables are schema-qualified (e.g., `myschema.threads`), and your backup must target that schema:
+The current schema version is **5**. If you pass a custom `schema=` to `PgCheckpointer`, the tables are schema-qualified (e.g., `myschema.threads`), and your backup must target that schema:
```bash
pg_dump "$DATABASE_URL" --format=custom \
diff --git a/src/content/docs/server/cli.md b/src/content/docs/server/cli.md
index 313f11d..6d80fa0 100644
--- a/src/content/docs/server/cli.md
+++ b/src/content/docs/server/cli.md
@@ -86,14 +86,16 @@ After building a graph, run your test suite with `10xgraph test`. It wraps pytes
10xgraph test -k "test_routing" # Run tests matching a pattern
```
-Evaluate your agent's behavior with `10xgraph eval`. The command discovers eval files, runs all cases, and generates HTML and JSON reports to `eval_reports/`:
+Evaluate your agent's behavior with `10xgraph eval`. The command discovers eval files, runs all cases, and writes JSON and HTML reports to `eval_reports/`, then opens the HTML report in your browser:
```bash
10xgraph eval # Run all evals
10xgraph eval --parallel # Run cases concurrently
-10xgraph eval --threshold 0.9 --open # Fail if pass rate below 90%, open report
+10xgraph eval --threshold 0.9 # Fail if pass rate below 90%
```
+Pass `--no-open` to skip the browser (it is also skipped with `--no-report`, `-q`, `--format json`, `--non-interactive`, when `CI` is set, or when stdout is not a terminal). The playground can still open the JSON report: Evals > Open report file.
+
See [Run tests](/docs/testing/run-tests) and [Run evaluations](/docs/testing/run-evals) for detailed guides.
## Deployment and operations
diff --git a/src/content/docs/server/configure.md b/src/content/docs/server/configure.md
index d1d6cf9..25d3c20 100644
--- a/src/content/docs/server/configure.md
+++ b/src/content/docs/server/configure.md
@@ -236,7 +236,7 @@ The `websocket` key manages connection limits for `/v1/graph/ws` (streaming) and
"websocket": {
"max_connections": 1000,
"max_connections_per_user": 10,
- "realtime_models": ["gemini-2.5-flash-live"],
+ "realtime_models": ["gemini-3.8-live"],
"realtime_client_overrides": ["voice", "modalities", "vad"]
}
}
@@ -375,7 +375,7 @@ The `"memory"` backend counts per process, so with three workers you allow three
### 5. Wire observability
-A production agent you cannot trace is one you cannot debug. At minimum, set `MODE=production` and `IS_DEBUG=false` in the environment (the server forces debug off in production anyway), and configure logging. See [Observability](/docs/server/observability).
+A production agent you cannot trace is one you cannot debug. At minimum, set `MODE=production` in the environment (it turns debug off), and configure logging. See [Observability](/docs/server/observability).
## Verify before you deploy
diff --git a/src/content/docs/server/cors-and-security-headers.md b/src/content/docs/server/cors-and-security-headers.md
index fc09fe4..ee159a5 100644
--- a/src/content/docs/server/cors-and-security-headers.md
+++ b/src/content/docs/server/cors-and-security-headers.md
@@ -228,7 +228,7 @@ location /api/ {
Before deploying to production:
-- [ ] Set `MODE=production` (turns off `/docs` and `/redocs` unless you set `DOCS_PATH` or `REDOCS_PATH`, and enforces the CORS, host and JWT secret checks) and `IS_DEBUG=false`
+- [ ] Set `MODE=production` (turns off `/docs` and `/redocs` unless you set `DOCS_PATH` or `REDOCS_PATH`, and enforces the CORS, host and JWT secret checks, and turns debug off)
- [ ] Set explicit `ORIGINS` (comma-separated list of domains your frontend runs on)
- [ ] Set `CORS_ALLOW_CREDENTIALS=false` if your API is public and token-based, or `true` if it uses cookies
- [ ] Set `ALLOWED_HOST` to the DNS names the API answers to
diff --git a/src/content/docs/server/deploy.mdx b/src/content/docs/server/deploy.mdx
index c47e6b2..0577741 100644
--- a/src/content/docs/server/deploy.mdx
+++ b/src/content/docs/server/deploy.mdx
@@ -6,7 +6,7 @@ order: 150
updated: "2026-10-27"
faq:
- q: Why does the generated config wait so long to shut down?
- a: An agent run can take minutes, and Gunicorn's default 30 second graceful timeout would kill runs mid-flight on every rolling deploy. The generated files use a 600 second graceful timeout, a 660 second worker timeout, and a 660 second Kubernetes grace period.
+ a: An agent run can take minutes, and Gunicorn's default 30 second graceful timeout would kill runs mid-flight on every rolling deploy. The generated files use a 600 second graceful timeout, a 660 second worker timeout, a 660 second Compose stop grace period and a 675 second Kubernetes grace period.
- q: Can I change the image name in k8s.yaml?
a: Yes. The manifest uses the placeholder image registry.example.com/:1.0.0. Build and push your own image, then set the image field to its version tag or digest before applying.
- q: Does build read my 10xgraph.json?
@@ -75,7 +75,7 @@ Without `--force`, `build` stops if the Dockerfile exists. It keeps an existing
-The Dockerfile sets `MODE=production`, `IS_DEBUG=false` and `WEB_CONCURRENCY=2`, installs `gunicorn` and `uvicorn`, removes the CLI-only files from the installed package, copies your project, drops to the non-root user `appuser` (fixed uid and gid 10001, set as `USER 10001:10001` so Kubernetes can verify it is not root), and defines a `HEALTHCHECK` that calls `/ping`. Its `CMD` runs `tenxgraph_api.src.app.main:app` under Gunicorn with `--graceful-timeout 600` and `--timeout 660`.
+The Dockerfile sets `MODE=production` and `WEB_CONCURRENCY=2`, installs `gunicorn` and `uvicorn`, removes the CLI-only files from the installed package, copies your project, drops to the non-root user `appuser` (fixed uid and gid 10001, set as `USER 10001:10001` so Kubernetes can verify it is not root), and defines a `HEALTHCHECK` that calls `/ping`. Its `CMD` runs `tenxgraph_api.src.app.main:app` under Gunicorn with the `TenxGraphUvicornWorker` class (`tenxgraph_api.src.app.uvicorn_worker.TenxGraphUvicornWorker`), `--graceful-timeout 600` and `--timeout 660`. The worker marks the app as draining on SIGTERM, so `/health/ready` answers 503 while open runs finish. `TENXGRAPH_GRACEFUL_SHUTDOWN_SECONDS` (default 540) sets how long uvicorn waits for open responses; see [Graceful shutdown](/docs/guides/graceful-shutdown).
## Does the image ship the CLI and the config editor?
@@ -117,7 +117,7 @@ A Dockerfile generated before `10xgraph-api` 0.7.0 does not have the strip step.
-The compose file defines one service with `build: .`, the production environment, the same Gunicorn command, `stop_grace_period: 600s` and `restart: unless-stopped`. It reads secrets and `ORIGINS`/`ALLOWED_HOST` from `.env` through `env_file`, so they never enter the image. Copy `.env.example` to `.env` and fill it in first.
+The compose file defines one service with `build: .`, the production environment, the same Gunicorn command, `stop_grace_period: 660s` and `restart: unless-stopped`. It reads secrets and `ORIGINS`/`ALLOWED_HOST` from `.env` through `env_file`, so they never enter the image. Copy `.env.example` to `.env` and fill it in first.
```bash
docker compose up --build -d
@@ -137,8 +137,10 @@ The container runs with `read_only: true`, `cap_drop: [ALL]` and `no-new-privile
docker build -t registry.example.com/my-agent:1.0 .
docker push registry.example.com/my-agent:1.0
kubectl create secret generic my-agent-secrets \
- --from-literal=JWT_SECRET_KEY=... --from-literal=GOOGLE_API_KEY=...
-# edit the image, ORIGINS and ALLOWED_HOST in k8s.yaml, then:
+ --from-literal=JWT_SECRET_KEY=... --from-literal=GOOGLE_API_KEY=... \
+ --from-literal=ORIGINS=https://your-frontend.example.com \
+ --from-literal=ALLOWED_HOST=your-app.example.com
+# edit the image in k8s.yaml, then:
kubectl apply -f k8s.yaml
```
@@ -146,7 +148,7 @@ The settings that protect running agents:
| Setting | Value | Why |
|---|---|---|
-| `terminationGracePeriodSeconds` | 660 | Longer than the 600 second Gunicorn drain |
+| `terminationGracePeriodSeconds` | 675 | Longer than the 600 second Gunicorn drain plus the 15 second `preStop` sleep, which runs inside the same period |
| `preStop` | `sleep 15` | Lets the load balancer stop sending new requests first |
| `readinessProbe` | `/health/ready`, every 10s | Removes the pod from rotation |
| `livenessProbe` | `/ping`, every 30s, 5 failures | Slack enough that a busy worker is not restarted mid-run |
@@ -162,7 +164,7 @@ The settings that lock the pod down:
| Volumes | `emptyDir` at `/tmp` (Gunicorn heartbeats) and `/app/uploads` (local media) |
| Secrets | `envFrom` the Secret `-secrets`; the pod does not start until it exists |
-The manifest sets `MODE`, `IS_DEBUG` and placeholder `ORIGINS` and `ALLOWED_HOST` values; everything secret comes from the Secret. Create it with `--from-literal` or a secret manager, not `--from-env-file .env`: `kubectl` keeps the quotes from a `.env` file as part of each value.
+The manifest sets `MODE`. `ORIGINS` and `ALLOWED_HOST` come from the Secret too, with everything secret; a value in the manifest would override the Secret. Create it with `--from-literal` or a secret manager, not `--from-env-file .env`: `kubectl` keeps the quotes from a `.env` file as part of each value.
@@ -197,12 +199,12 @@ For local development, `10xgraph dev` is sufficient. For shared or production us
## Production environment settings
-The generated files set `MODE=production` and `IS_DEBUG=false`. Add the following at runtime:
+The generated files set `MODE=production`. Add the following at runtime:
| Variable | Set to | Why |
|---|---|---|
-| `ORIGINS` | Comma-separated list of your frontend origins | `k8s.yaml` sets a placeholder; compose reads it from `.env`. A wildcard `*` stops startup in production unless `STRICT_PRODUCTION_CHECKS=false`, and is always refused when `CORS_ALLOW_CREDENTIALS` is true |
-| `ALLOWED_HOST` | Your hostnames | Comma-separated. `k8s.yaml` sets a placeholder; compose reads it from `.env`. A wildcard `*` stops startup in production unless `STRICT_PRODUCTION_CHECKS=false` |
+| `ORIGINS` | Comma-separated list of your frontend origins | `k8s.yaml` reads it from the Secret; compose reads it from `.env`. A wildcard `*` stops startup in production unless `STRICT_PRODUCTION_CHECKS=false`, and is always refused when `CORS_ALLOW_CREDENTIALS` is true |
+| `ALLOWED_HOST` | Your hostnames | Comma-separated. `k8s.yaml` reads it from the Secret; compose reads it from `.env`. A wildcard `*` stops startup in production unless `STRICT_PRODUCTION_CHECKS=false` |
| `JWT_SECRET_KEY` | 32+ bytes, from a secret manager | Required if `auth` is `jwt`. See [Add JWT authentication](/docs/server/auth) |
| `JWT_ALGORITHM` | `HS256` or your choice | Required with `jwt` |
| `REDIS_URL` | Your Redis connection URL | Optional. The server falls back to it for the shared tier of the authorization ownership cache when the `redis` key in `10xgraph.json` is unset. For shared rate limiting, point `rate_limit.redis.url` at it as `${REDIS_URL}` |
@@ -218,7 +220,7 @@ Full reference is in the [configuration](/docs/reference/api-cli/configuration)
-Replace the placeholder `ORIGINS` and `ALLOWED_HOST` before you deploy. In production the server refuses to start with wildcard origins, a wildcard host or enabled docs paths, because `STRICT_PRODUCTION_CHECKS` is on by default. It always refuses wildcard origins while `CORS_ALLOW_CREDENTIALS` is true. These are safety checks: wildcard CORS with credentials would allow any website to read your data.
+Set `ORIGINS` and `ALLOWED_HOST` in the Secret (Kubernetes) or `.env` (compose) before you deploy. In production the server refuses to start with wildcard origins, a wildcard host or enabled docs paths, because `STRICT_PRODUCTION_CHECKS` is on by default. It always refuses wildcard origins while `CORS_ALLOW_CREDENTIALS` is true. These are safety checks: wildcard CORS with credentials would allow any website to read your data.
diff --git a/src/content/docs/server/files-and-multimodal.md b/src/content/docs/server/files-and-multimodal.md
index 72a6cef..223a3b3 100644
--- a/src/content/docs/server/files-and-multimodal.md
+++ b/src/content/docs/server/files-and-multimodal.md
@@ -75,6 +75,9 @@ The server reads uploads in 1 MiB chunks, so oversized files are rejected before
| `415 Unsupported Media Type` | The file's MIME type is not in `MEDIA_ALLOWED_CONTENT_TYPES`. |
| `415 Unsupported Media Type` (content check) | The first bytes of the file do not match its declared type. The detail is `file content does not match `. |
| `413 Payload Too Large` | The file exceeds `MEDIA_MAX_SIZE_MB`. The limit is checked during streaming, so rejection occurs before the full body is loaded. |
+| `413 Payload Too Large` (PDF) | A PDF is over `MEDIA_MAX_PDF_MB` (default 10) or `MEDIA_MAX_PDF_PAGES` (default 500). Checked before text extraction, while `DOCUMENT_HANDLING=extract_text`. |
+| `413 Payload Too Large` (image) | An image has more than `MEDIA_MAX_IMAGE_PIXELS` pixels (default 50 million). Needs Pillow; without it the check is skipped. |
+| `413 Payload Too Large` (quota) | The user is at `MEDIA_MAX_FILES_PER_USER` files or `MEDIA_MAX_USER_STORAGE_MB` of storage. Both default to `0`, which means no limit. On the cloud store the quota counts files through the per-owner index, so files stored before `10xgraph` 1.0, or with no owner, are not counted. |
### Content check on upload
@@ -332,9 +335,15 @@ MEDIA_CLOUD_CREDENTIALS_JSON='{"type": "service_account", ...}'
Or set `GOOGLE_APPLICATION_CREDENTIALS` and omit `MEDIA_CLOUD_CREDENTIALS_JSON`.
+## Cloud listing, quotas and thread delete
+
+On S3 and GCS the cloud store keeps a per-owner index object (`{prefix}/owners/.index.json`), updated on every store and delete. `GET /v1/files`, the per-user quotas and the release of a thread's files on thread delete use it. Files stored before `10xgraph` 1.0, or stored without an owner, are not in the index, so they are not listed, counted or released. The orphan sweeper (`MEDIA_SWEEP_INTERVAL_SECONDS`) does not run on the cloud store, because the library cannot list blobs.
+
+Thread links on a file are updated atomically on the local and memory stores. On the cloud store each update re-reads the metadata just before it writes and retries up to 5 times, which leaves a small window where two writers racing at that instant can lose an update. Several workers sharing one local media folder serialize through a `.meta.lock` file in that folder (POSIX only).
+
## Signed URLs and direct access
-With a cloud storage backend, clients can request signed direct URLs to bypass the API server. With `local` or `memory` storage there is no direct URL, and `url` is the API path `/v1/files/{file_id}`. `expires_at` is a Unix timestamp in seconds.
+With a cloud storage backend, clients can request signed direct URLs to bypass the API server. With `local` or `memory` storage there is no direct URL, and `url` is the API path `/v1/files/{file_id}`. The signed URL's file extension comes from the stored metadata. `expires_at` is a Unix timestamp in seconds.
```bash
curl -H "Authorization: Bearer $TOKEN" \
@@ -370,6 +379,7 @@ Before shipping to production, configure the following:
- **Content types**: Set `MEDIA_ALLOWED_CONTENT_TYPES` to only the types your agent handles. Do not leave it empty.
- **File size**: Set `MEDIA_MAX_SIZE_MB` to the smallest value that works for your use case. This is independent of `MAX_REQUEST_SIZE` (10 MB by default), which applies to entire HTTP request bodies.
+- **Per-user limits**: Set `MEDIA_MAX_FILES_PER_USER` and `MEDIA_MAX_USER_STORAGE_MB` if users upload freely. Both are off (`0`) by default; set either to a positive number (for example `MEDIA_MAX_FILES_PER_USER=100`) to turn it on. With a quota on, every upload reads the metadata of each stored file to count the user's files, so the cost grows with the number of stored files. `MEDIA_INLINE_MAX_BYTES` (default 50000, `0` is off) moves large inline base64 media in a run request into the store, and `MEDIA_SWEEP_INTERVAL_SECONDS` (default 3600, `0` is off) cleans up files a crash left in the local store.
- **Storage backend**: Use `cloud` storage (S3 or GCS) for any deployment with multiple workers. The `local` backend requires a shared filesystem and is not suitable for containerized deployments. Memory storage is development-only.
- **Extraction cache**: Configure a checkpointer (PostgreSQL + Redis recommended) to share document extraction cache across workers. Without it, the same document is extracted per worker per process, wasting CPU.
- **Ownership enforcement**: Set `MEDIA_REQUIRE_OWNER=true` if you need absolute certainty that no file without a recorded owner can be accessed. Files uploaded before ownership tracking was enabled have no owner and are otherwise allowed with a warning.
diff --git a/src/content/docs/server/index.md b/src/content/docs/server/index.md
index d4901d9..441b2d8 100644
--- a/src/content/docs/server/index.md
+++ b/src/content/docs/server/index.md
@@ -21,7 +21,7 @@ Serving your graph with `10xgraph-api` gives you these guarantees without code i
| Scoped authorization | Per-route scopes, and a boot-time route guard that refuses to start if any non-public route has no permission check. | [Authorization scopes](/docs/guides/authorization-scopes) |
| Input rules | Request size limit, client messages limited to the `user` role, built-in state fields not writable, `current_node` and state values validated. | [Invoke and stream](/docs/server/invoke-and-stream), [CORS and security headers](/docs/server/cors-and-security-headers) |
| Rate limits and run caps | Sliding-window limits (memory or Redis) and a per-run timeout (300 s by default in production). | [Rate limiting](/docs/server/rate-limiting) |
-| Production mode | `MODE=production` validates itself at startup, turns off `/docs`, leaves out the eval routes and hides 5xx error text. | [Production checklist](/docs/server/production-checklist) |
+| Production mode | `MODE=production` validates itself at startup, turns off `/docs`, and hides 5xx error text. | [Production checklist](/docs/server/production-checklist) |
| Durable state | Threads, state and messages go through your checkpointer; with `PgCheckpointer` they survive restarts and are shared across replicas. | [Backup and restore](/docs/server/backup-and-restore), [Kubernetes](/docs/server/kubernetes) |
| Deploy files | `10xgraph build` writes a hardened Dockerfile and optional Compose and Kubernetes files. | [Deploy](/docs/server/deploy) |
| Observability | Structured logs, metrics, Sentry and OpenTelemetry hooks, and per-thread run traces in development. | [Observability](/docs/server/observability) |
@@ -32,7 +32,7 @@ For the reasoning behind these and the limits of what the server protects, read
Running `10xgraph dev` (add `--no-open` to skip the playground) starts a Uvicorn ASGI development server on port 8000 that exposes your compiled graph alongside infrastructure for real applications: stateful thread management, semantic memory, multimodal file handling, JWT or custom authentication, rate limiting, and structured logging and tracing. The server reads two inputs: your `10xgraph.json` configuration file (which graph to load, which auth backend, storage layer) and environment variables for secrets and runtime tunables. Your graph logic stays in Python; the server handles the transport layer, persistence, and cross-cutting concerns.
-The server is mode-aware: development mode (`MODE=development`, the default) mounts the unauthenticated eval endpoints and leaves CORS open; production mode (`MODE=production`) does not mount them, refuses wildcard CORS with credentials, turns off the `/docs` and `/redocs` pages unless you set their paths, and defaults to ownership-based thread isolation. A single codebase switches between modes via environment variables, so your Docker image works in both dev and production.
+The server is mode-aware: development mode (`MODE=development`, the default) leaves CORS open; production mode (`MODE=production`) refuses wildcard CORS with credentials, turns off the `/docs` and `/redocs` pages unless you set their paths, and defaults to ownership-based thread isolation. A single codebase switches between modes via environment variables, so your Docker image works in both dev and production.
## API routes by function
@@ -49,13 +49,12 @@ The server organizes its endpoints into logical groups. Here is the complete rou
| **Media/Files** | `/v1/files/*` | Yes | Upload, retrieve, and introspect multimodal files (images, audio, documents): `POST /v1/files/upload`, `GET /v1/files/{id}`, `GET /v1/files/{id}/info`, and `GET /v1/files/{id}/url` for signed URLs. |
| **Config** | `/v1/config/multimodal` | Yes | Read the server's multimodal configuration (supported MIME types, size limits, extraction settings). |
| **Observability** | `/v1/observability/{thread_id}` | Yes | Retrieve a reconstructed run trace for a thread (spans, events, token usage). Development only: returns empty in `MODE=production`. |
-| **Evals** | `/v1/evals/*` | No | List and inspect eval runs from `eval_reports/` (`GET /v1/evals/runs`, `GET /v1/evals/runs/{run_id}`), as written by `10xgraph eval`. Unauthenticated for convenience during development. **Not mounted in production mode** (`MODE=production` disables this endpoint for security). |
For detailed route signatures and response shapes, see the [REST API reference](/docs/reference/rest-api/conventions).
## Authentication and authorization
-All endpoints except `/ping` and `/v1/evals` (in development) enforce an auth + authorization layer. Your choice at configuration time determines what credentials are required:
+All endpoints except `/ping` enforce an auth + authorization layer. Your choice at configuration time determines what credentials are required:
- **No auth** (`"auth": null` in `10xgraph.json`): All authenticated endpoints allow any request. Safe for internal networks or local development behind a firewall.
- **JWT** (`"auth": "jwt"`): Bearer token checked against a shared secret (`JWT_SECRET_KEY`). Standard, stateless, and suitable for mobile and SPAs.
@@ -64,7 +63,7 @@ All endpoints except `/ping` and `/v1/evals` (in development) enforce an auth +
Authorization (per-resource access control) is separate and optional:
- **Ownership** (default in production): A user can access only threads they created. Enforced on every thread operation (invoke, stream, read state, delete).
-- **RBAC** (role-based access control): Custom `AuthorizationBackend` subclass for per-tool access, team scopes, or resource-level rules.
+- **RBAC** (role-based access control): Custom `AuthorizationBackend` subclass for per-route scopes (such as `graph:invoke` or `store:write`), team scopes, or resource-level rules. Scopes guard routes, not individual tools; to check a scope inside a tool, use `has_scope` in the tool code.
- **Allow all** (default in development): All authenticated users can access all resources.
WebSocket and HTTP endpoints both enforce authentication via the `Authorization: Bearer ` header. WebSocket also accepts the token in the `Sec-WebSocket-Protocol` header as `10xgraph-bearer, ` (the deprecated `agentflow-bearer` name still works), or as a `?token=` query parameter as a last resort. HTTP routes read only the `Authorization` header. See [Secure your server](/docs/server/auth) for setup and examples.
@@ -73,8 +72,8 @@ WebSocket and HTTP endpoints both enforce authentication via the `Authorization:
The `MODE` environment variable switches the server's security and logging posture:
-- **`MODE=development`** (default): Unauthenticated `/ping` and `/v1/evals` accessible, `ORIGINS="*"` (CORS allows any domain), `IS_DEBUG` defaults to true. Use for local development and testing.
-- **`MODE=production`**: Only `/ping` is unauthenticated, the `/v1/evals` endpoints are not mounted, a graph run is cancelled after 300 seconds unless you change `runs.timeout`, wildcard CORS with credentials is refused (set `ORIGINS` explicitly), and `/docs` and `/redocs` are disabled unless you set `DOCS_PATH` and `REDOCS_PATH`. `IS_DEBUG` is forced off, and `graph_introspection` defaults to `"scoped"` (see [Configure the server](/docs/server/configure#control-who-can-read-the-graph-structure)). Security headers (HSTS, X-Frame-Options and others) are on in every mode unless `SECURITY_HEADERS_ENABLED=false`. Use for deployments exposed to the internet.
+- **`MODE=development`** (default): Unauthenticated `/ping` accessible, `ORIGINS="*"` (CORS allows any domain), debug mode is on. Use for local development and testing.
+- **`MODE=production`**: Only `/ping` is unauthenticated, a graph run is cancelled after 300 seconds unless you change `runs.timeout`, wildcard CORS with credentials is refused (set `ORIGINS` explicitly), and `/docs` and `/redocs` are disabled unless you set `DOCS_PATH` and `REDOCS_PATH`. debug mode is off, and `graph_introspection` defaults to `"scoped"` (see [Configure the server](/docs/server/configure#control-who-can-read-the-graph-structure)). Security headers (HSTS, X-Frame-Options and others) are on in every mode unless `SECURITY_HEADERS_ENABLED=false`. Use for deployments exposed to the internet.
Together with `JWT_SECRET_KEY` (a strong, randomly generated secret in production) and an explicit `ORIGINS` list, production mode refuses to start with wildcard CORS and credentials enabled. `STRICT_PRODUCTION_CHECKS` is on by default in production and also refuses a wildcard `ORIGINS`, a wildcard `ALLOWED_HOST` and enabled docs paths, so set `ORIGINS` and `ALLOWED_HOST` to your real values. Set `STRICT_PRODUCTION_CHECKS=false` to opt out.
diff --git a/src/content/docs/server/invoke-and-stream.md b/src/content/docs/server/invoke-and-stream.md
index 79c0067..a9cd434 100644
--- a/src/content/docs/server/invoke-and-stream.md
+++ b/src/content/docs/server/invoke-and-stream.md
@@ -80,7 +80,7 @@ The server refuses some input with `422`, on invoke, stream and the WebSocket:
- An unknown `current_node`.
- A state value that does not match its field type. The error names the field.
-Requests are also limited in size: at most 500 messages, 100 content blocks per message and 1,000,000 characters of text in total. A request over a limit gets `422` with a message that names the limit, in development and production alike. The limits are fixed.
+Requests are also limited in size: at most 500 messages, 100 content blocks per message and 1,000,000 characters of text in total. `initial_state`, `config` and `resume` may be nested no deeper than 64 levels; deeper gets `422` with code `VALIDATION_ERROR`. A request over a limit gets `422` with a message that names the limit, in development and production alike. The limits are fixed.
Custom state fields are allowed, so a frontend can pass a selected ID straight into state. To limit which keys clients may set, list them in `runs.state_keys` in `10xgraph.json`. The server ignores any `message_id` you send and generates its own.
@@ -98,7 +98,7 @@ The `data` object has these fields:
## Stream results in real time
-Use POST `/v1/graph/stream` to get results as they are produced. The response body is NDJSON (newline-delimited JSON), one chunk per line. The `Content-Type` header is `text/event-stream`, but lines are plain JSON, not `data:` frames.
+Use POST `/v1/graph/stream` to get results as they are produced. The response body is NDJSON (newline-delimited JSON), one chunk per line. The `Content-Type` header is `application/x-ndjson`. Lines are plain JSON, not `data:` frames. While the run is silent the server sends a blank line every 15 seconds so a proxy does not cut the connection; skip empty lines. A stream that finishes without an error ends with an `updates` chunk whose `data` is `{"status": "done"}`.
```bash
curl -X POST http://localhost:8000/v1/graph/stream \
diff --git a/src/content/docs/server/kubernetes.md b/src/content/docs/server/kubernetes.md
index 37a6d05..d01dd17 100644
--- a/src/content/docs/server/kubernetes.md
+++ b/src/content/docs/server/kubernetes.md
@@ -21,6 +21,8 @@ A typical agent invocation chains several operations: the client calls the serve
Kubernetes' default pod lifecycle is hostile to this. When you trigger a rolling deploy, the kubelet gives a pod 30 seconds to shut down gracefully before sending SIGKILL. If the app is still in an LLM call, the pod dies mid-request, and the client's stream breaks.
+On SIGTERM the generated image answers `503` on `/health/ready` (`draining`) so the Service stops routing to the pod, gives open runs up to 540 seconds to finish, and ends open streams with an "interrupted" chunk the client can resume from. Details are in [Graceful shutdown](/docs/guides/graceful-shutdown).
+
For the scaling model behind these settings, see [scaling and reliability](/docs/production/scaling). Draining runs on shutdown is covered in [Graceful shutdown](/docs/guides/graceful-shutdown).
`10xgraph build --k8s` generates a manifest that fixes this: it sets the termination grace period, pre-termination delay, and probe timeouts so that in-flight runs finish normally, even during deploys.
@@ -35,7 +37,7 @@ Create a `Dockerfile` and Kubernetes manifest in one command:
This produces three files:
-- `Dockerfile` with a production-ready image that starts Gunicorn with a 600-second graceful timeout
+- `Dockerfile` with a production-ready image that starts Gunicorn with `TenxGraphUvicornWorker` and a 600-second graceful timeout
- `.dockerignore`
- `k8s.yaml` containing a Deployment and a Service
@@ -47,7 +49,7 @@ The manifest is a working skeleton. It sets these values, which are easy to get
| Setting | Value | Purpose |
| --- | --- | --- |
-| `terminationGracePeriodSeconds` | 660 | Exceeds the app's 600-second graceful timeout. Ensures SIGKILL does not arrive until the app has fully drained. |
+| `terminationGracePeriodSeconds` | 675 | Exceeds the app's 600-second graceful timeout plus the 15-second `preStop` sleep, which runs inside the same period. Ensures SIGKILL does not arrive until the app has fully drained. |
| `preStop` sleep | 15 seconds | Gives the load balancer time to notice the pod is terminating and stop routing new requests. Without it, new traffic arrives while the app shuts down. |
| `readinessProbe` | 10s interval, 5s initial delay | Removes unhealthy pods from the load balancer quickly. Checked via `GET /health/ready`. |
| `livenessProbe` | 30s interval, 30s initial delay, 5 failures | Deliberately slack. A worker handling a long run must not be restarted. Checked via `GET /ping`. |
@@ -82,12 +84,8 @@ containers:
- name: my-agent
image: registry.example.com/my-agent:1.0.0 # Replace with your own pinned image reference
env:
- - name: ORIGINS
- value: "https://your-frontend.example.com" # Set to your actual origin
- name: MODE
value: "production" # Already set, but verify
- - name: IS_DEBUG
- value: "false" # Already set, but verify
```
### Pin the image
@@ -100,14 +98,15 @@ The manifest uses `registry.example.com/:1.0.0` as a placeholder image
### Set CORS origins explicitly
-The manifest sets `ORIGINS` to the placeholder `https://your-frontend.example.com`. Replace it with your actual frontend domain(s), comma-separated if there are multiple:
+The manifest does not set `ORIGINS` or `ALLOWED_HOST` as env entries. Both are read from the Secret (see [Mount secrets](#mount-secrets-do-not-bake-them)), because an env entry in the manifest would override the Secret. Put your actual frontend domain(s) in the Secret, comma-separated if there are multiple:
-```yaml
-- name: ORIGINS
- value: "https://app.example.com,https://app-staging.example.com"
+```bash
+kubectl create secret generic my-agent-secrets \
+ --from-literal=ORIGINS="https://app.example.com,https://app-staging.example.com" \
+ --from-literal=ALLOWED_HOST="app.example.com"
```
-With `MODE=production`, the server refuses to start if `ORIGINS` or `ALLOWED_HOST` is the wildcard `*`. The check is `STRICT_PRODUCTION_CHECKS`, which is on by default in production. The generated manifest sets placeholders for both, so replace them. If you truly need wildcard origins, set `CORS_ALLOW_CREDENTIALS=false` and `STRICT_PRODUCTION_CHECKS=false`, which is rare.
+With `MODE=production`, the server refuses to start if `ORIGINS` or `ALLOWED_HOST` is the wildcard `*`. The check is `STRICT_PRODUCTION_CHECKS`, which is on by default in production. Production refuses to start while either is missing, so add both to the Secret. If you truly need wildcard origins, set `CORS_ALLOW_CREDENTIALS=false` and `STRICT_PRODUCTION_CHECKS=false`, which is rare.
### Mount secrets, do not bake them
@@ -119,7 +118,7 @@ envFrom:
name: my-agent-secrets
```
-Create the secret in your cluster before deploying. The server reads `JWT_SECRET_KEY`, `JWT_ALGORITHM` and `REDIS_URL` itself; names such as the Postgres DSN variable below are whatever your graph module reads when it builds the checkpointer:
+Create the secret in your cluster before deploying. The server reads `JWT_SECRET_KEY`, `JWT_ALGORITHM`, `REDIS_URL`, `ORIGINS` and `ALLOWED_HOST` itself; names such as the Postgres DSN variable below are whatever your graph module reads when it builds the checkpointer:
```bash
kubectl create secret generic my-agent-secrets \
@@ -127,6 +126,8 @@ kubectl create secret generic my-agent-secrets \
--from-literal=OPENAI_API_KEY="..." \
--from-literal=JWT_SECRET_KEY="..." \
--from-literal=JWT_ALGORITHM="HS256" \
+ --from-literal=ORIGINS="https://app.example.com" \
+ --from-literal=ALLOWED_HOST="app.example.com" \
--from-literal=POSTGRES_DSN="postgresql://..." \
--from-literal=REDIS_URL="redis://..."
```
@@ -233,13 +234,13 @@ CPU is the provided metric, but it is a poor signal for agent workloads. Agents
| Symptom | Cause | Fix |
| --- | --- | --- |
-| Runs truncate on every deploy | `terminationGracePeriodSeconds` is too short, or the process is not PID 1 | Increase to 660+ seconds. Ensure your Dockerfile uses `CMD` or `ENTRYPOINT`, not a shell wrapper. |
+| Runs truncate on every deploy | `terminationGracePeriodSeconds` is too short, or the process is not PID 1 | Increase to 675+ seconds. Ensure your Dockerfile uses `CMD` or `ENTRYPOINT`, not a shell wrapper. |
| Requests fail for a few seconds after a deploy | `preStop` sleep removed or load balancer does not respect endpoint removal | Restore the `preStop` sleep in the manifest. Increase your load balancer's connection drain time. |
| Pods restart during long runs | Liveness probe is too strict (failing too quickly) | Loosen the liveness probe (`periodSeconds: 30`, `failureThreshold: 5`). The readiness probe should be strict; the liveness probe should not. |
| Thread history disappears or threads resolve differently | Replicas do not share a checkpointer | Ensure the Postgres DSN and `REDIS_URL` your graph module uses point to shared instances. Do not use `InMemoryCheckpointer` in production. |
| Random 409 errors under high load | Two requests wrote to the same thread concurrently | This is expected with optimistic concurrency. Retry with exponential backoff, or serialize writes to the same thread. |
| Server exits at startup with a CORS or host error | `ORIGINS` or `ALLOWED_HOST` is `*` | Set both to your real values. If you need wildcard origins, set `CORS_ALLOW_CREDENTIALS=false` and `STRICT_PRODUCTION_CHECKS=false` (unusual). |
-| Browser requests fail CORS checks | `ORIGINS` still holds the placeholder | Set `ORIGINS` to your real domain. |
+| Browser requests fail CORS checks | `ORIGINS` is missing from the Secret or holds the wrong domain | Set `ORIGINS` in the Secret to your real domain. |
## Related
diff --git a/src/content/docs/server/observability.md b/src/content/docs/server/observability.md
index 465bcce..76f5d3c 100644
--- a/src/content/docs/server/observability.md
+++ b/src/content/docs/server/observability.md
@@ -301,7 +301,7 @@ The response is wrapped in the standard envelope; `data` holds the thread's run
`status` is one of `running`, `done`, `error` or `stopped`. Span `kind` is `root`, `node`, `llm` or `tool`. Event `type` is `message`, `updates`, `state`, `error` or `result`.
-The traces come from an in-memory, process-local store that the server binds only outside production. With `MODE=production`, which the generated Docker files set, the store is disabled and the endpoint returns an empty result (`run_count` 0, `run` null); use OTEL or a publisher there. When enabled, it keeps at most 200 threads, 20 runs per thread and 2000 records per run, and a restart clears it. It is not a durable tracing backend. When no run exists for a thread, `run` is `null` and the endpoint still returns 200. It requires the `graph:read` permission. See [authorization](/docs/server/auth) for how to set up access control.
+This endpoint is a development tool built for the playground's Run trace page. The traces come from an in-memory, process-local store that the server creates only when `MODE` is not `production`. With `MODE=production`, which the generated Docker files set, the store is never created: nothing is recorded, and the endpoint returns an empty result with `enabled: false` and `reason: "disabled_in_production"`. Other reasons are `store_unavailable` and `no_runs`. Use OTEL, LangSmith, Logfire or another publisher for production tracing. When enabled, it keeps at most 200 threads, 20 runs per thread and 2000 records per run, and a restart clears it. It is not a durable tracing backend. When no run exists for a thread, `run` is `null` and the endpoint still returns 200. It requires the `graph:read` permission, and with auth on the thread id goes to the authorization backend, so a user can read traces only for their own threads by default. See [authorization](/docs/server/auth) for how to set up access control.
The TypeScript client wraps this endpoint as `client.observability(threadId, runId?)`.
diff --git a/src/content/docs/server/playground.md b/src/content/docs/server/playground.md
index 15d045c..1d29cce 100644
--- a/src/content/docs/server/playground.md
+++ b/src/content/docs/server/playground.md
@@ -67,7 +67,7 @@ The playground is organized into pages in a left navigation rail:
| Interact | Chat | `/chat` | Send messages and receive replies; choose between streaming, invoke, or WebSocket transport |
| Interact | Live | `/live` | Voice-to-voice sessions for realtime agents (Gemini live, Claude realtime, etc.) |
| Inspect | Thread Inspector | `/threads` | Browse saved threads, messages, and checkpointed state |
-| Inspect | Observability | `/observability` | Trace spans, event timelines, and token cost for a run |
+| Inspect | Run trace | `/trace` | Trace spans, event timelines, and token cost for a run |
| Inspect | Evals | `/evals` | View evaluation runs and drill into individual cases |
| Inspect | Memory Inspector | `/memory` | Search and browse the memory store |
| Build | Graph | `/graph` | Render the compiled graph as a node-and-edge canvas with live node highlighting during runs |
@@ -106,9 +106,13 @@ Build a realtime agent with [Use realtime audio](/docs/guides/use-realtime-audio
**Thread Inspector** lists all saved threads (if your graph has a checkpointer). Select one to see its messages, state, and checkpoint data in three tabs: Messages, State & checkpoint, and Raw JSON. Threads can be deleted from here.
-**Observability** shows a span timeline (root → node → llm | tool), an event list, and token cost. Click any span or event to see details. Observability reads the active thread, so send a message in Chat first; with no runs recorded, it shows a placeholder.
+**Run trace** shows a span timeline (root → node → llm | tool), an event list, and token cost. Click any span or event to see details. It reads the active thread, so send a message in Chat first; with no runs recorded, it shows a placeholder.
-**Evals** lists evaluation runs from the server with per-case drilldown, mirroring the output of `10xgraph eval` in the CLI.
+> **Run trace is a development tool.** It exists for the playground. It is turned off in production mode: nothing is recorded, and the endpoint returns `enabled: false`.
+>
+> The data comes from a small in-memory store that the server creates only when `MODE` is not `production`, and a restart clears it. For production tracing, use OTEL, LangSmith or Logfire through [publishers](/docs/guides/use-publishers). See [Observability](/docs/server/observability) for the endpoint.
+
+**Evals** opens a report file written by `10xgraph eval` (file picker or drag and drop) and shows per-case drilldown. Viewing is local and needs no server. It can also download a Markdown report.
**Memory Inspector** searches and browses the memory store (if configured on the server). Narrow results by memory type (episodic, semantic, procedural, entity, relationship, declarative, custom), pick a retrieval strategy and similarity metric, and open any memory record to see its full content.
diff --git a/src/content/docs/server/production-checklist.md b/src/content/docs/server/production-checklist.md
index 0f1dc56..8986ca7 100644
--- a/src/content/docs/server/production-checklist.md
+++ b/src/content/docs/server/production-checklist.md
@@ -9,7 +9,7 @@ label: Production Checklist
updated: "2026-10-27"
faq:
- question: "What is the minimum checklist for production?"
- answer: "Set MODE=production, IS_DEBUG=false, use explicit ORIGINS, configure Postgres+Redis for persistence, enable rate limiting, and run at least two API instances."
+ answer: "Set MODE=production, use explicit ORIGINS, configure Postgres+Redis for persistence, enable rate limiting, and run at least two API instances."
- question: "Can I use in-memory checkpointing in production?"
answer: "No. InMemoryCheckpointer loses all state on restart and cannot be shared across instances. Use PgCheckpointer for all production deployments."
- question: "Do I have to use Redis?"
@@ -25,13 +25,12 @@ With `MODE=production`, the server does these for you. You do not configure them
- Validates `MODE` at startup: a typo stops the server instead of running with development defaults.
- Runs strict production checks (`STRICT_PRODUCTION_CHECKS`, on by default): wildcard `ORIGINS` or `ALLOWED_HOST`, wildcard CORS with credentials and enabled docs paths are refused at boot, and short HMAC JWT secrets are rejected.
- Turns off the `/docs` and `/redocs` pages unless you set their paths.
-- Does not mount the unauthenticated `/v1/evals` routes.
- Replaces 5xx error text, including streamed error chunks, with a generic message. Raise `ClientInputError` for 422 messages that must reach the client.
- Cancels a graph run after 300 seconds unless you set `runs.timeout`.
- Scopes graph introspection (`GET /v1/graph`, `/v1/graph/tools`, `/v1/graph:StateSchema`) to callers with the `graph:read` scope, unless you set `graph_introspection` to `"open"`.
- Defaults authorization to ownership, so each user reaches only their own threads.
-- Forces `IS_DEBUG` off, even if the environment sets it to true.
-- Ships a hardened image when you use `10xgraph build`: non-root user, Gunicorn with long graceful timeouts, health check on `/ping`.
+- Turns debug mode off.
+- Ships a hardened image when you use `10xgraph build`: non-root user, Gunicorn with `TenxGraphUvicornWorker` and long graceful timeouts, health check on `/ping`. On SIGTERM `/health/ready` answers 503 while open runs finish.
For why these matter, see the [security model](/docs/production/security-model) and [scaling and reliability](/docs/production/scaling).
@@ -59,13 +58,12 @@ The first critical step is disabling development features. Set these environment
```bash
MODE=production
-IS_DEBUG=false
LOG_LEVEL=INFO
```
`MODE` accepts `production` (or `prod`) and `development` (or `dev`, `local`), trimmed and case-insensitive. Unset means `development`. Any other value, such as a typo, stops the server at startup, so a misspelling cannot run with the development defaults.
-`MODE=production` enforces stricter validation at startup: wildcard CORS origins combined with credentials fail the server boot, and HMAC JWT secrets shorter than 32 bytes are rejected. `STRICT_PRODUCTION_CHECKS` is on by default in production and also stops startup on a wildcard `ORIGINS`, a wildcard `ALLOWED_HOST`, or enabled `DOCS_PATH` and `REDOCS_PATH`. Set `STRICT_PRODUCTION_CHECKS=false` to start with warnings only. It also turns off the `/docs` and `/redocs` pages unless you set their paths explicitly, and it does not mount the eval report routes. Leave `IS_DEBUG=false`. In production the server forces `IS_DEBUG` off and logs a warning if it was set to true.
+`MODE=production` enforces stricter validation at startup: wildcard CORS origins combined with credentials fail the server boot, and HMAC JWT secrets shorter than 32 bytes are rejected. `STRICT_PRODUCTION_CHECKS` is on by default in production and also stops startup on a wildcard `ORIGINS`, a wildcard `ALLOWED_HOST`, or enabled `DOCS_PATH` and `REDOCS_PATH`. Set `STRICT_PRODUCTION_CHECKS=false` to start with warnings only. It also turns off the `/docs` and `/redocs` pages unless you set their paths explicitly. Debug mode follows `MODE`, so it is off in production.
## 2. Secrets and authentication
@@ -86,7 +84,7 @@ JWT_SECRET_KEY=$(openssl rand -hex 32) # 64 hex chars = 32 bytes, production m
JWT_ALGORITHM=HS256
```
-Export these to your deployment. The server requires them at startup when JWT auth is configured and will fail to boot otherwise.
+Export these to your deployment. The server requires them at startup when JWT auth is configured and will fail to boot otherwise. It also fails to boot if PyJWT is missing, or if `JWT_ALGORITHM` is `none`, empty or not one of `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512` or `EdDSA`. The `auth` value must be exactly `"jwt"`.
The server rejects a token that has no `user_id` claim. To mint a test token:
@@ -176,7 +174,7 @@ my_checkpointer = PgCheckpointer(
app = state_graph.compile(checkpointer=my_checkpointer)
```
-The checkpointer creates tables automatically on first connection (call `setup()` or `asetup()` if your code manages startup itself). Make sure the database user has CREATE permission. List the tables:
+The API server calls the checkpointer's `asetup()` at boot to create the tables. If that fails it logs the error and retries on first use. Call `setup()` or `asetup()` yourself only if your code manages startup outside the server. Make sure the database user has CREATE permission. List the tables:
```bash
psql "postgresql://user:password@postgres.internal/10xgraph" -c "
@@ -188,10 +186,14 @@ psql "postgresql://user:password@postgres.internal/10xgraph" -c "
Expected tables in the `public` schema (or the `schema=` you pass): `threads`, `states`, `messages`, `tool_executions`, `schema_version`. They have no name prefix, so use a dedicated database or schema if you share the instance.
+`/health/ready` answers 503 while the database schema version is below what the code expects, so a pod started before its migration ran stays out of rotation.
+
### Redis caching
Redis acts as a hot cache in front of Postgres. `PgCheckpointer` requires a Redis connection, but the durable data lives in Postgres.
+If several deployments share one Redis, set `REDIS_KEY_PREFIX` for the API (run locks, caps, ownership cache, token revocation) and `redis_key_prefix` on `PgCheckpointer` to the same value. They are two separate settings, and each prefixes only its own keys.
+
```bash
REDIS_URL=redis://redis.internal:6379/0
```
@@ -207,6 +209,10 @@ redis-cli -u redis://redis.internal:6379/0 ping
The server runs one graph run at a time on a thread. With Redis configured, the lock is shared across all instances, so two replicas cannot run the same thread at once. Without Redis, the lock is per process and does not stop two replicas from running one thread together. A second run waits up to 30 seconds, then gets `409` (`thread is busy`). The lock expires on its own if a worker dies. If Redis is unreachable, runs proceed without the lock and the error is logged. See [Invoke and stream](/docs/server/invoke-and-stream#one-run-per-thread).
+### Warnings you may see
+
+The server logs a WARNING at startup in production, or when `WEB_CONCURRENCY` is above 1, if the checkpointer is `InMemoryCheckpointer` or `SqliteCheckpointer` (threads live in one process) or if no Redis is configured (run locks, caps and in-memory rate limits count per process). `compile()` logs a WARNING when you give it no checkpointer and it falls back to in-memory, and a graph logs one WARNING per graph on its first run with no `user_id`. These are your call; use `PgCheckpointer` with Redis for more than one process.
+
### Topology diagram
```
@@ -229,21 +235,20 @@ Deploy at least two API instances behind a load balancer to survive instance fai
### Gunicorn + Uvicorn (ASGI server)
-The `Dockerfile` from `10xgraph build` already starts Gunicorn with Uvicorn workers. If you run the server yourself, use the same flags. The generated command uses a 600-second graceful timeout and a 660-second worker timeout so long agent runs are not killed mid-flight:
+The `Dockerfile` from `10xgraph build` already starts Gunicorn with Uvicorn workers. If you run the server yourself, use the same flags. The generated command uses the `TenxGraphUvicornWorker` class, a 600-second graceful timeout and a 660-second worker timeout so long agent runs are not killed mid-flight. The worker marks the app as draining on SIGTERM (readiness answers 503) and waits `TENXGRAPH_GRACEFUL_SHUTDOWN_SECONDS` (540 by default) for open responses. Plain `uvicorn.workers.UvicornWorker` also works but skips the draining flag and the final "interrupted" chunk for open streams:
```bash
#!/bin/bash
set -e
export MODE=production
-export IS_DEBUG=false
export REDIS_URL="redis://redis.internal:6379/0"
export JWT_SECRET_KEY=$JWT_SECRET_KEY # from environment
export ORIGINS="https://app.example.com"
gunicorn \
--workers 4 \
- -k uvicorn.workers.UvicornWorker \
+ -k tenxgraph_api.src.app.uvicorn_worker.TenxGraphUvicornWorker \
-b 0.0.0.0:8000 \
--graceful-timeout 600 \
--timeout 660 \
@@ -278,7 +283,7 @@ SNOWFLAKE_WORKER_ID=2
### Error text and request limits
-With `MODE=production`, error chunks on stream, WebSocket and AG-UI carry a generic message instead of the raw exception text, the same as invoke. Requests are also limited to 500 messages, 100 content blocks per message and 1,000,000 characters of text; over a limit gets `422` naming the limit. Neither needs configuration.
+With `MODE=production`, error chunks on stream, WebSocket and AG-UI carry a generic message instead of the raw exception text, the same as invoke. Requests are also limited to 500 messages, 100 content blocks per message and 1,000,000 characters of text; over a limit gets `422` naming the limit. `initial_state`, `config` and `resume` may be nested no deeper than 64 levels. Neither needs configuration.
## 7. Rate limiting
@@ -368,11 +373,7 @@ MEDIA_CLOUD_PREFIX=10xgraph-media
Ensure the storage directory (or cloud bucket) is writable and has sufficient capacity. Monitor disk or cloud storage usage.
-## 10. Eval endpoints
-
-The eval report routes (`/v1/evals/runs`) have no authentication. The server does not mount them when `MODE=production`, so no extra configuration is needed. Confirm `MODE=production` is set in every environment that faces users.
-
-## 11. Database backups
+## 10. Database backups
PostgreSQL holds all thread history, messages, and state snapshots. Back it up regularly:
@@ -406,6 +407,8 @@ SENTRY_TRACES_SAMPLE_RATE=0.1
Sampling 10% of traces is typical for production.
+The playground's Run trace endpoint (`GET /v1/observability/{thread_id}`) is a development tool. With `MODE=production` it records nothing and returns `enabled: false`, so you have nothing to configure or secure for it. Use OTEL or a publisher for production traces.
+
The API reports only server faults: 4xx responses are logged at WARNING and never become events, and request bodies are never sent to Sentry. If an alert matches ERROR logs for client errors, match WARNING instead.
### OpenTelemetry tracing
diff --git a/src/content/docs/server/project-setup.md b/src/content/docs/server/project-setup.md
index 6bd192c..353c6fd 100644
--- a/src/content/docs/server/project-setup.md
+++ b/src/content/docs/server/project-setup.md
@@ -196,7 +196,7 @@ Init builds your config from your answers, not a static template. Examples:
"window": 60,
"by": "ip",
"trusted_proxy_headers": false,
- "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"]
+ "exclude_paths": ["/ping", "/docs", "/redocs", "/openapi.json"]
},
"ag_ui": {"enabled": false}
}
diff --git a/src/content/docs/server/rate-limiting.md b/src/content/docs/server/rate-limiting.md
index 9a899cb..28841ce 100644
--- a/src/content/docs/server/rate-limiting.md
+++ b/src/content/docs/server/rate-limiting.md
@@ -43,7 +43,7 @@ The simplest setup stores counters in the process memory. It works with a single
"requests": 100,
"window": 60,
"by": "ip",
- "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"]
+ "exclude_paths": ["/ping", "/docs", "/redocs", "/openapi.json"]
}
}
```
@@ -105,7 +105,7 @@ pip install "10xgraph-api[redis]"
"window": 60,
"by": "ip",
"trusted_proxy_headers": true,
- "exclude_paths": ["/ping", "/metrics", "/docs", "/redoc", "/openapi.json"],
+ "exclude_paths": ["/ping", "/metrics", "/docs", "/redocs", "/openapi.json"],
"redis": {
"url": "${RATE_LIMIT_REDIS_URL}",
"prefix": "10xgraph:rate-limit"
@@ -299,12 +299,12 @@ Add monitoring, health-check, and documentation paths to `exclude_paths` so they
```json
{
"rate_limit": {
- "exclude_paths": ["/ping", "/metrics", "/docs", "/redoc", "/openapi.json"]
+ "exclude_paths": ["/ping", "/metrics", "/docs", "/redocs", "/openapi.json"]
}
}
```
-Health checks (`/ping`) are excluded automatically and do not need to be listed. Other status endpoints, readiness checks, and documentation paths should be added here so your monitoring and developers do not trigger limits.
+Health checks (`/ping`) are excluded automatically and do not need to be listed. Other status endpoints, readiness checks, and documentation paths should be added here so your monitoring and developers do not trigger limits. The ReDoc page is served at `/redocs`, not `/redoc`, so listing `/redoc` excludes nothing.
## Disabling rate limiting
diff --git a/src/content/docs/server/websockets.md b/src/content/docs/server/websockets.md
index 6fb4d00..85faa76 100644
--- a/src/content/docs/server/websockets.md
+++ b/src/content/docs/server/websockets.md
@@ -131,7 +131,7 @@ To stop the running run and take its place, add `"on_busy": "cancel"` to the fra
### Request limits and error text
-A frame is limited to 500 messages (in `messages` or `tool_result`), 100 content blocks per message and 1,000,000 characters of text in total. An oversized frame gets 422 from the schema check. In production (`MODE=production`), `error` chunks from a failing node or tool carry a generic message instead of the raw exception text. Development shows the details.
+A frame is limited to 500 messages (in `messages` or `tool_result`), 100 content blocks per message and 1,000,000 characters of text in total. An oversized frame gets 422 from the schema check, as does an `initial_state`, `config` or `resume` nested deeper than 64 levels. In production (`MODE=production`), `error` chunks from a failing node or tool carry a generic message instead of the raw exception text. Development shows the details.
### Rate limiting and concurrency
@@ -140,6 +140,12 @@ Every WebSocket handshake and every graph run on the socket counts against the g
- A handshake over the limit is refused before `accept()` with close code 1013.
- A run over the limit gets an `error` chunk whose `data` has `reason` and `retry_after_seconds`. The run is skipped and the socket stays open; the check runs again on the next frame.
+A run refused because the graph is closing gets an `error` frame whose `data` has `code: "SERVICE_UNAVAILABLE"`, a `reason` and `retry_after_seconds`, and no `done` frame. The server then closes the socket with code 1012 (service restart). Reconnect, which lands on another replica.
+
+A frame that fails to parse or validate also counts against the rate limit. After 20 invalid frames in a row the server closes the socket with code 1008. A valid frame resets the count.
+
+A socket that sits idle is not kept past its token's expiry. When the token expires, the server checks the credential again, and if it no longer verifies it sends an `error` chunk ("Session expired. Reconnect with a valid token.") and closes with code 1008. Reconnect with a fresh token.
+
Connection caps apply at the handshake only: `websocket.max_connections` and `websocket.max_connections_per_user`. Over either cap, the handshake is refused with close code 1013. Live sessions on `/v1/graph/live` count against `websocket.max_connections_per_user` like any other socket.
## Realtime audio: `/v1/graph/live`
@@ -155,7 +161,7 @@ Realtime is only for agents built for it: connecting to `/v1/graph/ws` with a re
```json
Client → Server:
{
- "model": "gemini-2.5-flash-live",
+ "model": "gemini-3.8-live",
"voice": "Puck",
"modalities": ["AUDIO"],
"vad": {
@@ -167,7 +173,7 @@ Client → Server:
}
```
-The realtime client makes its own thread id when you do not pass one, and `/v1/graph/live` accepts it even with `runs.thread_ids` set to `"server"`. The caller owns that new thread.
+`/v1/graph/live` follows `runs.thread_ids` like the other routes. With the default `"server"`, a `thread_id` the server never issued is refused with a `not_authorized` error event and close code `1008`. Omit `thread_id` (or send `"new"` or a blank value) and the server creates the thread. Every session starts with a `session_started` event that carries the id, so send that id back on reconnect to continue the thread. The TypeScript client does this for you and exposes the id as `session.threadId`.
The `model` override is honored only if it is listed in `websocket.realtime_models`; otherwise it is ignored and the agent's own model is used. A bad value (for example an invalid `modalities` entry) returns a fatal `error` event with code `invalid_config`.
@@ -189,7 +195,7 @@ WebSocket behavior is controlled by keys in the `websocket` object within `10xgr
|---|---|---|---|
| `max_connections` | integer, null | 1000 | Hard cap on concurrent WebSocket connections (both `/v1/graph/ws` and `/v1/graph/live`) across this process. Set to `0` or `null` for unlimited. |
| `max_connections_per_user` | integer, null | 10 | Cap on how many WebSocket connections one verified user can hold (by `user_id`). Set to `0` or `null` for unlimited. |
-| `realtime_models` | array of strings | `[]` | Models a `/v1/graph/live` client is allowed to request (for example `["gemini-2.5-flash-live"]`). Empty means clients cannot override the agent's model. |
+| `realtime_models` | array of strings | `[]` | Models a `/v1/graph/live` client is allowed to request (for example `["gemini-3.8-live"]`). Empty means clients cannot override the agent's model. |
**Example:**
@@ -199,7 +205,7 @@ WebSocket behavior is controlled by keys in the `websocket` object within `10xgr
"websocket": {
"max_connections": 5000,
"max_connections_per_user": 50,
- "realtime_models": ["gemini-2.5-flash-live"]
+ "realtime_models": ["gemini-3.8-live"]
}
}
```
diff --git a/src/content/docs/testing/before-production.md b/src/content/docs/testing/before-production.md
index 5562791..a3c2d65 100644
--- a/src/content/docs/testing/before-production.md
+++ b/src/content/docs/testing/before-production.md
@@ -33,7 +33,7 @@ Evals score real agent behavior. Use `--threshold` to turn them into a release g
10xgraph eval --threshold 0.9
```
-The run exits 1 when the overall pass rate is below `0.9`. Without `--threshold`, the run exits 1 unless every case passes. Reports go to `eval_reports/` as HTML and JSON (`--no-report` skips them). See [run evaluations](/docs/testing/run-evals).
+The run exits 1 when the overall pass rate is below `0.9`. Without `--threshold`, the run exits 1 unless every case passes. Reports go to `eval_reports/` as JSON (`--no-report` skips it). See [run evaluations](/docs/testing/run-evals).
## 3. Audit the environment
@@ -55,7 +55,7 @@ Read the startup log. Examples of what the server refuses or changes:
- `ORIGINS` or `ALLOWED_HOST` left as `*` stops startup while `STRICT_PRODUCTION_CHECKS` is on, which is the default in production. `ALLOWED_HOST` defaults to `*`, so set it. Set exact values, or `STRICT_PRODUCTION_CHECKS=false` to downgrade the refusal to a warning.
- Wildcard `ORIGINS` together with credentials is refused. Set explicit origins or `CORS_ALLOW_CREDENTIALS=false`.
-- `IS_DEBUG=true` is ignored and the interactive docs paths are turned off.
+- Debug mode is off and the interactive docs paths are turned off.
- With auth on, a setup whose checkpointer cannot resolve thread owners does not boot.
This check does not catch `auth: null`. A server with no auth starts open even in production, and nothing refuses it. Confirm that auth is configured with the next step. See the [production checklist](/docs/server/production-checklist) and [what 10xGraph does not protect](/docs/production/limits).
diff --git a/src/content/docs/testing/evaluation.md b/src/content/docs/testing/evaluation.md
index ccee39c..2ddd4c3 100644
--- a/src/content/docs/testing/evaluation.md
+++ b/src/content/docs/testing/evaluation.md
@@ -105,7 +105,7 @@ Put eval files in an `evals/` directory and run the command:
10xgraph eval
```
-The CLI discovers `*_eval.py` and `eval_*.py` files, loads your agent from `10xgraph.json`, runs all cases, and writes HTML and JSON reports to `eval_reports/` automatically. For full CLI options, see [How to run evaluations](/docs/testing/run-evals).
+The CLI discovers `*_eval.py` and `eval_*.py` files, loads your agent from `10xgraph.json`, runs all cases, and writes JSON and HTML reports to `eval_reports/` automatically and opens the HTML report in your browser (`--no-open` to skip). The playground can also open the JSON (Evals > Open report file). For full CLI options, see [How to run evaluations](/docs/testing/run-evals).
---
@@ -186,7 +186,7 @@ See [User simulation](/docs/testing/user-simulation) for the full API and the `g
### Reports
-By default, an evaluation run writes an **HTML visual dashboard** (pass rates, criterion scores, failure details) and a JSON report to `eval_reports/`. JUnit XML for CI integrations is opt-in through `ReporterConfig(junit_xml=True)`.
+By default, `10xgraph eval` writes JSON and HTML reports to `eval_reports/` (pass rates, criterion scores, failure details) and opens the HTML report; the playground Evals page opens the JSON. When you call the library directly, HTML and JUnit XML are opt-in through `ReporterConfig(html=True)` and `ReporterConfig(junit_xml=True)`.
See [Reports](/docs/testing/reports) for output formats and CI setup.
diff --git a/src/content/docs/testing/reports.md b/src/content/docs/testing/reports.md
index f1990b8..dba44a8 100644
--- a/src/content/docs/testing/reports.md
+++ b/src/content/docs/testing/reports.md
@@ -6,7 +6,7 @@ section: "Testing and evaluation"
group: "Evaluation"
order: 90
label: Reports
-updated: "2026-10-08"
+updated: "2026-10-30"
---
After every evaluation run, 10xGraph generates structured reports so you can understand what passed, what failed, and why. Reports are always generated by default, no flags required.
@@ -17,8 +17,8 @@ After every evaluation run, 10xGraph generates structured reports so you can und
| Format | File | Best for |
|---|---|---|
-| HTML | `eval_reports/_.html` | Human review, visual dashboard |
-| JSON | `eval_reports/_.json` | Programmatic analysis, dashboards |
+| HTML | `eval_reports/_.html` | Human review, scorecard (written and opened by `10xgraph eval`; opt-in with `ReporterConfig(html=True)` in the library) |
+| JSON | `eval_reports/_.json` | Programmatic analysis, dashboards; the file `10xgraph eval` writes and the playground Evals page opens |
| JUnit XML | `eval_reports/__junit.xml` | CI systems (GitHub Actions test summary) |
| Console | stdout | Quick local feedback during development |
@@ -26,29 +26,34 @@ After every evaluation run, 10xGraph generates structured reports so you can und
## HTML report
-The HTML report is a self-contained dashboard that opens in any browser. It contains:
+`10xgraph eval` writes a self-contained HTML scorecard next to the JSON report, and library code gets the same file with `ReporterConfig(html=True)`. It is one file with no external requests, so it opens offline from disk. From top to bottom:
-- **Summary header**: eval set name, total cases, passed/failed count, overall pass rate
-- **Criterion bar chart**: a breakdown of scores for each active criterion across all cases
-- **Per-case detail**: expandable sections showing the query, actual response, expected response, and score for each criterion per case
+- **Verdict**: "Gate passed" or "Gate failed", with the pass rate against the gate. The gate is the `--threshold` value (for example "the gate is 62%"). Without a threshold the gate is every case ("the gate requires every case to pass"), which matches the exit code of `10xgraph eval`. A report built outside the CLI has no recorded threshold and uses the same every-case wording.
+- **Key numbers**: pass rate, average score (the mean of all criterion scores), latency per case and tokens, each with its change from the previous report.
+- **Regression line**: cases that newly fail, newly pass, still fail, are new or were removed since the previous report.
+- **Scorecard matrix**: one row per case, one column per criterion. Each cell shows a mark and the score: a check for a pass, a half circle for a pass within 0.10 of the threshold, a cross for a fail, a dash when the criterion did not run. A corner mark flags cells that changed since the previous report. Filter by status, search cases, and sort by failures, lowest score, slowest, most tokens or changed first.
+- **Case drawer**: click a row (or press Enter on it) to see the input, expected and actual output or the simulation conversation, each criterion's score against its threshold with the judge's reasoning, tool calls, node order, trajectory and tokens. Use the arrow buttons or the down and up keys to move between cases and Esc to close. The URL fragment is the case id, so `report.html#london_weather` opens that case.
+- **Run over run, cost and latency, run details**: per-criterion score bars against the previous report, per-case duration and token bars, and collapsed token, criteria-per-file and configuration tables.
-Open it manually:
+The page has light and dark themes (a toggle in the header), a phone layout and a print layout.
-```bash
-open eval_reports/weather-agent-regression_20260519_142301.html
-```
+### Comparison with the previous report
-Or open automatically after the run:
+The report finds its own baseline: the most recent earlier JSON report with the same eval set id in the folder the HTML file is written to. The JSON file written by the current run is never used. If there is none, the page says so and shows no deltas. Keep the JSON reports in the same folder (the default `eval_reports/` does this) to get comparisons. Durations recorded as 0.0s in older simulation reports cannot be compared, so the latency delta is shown as not comparable in that case.
+
+Report content (model output, user input, tool results) is treated as untrusted: it is embedded as data, rendered as text, and a Content-Security-Policy in the file blocks scripts that do not come from the report itself.
+
+Open it manually:
```bash
-10xgraph eval --open
+open eval_reports/weather-agent-regression_20260519_142301.html
```
---
## JSON report
-The JSON report contains every piece of data from the run in a machine-readable format. You can consume it in post-processing scripts, ingest it into a metrics dashboard, or archive it for trend analysis.
+The JSON report contains every piece of data from the run in a machine-readable format. `10xgraph eval` writes it next to the HTML report. To view it in the playground: Evals > Open report file (file picker or drag and drop). The page can also download a Markdown report. You can consume it in post-processing scripts, ingest it into a metrics dashboard, or archive it for trend analysis.
Structure (simplified):
@@ -293,11 +298,11 @@ A case passes only when **all enabled criteria** meet their thresholds. The pass
- `1.0` (100%): every criterion passed for every case
- `< 1.0`: at least one case has at least one criterion below threshold
-The `10xgraph eval` CLI exits with code `1` whenever the pass rate is below `1.0`. `--threshold` adds an extra error message when the pass rate is below the given value, but it does not relax the exit code: only a 100% pass rate exits `0`.
+The `10xgraph eval` CLI exits with code `0` when the pass rate meets the gate and `1` otherwise. Without `--threshold` the gate is `1.0`, so only a clean run passes. With `--threshold 0.8`, a run at 80% or more exits `0`. The gate is recorded in the report metadata as `pass_rate_threshold`, with `pass_rate_threshold_source` set to `threshold` or `default`, and the HTML report shows it in its verdict.
### Diagnosing failures
-1. Open the HTML report and look at the per-case criterion bars.
+1. Open the JSON report in the playground Evals page (or the HTML report that `10xgraph eval` opens) and look at the matrix for the crosses and half circles, then open the case.
2. Find which criterion scored lowest.
3. If it is a trajectory criterion, the trajectory section shows which tool calls were expected vs actual.
4. If it is an LLM criterion, the detail section shows the judge's reasoning (when `include_details=True`).
diff --git a/src/content/docs/testing/run-evals.md b/src/content/docs/testing/run-evals.md
index 439c07c..ded915f 100644
--- a/src/content/docs/testing/run-evals.md
+++ b/src/content/docs/testing/run-evals.md
@@ -6,7 +6,7 @@ section: "Testing and evaluation"
group: "Evaluation"
order: 100
label: Run Evaluations
-updated: "2026-10-08"
+updated: "2026-10-30"
---
## What is evaluation and why it matters
@@ -29,17 +29,16 @@ From the folder that contains `10xgraph.json`:
10xgraph eval
```
-The CLI scans `evals/` for files matching `*_eval.py` or `eval_*.py`, collects every case from every file into a single pool, runs them all, and writes timestamped reports to `eval_reports/`:
+The CLI scans `evals/` for files matching `*_eval.py` or `eval_*.py`, collects every case from every file into a single pool, runs them all, and writes timestamped JSON and HTML reports to `eval_reports/`:
```
eval_reports/
- _20260513_142301.html
_20260513_142301.json
```
`EvalSetBuilder` assigns a random UUID as the eval set id, so that is what appears in the file name for a single set. When a run covers more than one eval set, the results are merged into one report named `combined_eval_`.
-The HTML report shows a visual dashboard: summary pass rate, criterion scores, and per-case results. The JSON report is machine-readable for CI systems or custom analysis. A progress display updates as cases complete.
+The JSON report is machine-readable for CI systems or custom analysis. After the run the command prints the HTML and JSON paths and opens the HTML report in your default browser. Pass `--no-open` to skip that; it is also skipped with `--no-report`, `-q`, `--format json` or `jsonl`, `--non-interactive`, when `CI` is set, and when stdout is not a terminal. The playground Evals page shows the summary pass rate, criterion scores, and per-case results, and can download a Markdown report. A progress display updates as cases complete.
## Running evaluations
@@ -78,34 +77,29 @@ You can enable parallel by default in `10xgraph.json` (see [Configure evaluation
### Reports and output
-Every run produces two files in `eval_reports/` (or a custom directory via `--output`):
+Every run produces one JSON file and one HTML file in `eval_reports/` (or a custom directory via `--output`):
| File | Format | Purpose |
| --- | --- | --- |
-| `_.html` | HTML + interactive | Visual dashboard with summary, criterion bars, and per-case details for humans to review |
-| `_.json` | JSON | Machine-readable results for CI tooling, dashboards, or programmatic analysis |
+| `_.json` | JSON | Machine-readable results for CI tooling, dashboards, or programmatic analysis. Open it in the playground Evals page for review |
-The progress display shows each case as `::` with its status and duration. Both report files are written after all cases finish. To skip writing files and see only console output:
+The progress display shows each case as `::` with its status and duration. The report file is written after all cases finish. To skip writing files and see only console output:
```bash
10xgraph eval --no-report
```
-To open the HTML report automatically in your browser:
-
-```bash
-10xgraph eval --open
-```
+The HTML report opens automatically. To review the JSON report instead, open the playground, go to Evals, and choose Open report file (or drag the JSON file onto the page). Nothing is served by the API, so no server is needed for viewing. The page can also download a Markdown report.
### Exit codes and thresholds
-The command exits with code 0 only when every case passes (100% pass rate) and 1 otherwise. For CI this already gates merges on any regression.
+The command exits with code 0 when the pass rate meets the gate and 1 otherwise. Without `--threshold` the gate is 100%, so only a run where every case passes exits 0. For CI this already gates merges on any regression.
```bash
10xgraph eval --threshold 0.8
```
-`--threshold` makes the command print an error ("Pass rate ... is below threshold ...") when the overall pass rate is under 80%. It does not relax the exit code: a run at 90% still exits 1. The threshold can also be set in `10xgraph.json` (see below).
+`--threshold` sets the gate: the command exits 0 at 80% or more, and otherwise prints an error ("Pass rate ... is below threshold ...") and exits 1. The HTML report states the gate in its verdict. The threshold can also be set in `10xgraph.json` (see below).
### Custom output directory
@@ -135,10 +129,11 @@ Add an `evaluation` block to `10xgraph.json` to set project-level defaults. CLI
| Field | Default | Description |
| --- | --- | --- |
| `directory` | `"evals"` | Directory scanned when no `TARGET` argument is given |
-| `output_dir` | `"eval_reports"` | Directory where HTML and JSON reports are written |
-| `threshold` | (none) | Pass rate below which an error message is printed; the exit code is 1 for any pass rate under 100% |
+| `output_dir` | `"eval_reports"` | Directory where the JSON and HTML reports are written |
+| `threshold` | (none) | Pass-rate gate: below it the command prints an error and exits 1. Without it the gate is 100% |
| `parallel` | `false` | Run all cases concurrently instead of sequentially |
| `max_concurrency` | `4` | Maximum cases running simultaneously when `parallel` is true |
+| `open_report` | `true` | Open the HTML report in the browser when the run ends; `--open` / `--no-open` overrides it |
Report filenames always include a timestamp; there is no configuration setting for it.
@@ -413,13 +408,13 @@ A CLI flag overrides `10xgraph.json` for run settings. For criteria, the eval fi
## Common scenarios and recipes
-**Fast local check on a single file, open the report:**
+**Fast local check on a single file:**
```bash
-10xgraph eval evals/weather_eval.py --open
+10xgraph eval evals/weather_eval.py
```
-Run one file sequentially, write the report, and open it in your browser for inspection.
+Run one file sequentially, and write the JSON report; open it in the playground Evals page for inspection.
**Parallel run with 8 concurrent cases:**
@@ -494,7 +489,7 @@ A file was found but does not expose any recognized entry point. Add `get_eval_s
**Exit code 1 although most cases pass**
-The exit code is 1 whenever the pass rate is below 100%, including cases that errored. Check the summary line and the HTML report for cases with status `error`.
+The exit code is 1 whenever the pass rate is below 100%, including cases that errored. Check the summary line and the report in the playground Evals page for cases with status `error`.
**Simulator scenarios always fail**
diff --git a/src/content/docs/testing/user-simulation.md b/src/content/docs/testing/user-simulation.md
index f108902..39da545 100644
--- a/src/content/docs/testing/user-simulation.md
+++ b/src/content/docs/testing/user-simulation.md
@@ -108,7 +108,7 @@ Run it:
10xgraph eval --parallel --max-concurrency 4
```
-The CLI detects `get_scenarios()` (or a `SCENARIOS` module-level constant), runs each scenario with a `SimulationGoalsCriterion` attached automatically (threshold 0.7, one judge sample), and produces the same HTML + JSON report as regular eval cases. Simulation scenarios and regular eval cases are pooled together in the same report.
+The CLI detects `get_scenarios()` (or a `SCENARIOS` module-level constant), runs each scenario with a `SimulationGoalsCriterion` attached automatically (threshold 0.7, one judge sample), and produces the same HTML + JSON report as regular eval cases. Simulation scenarios and regular eval cases are pooled together in the same report. While a scenario runs, the CLI prints a muted line per step (each simulated user message, the agent reply, newly achieved goals, and the criteria being judged), and the result line shows the real duration. `-q` hides these lines.
For full details on CLI flags, parallel execution, and integration into CI, see [How to run evaluations](/docs/testing/run-evals).
diff --git a/src/content/docs/troubleshooting/api-server.md b/src/content/docs/troubleshooting/api-server.md
index 95b23e6..a215842 100644
--- a/src/content/docs/troubleshooting/api-server.md
+++ b/src/content/docs/troubleshooting/api-server.md
@@ -84,7 +84,7 @@ async def my_endpoint(
return {"ok": True}
```
-Only three paths are public: `/ping`, `/v1/evals/runs`, and `/v1/evals/runs/{run_id}`. Modifying the public allowlist requires editing the source code frozenset, which is intentional. Do not use it to silence this error on a route that handles user data.
+Only `/ping` is public. Modifying the public allowlist requires editing the source code frozenset, which is intentional. Do not use it to silence this error on a route that handles user data.
### Issue: `InsecureCorsConfigError` on startup
@@ -266,7 +266,7 @@ A common case: switching to the RBAC backend. `RoleBasedAuthorizationBackend` al
| `1003` | Invalid init frame on `/v1/graph/live`: not JSON, or JSON that is not an object | Send the init control frame as a JSON object first, before any audio |
| `1011` | Unexpected server error during the session | Check server logs; this is not an auth or config rejection |
-A `404` on `/v1/evals/*` means the server runs with `MODE=production`: the eval report viewer has no auth, so it is mounted only outside production. A `404` on `POST /v1/ag-ui` means `ag_ui.enabled` is not `true` in `10xgraph.json`. The threads, store, and files routers are always mounted.
+A `404` on `POST /v1/ag-ui` means `ag_ui.enabled` is not `true` in `10xgraph.json`. The threads, store, and files routers are always mounted.
A `1008` accompanied by an `error` event with `code: "not_live"` or `"not_authorized"` tells you which of the two `1008` causes applies.
diff --git a/src/content/docs/troubleshooting/client.md b/src/content/docs/troubleshooting/client.md
index fa60956..ece6846 100644
--- a/src/content/docs/troubleshooting/client.md
+++ b/src/content/docs/troubleshooting/client.md
@@ -160,7 +160,7 @@ If `checkpointer` is `false`, configure one: see [Set up checkpointing](/docs/gu
**Likely causes**
- Different routes: `invoke()` calls `POST /v1/graph/invoke`, `stream()` calls `POST /v1/graph/stream`.
-- A hand-written parser expects SSE `data:` frames. The stream endpoint is sent with `Content-Type: text/event-stream`, but the client parses it as newline-delimited JSON chunks.
+- A hand-written parser expects SSE `data:` frames. The stream endpoint is sent with `Content-Type: application/x-ndjson`, and the client parses it as newline-delimited JSON chunks.
- A proxy buffers the response or times out long-lived connections.
**Fix**
diff --git a/src/content/docs/troubleshooting/providers.md b/src/content/docs/troubleshooting/providers.md
index 656bcef..bf62262 100644
--- a/src/content/docs/troubleshooting/providers.md
+++ b/src/content/docs/troubleshooting/providers.md
@@ -10,7 +10,7 @@ faq:
- q: "Which SDK extra do I need?"
a: "Install the extra for your provider: 10xgraph[openai], 10xgraph[google-genai] or 10xgraph[anthropic]. For Claude on Vertex AI or Bedrock use 10xgraph[anthropic-vertex] or 10xgraph[anthropic-bedrock]."
- q: "Why does my LLM call time out?"
- a: "The default request timeout is 600 seconds. Raise it with the AGENTFLOW_LLM_TIMEOUT environment variable (in seconds), or pass timeout to Agent for OpenAI and Anthropic."
+ a: "The default request timeout is 600 seconds. Raise it with the TENXGRAPH_LLM_TIMEOUT environment variable (in seconds), or pass timeout to Agent for OpenAI and Anthropic."
- q: "Do I need to enable retries for rate limits?"
a: "No. Agent retries 429, 500, 502, 503 and 529 responses by default with exponential backoff. Pass a RetryConfig to change the limits."
---
@@ -27,7 +27,7 @@ When an agent fails at the provider boundary, the cause is almost always one of
| Model not found | Check the exact model id in the provider docs |
| 429 or 529 responses | Keep retries on, or tune `RetryConfig` |
| `GOOGLE_CLOUD_PROJECT environment variable must be set` | Export `GOOGLE_CLOUD_PROJECT` for Vertex AI |
-| Request times out | Raise `AGENTFLOW_LLM_TIMEOUT` or pass `timeout` |
+| Request times out | Raise `TENXGRAPH_LLM_TIMEOUT` or pass `timeout` |
@@ -209,12 +209,12 @@ Every LLM client is built with a request timeout of 600 seconds. If a call excee
The timeout is resolved in this order, first match wins:
1. A value set with `set_default_llm_timeout(seconds)` from `tenxgraph.core.llm.client_factory`.
-2. The `AGENTFLOW_LLM_TIMEOUT` environment variable, in seconds. Invalid or non-positive values are ignored with a warning.
+2. The `TENXGRAPH_LLM_TIMEOUT` environment variable, in seconds. Invalid or non-positive values are ignored with a warning. The old `AGENTFLOW_LLM_TIMEOUT` is still read as a deprecated fallback when `TENXGRAPH_LLM_TIMEOUT` is unset or empty. If `TENXGRAPH_LLM_TIMEOUT` is set but invalid or not positive, it is ignored with a warning and the built-in default applies; the old variable is not consulted.
3. The built-in default of 600 seconds.
```bash
# Allow up to 30 minutes per request
-export AGENTFLOW_LLM_TIMEOUT=1800
+export TENXGRAPH_LLM_TIMEOUT=1800
```
For OpenAI and Anthropic you can also pass `timeout` to a single agent, which overrides the default for that client: