diff --git a/src/content/docs-es/runs.mdx b/src/content/docs-es/runs.mdx
index 75ed923..82e71d5 100644
--- a/src/content/docs-es/runs.mdx
+++ b/src/content/docs-es/runs.mdx
@@ -302,106 +302,43 @@ Un **token** es lo que conviene darle a un script que solo gestiona runs. No sir
- Cada uno caduca a los **30, 90 o 365 días**, o nunca.
- Puedes revocar cualquiera en cualquier momento.
-Para crear runs sigue haciendo falta un plan con inferencia, uses la credencial que uses.
+Para crear runs sigue haciendo falta un plan con inferencia, uses la credencial que uses. La [referencia de la API](/es/docs/api#description/platform-tokens) indica qué credencial acepta cada endpoint.
La clave que hay dentro de un workspace, la que usan sus agentes para la inferencia, no puede gestionar runs: la API le responde con un `403`. Un agente que trabaja en tu workspace no puede lanzar runs nuevos por su cuenta, salvo que le des una de tus propias API keys o tokens.
-### Crea un run
+### Crea un run y síguelo
+
+Un ejemplo de principio a fin: crea un run, guarda su `id` (con [`jq`](https://jqlang.org/)) y sigue su log en directo hasta que termine. Si el run no se puede crear, muestra el error en lugar de seguir el log.
```bash
export NAN_TOKEN="nan_pat_..."
-curl https://api.nan.builders/v1/runs \
+RESP=$(curl -s https://api.nan.builders/v1/runs \
-H "Authorization: Bearer $NAN_TOKEN" \
-H "Content-Type: application/json" \
- -H "Idempotency-Key: fix-date-parser-1" \
-d '{
"workspace": "develop",
"agent": "pi",
"prompt": "añade tests al parser de fechas y haz que pasen",
- "cwd": "/home/nan/projects/api",
- "git_isolation": null,
- "timeout_seconds": 1800
- }'
-```
-
-| Campo | Obligatorio | Qué es |
-|---|---|---|
-| `workspace` | Sí | Nombre o id del workspace. |
-| `agent` | Sí | `pi` o `hermes`. |
-| `prompt` | Sí | La tarea, hasta 32 KiB. |
-| `cwd` | No | La carpeta, `/home/nan` por defecto. |
-| `git_isolation` | No | Rama aparte: `null` para auto (por defecto), `true` para on, `false` para off. |
-| `timeout_seconds` | No | El tiempo máximo, de 60 a 7200. 1800 (30 minutos) por defecto. |
-| `model` | No | El modelo del agente. Por defecto, el suyo. |
-
-Los campos equivalen al formulario del portal: **Agent** es `agent`, **Task** es `prompt`, **Folder** es `cwd`, **Separate branch** es `git_isolation` y **Time limit** es `timeout_seconds`.
-
-La respuesta es el run, con su `id` y su `state`. La cabecera `Idempotency-Key` es opcional: si un reintento manda la misma clave con el mismo cuerpo, recibes el run que ya se creó en lugar de uno nuevo.
-
-### Síguelo
-
-Los eventos de un run llegan como un stream de server-sent events cuando pides `text/event-stream`. Con `curl -N` los ves según llegan:
-
-```bash
-curl -N https://api.nan.builders/v1/runs/$RUN_ID/events \
- -H "Authorization: Bearer $NAN_TOKEN" \
- -H "Accept: text/event-stream"
-```
-
-Cada evento del stream es uno de estos:
-
-- `event: run_event`, con una línea `id:`: un evento del log (un mensaje, una llamada a una herramienta, su resultado...). Manda el último `id` que hayas visto en la cabecera `Last-Event-ID` para retomar tras un corte, sin perder ni repetir nada.
-- `event: state`: el run ha cambiado de estado.
-- `event: end`: el run ha terminado y su log se ha enviado entero. Los datos son el run terminado. El stream se cierra después.
-
-Cada 15 segundos el stream manda una línea de comentario para mantener la conexión abierta. Un stream dura como mucho 30 minutos: si se cierra antes del `end`, vuelve a conectarte con `Last-Event-ID` y sigue donde lo dejaste. Sin la cabecera `Accept`, el mismo endpoint responde con páginas de JSON: pasa el `next_after` de cada página como `?after=` para pedir la siguiente.
-
-Para ver cómo ha terminado:
-
-```bash
-curl https://api.nan.builders/v1/runs/$RUN_ID \
- -H "Authorization: Bearer $NAN_TOKEN"
+ "cwd": "/home/nan/projects/api"
+ }')
+RUN_ID=$(echo "$RESP" | jq -r '.id // empty')
+if [ -z "$RUN_ID" ]; then
+ echo "$RESP"
+else
+ curl -N "https://api.nan.builders/v1/runs/$RUN_ID/events" \
+ -H "Authorization: Bearer $NAN_TOKEN" \
+ -H "Accept: text/event-stream"
+fi
```
-### Cancélalo
-
-```bash
-curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/cancel \
- -H "Authorization: Bearer $NAN_TOKEN"
-```
+Los campos equivalen al formulario del portal: **Agent** es `agent`, **Task** es `prompt`, **Folder** es `cwd`, y los opcionales **Separate branch** y **Time limit** son `git_isolation` y `timeout_seconds`.
-### Lista tus runs
+El stream manda un frame `run_event` por cada evento del log, un frame `state` cuando el run cambia de estado y un frame `end` con el run terminado, y después se cierra.
-```bash
-curl "https://api.nan.builders/v1/runs?workspace=develop&state=running&limit=20" \
- -H "Authorization: Bearer $NAN_TOKEN"
-```
-
-Todos los filtros son opcionales. Si hay más runs de los que caben en una página, la respuesta trae un `next_cursor`: pásalo como `?cursor=` para pedir la siguiente.
-
-### Errores
-
-Los errores usan el mismo formato que la API de inferencia:
-
-```json
-{"error": {"message": "...", "type": "...", "param": null, "code": "tier_restricted"}}
-```
-
-| Estado y código | Qué hacer |
-|---|---|
-| `400 invalid_request` | Hay un campo incorrecto. `param` te dice cuál. |
-| `403 tier_restricted` | Tu plan no incluye inferencia. Mejóralo para crear runs. |
-| `403 workspace_key_not_allowed` | Has usado la clave de dentro de un workspace. Usa una API key o un token. |
-| `404` | El workspace o el run no existe, o no es tuyo. |
-| `409 idempotency_conflict` | Has repetido una `Idempotency-Key` con otro cuerpo. Usa una clave nueva. |
-| `409 workspace_not_running` | Enciende primero el workspace. |
-| `409 agent_not_installed` | Instala el agente desde el panel de **Software**, o elige el otro. |
-| `409 no_inference_key` | El workspace no tiene clave de inferencia. Comprueba que tu plan incluye inferencia. |
-| `429 run_queue_full` | La cola está llena (20 por workspace, 50 en total). Espera a que terminen algunos runs. |
-| `429 rate_limited` | Más de 60 runs en la última hora. Vuelve a intentarlo más tarde. |
+Referencia completa: [Referencia de la API → Runs](/es/docs/api#tag/runs). Ahí están todos los endpoints (listar, consultar, cancelar), los campos de cada petición y respuesta, los tipos de evento, cómo retomar un stream, los reintentos idempotentes y todos los códigos de error. Para sacar el nombre o el id de un workspace desde un script, consulta [Referencia de la API → Workspaces](/es/docs/api#tag/workspaces).
## Preguntas frecuentes
diff --git a/src/content/docs/runs.mdx b/src/content/docs/runs.mdx
index 1fedabe..b3a3e04 100644
--- a/src/content/docs/runs.mdx
+++ b/src/content/docs/runs.mdx
@@ -302,106 +302,43 @@ A **token** is the one to give a script that only manages runs. It cannot call t
- Each one expires after **30, 90 or 365 days**, or never.
- You can revoke any of them at any time.
-Creating runs still needs a plan with inference, whatever credential you use.
+Creating runs still needs a plan with inference, whatever credential you use. The [API reference](/docs/api#description/platform-tokens) lists which credential each endpoint accepts.
The key inside a workspace, the one its agents use for inference, cannot manage runs: the API answers it with `403`. An agent working in your workspace cannot start new runs on its own, unless you give it one of your own API keys or tokens.
-### Create a run
+### Create a run and follow it
+
+An end-to-end example: create a run, keep its `id` (with [`jq`](https://jqlang.org/)) and follow its log live until it ends. If the run cannot be created, it prints the error instead of following the log.
```bash
export NAN_TOKEN="nan_pat_..."
-curl https://api.nan.builders/v1/runs \
+RESP=$(curl -s https://api.nan.builders/v1/runs \
-H "Authorization: Bearer $NAN_TOKEN" \
-H "Content-Type: application/json" \
- -H "Idempotency-Key: fix-date-parser-1" \
-d '{
"workspace": "develop",
"agent": "pi",
"prompt": "add tests for the date parser and make them pass",
- "cwd": "/home/nan/projects/api",
- "git_isolation": null,
- "timeout_seconds": 1800
- }'
-```
-
-| Field | Required | What it is |
-|---|---|---|
-| `workspace` | Yes | Name or id of the workspace. |
-| `agent` | Yes | `pi` or `hermes`. |
-| `prompt` | Yes | The task, up to 32 KiB. |
-| `cwd` | No | The folder, `/home/nan` by default. |
-| `git_isolation` | No | Separate branch: `null` for auto (the default), `true` for on, `false` for off. |
-| `timeout_seconds` | No | The time limit, from 60 to 7200. 1800 (30 minutes) by default. |
-| `model` | No | The model for the agent. Its own by default. |
-
-The fields match the portal form: **Agent** is `agent`, **Task** is `prompt`, **Folder** is `cwd`, **Separate branch** is `git_isolation` and **Time limit** is `timeout_seconds`.
-
-The answer is the run, with its `id` and its `state`. The `Idempotency-Key` header is optional: if a retry sends the same key with the same body, you get the run that was already created instead of a second one.
-
-### Follow it
-
-The events of a run come as a stream of server-sent events when you ask for `text/event-stream`. With `curl -N` you see them as they arrive:
-
-```bash
-curl -N https://api.nan.builders/v1/runs/$RUN_ID/events \
- -H "Authorization: Bearer $NAN_TOKEN" \
- -H "Accept: text/event-stream"
-```
-
-Each frame is one of these:
-
-- `event: run_event`, with an `id:` line: one event of the log (a message, a tool call, its result...). Send the last `id` you saw as the `Last-Event-ID` header to resume after a dropped connection, without missing or repeating anything.
-- `event: state`: the run changed state.
-- `event: end`: the run is over and its log has been sent in full. The data is the finished run. The stream closes after it.
-
-Every 15 seconds the stream sends a comment line to keep the connection open. A stream lasts 30 minutes at most: if it closes before the `end`, reconnect with `Last-Event-ID` and carry on. Without the `Accept` header, the same endpoint answers pages of JSON instead: pass the `next_after` of each page as `?after=` to get the next one.
-
-To see how it ended:
-
-```bash
-curl https://api.nan.builders/v1/runs/$RUN_ID \
- -H "Authorization: Bearer $NAN_TOKEN"
+ "cwd": "/home/nan/projects/api"
+ }')
+RUN_ID=$(echo "$RESP" | jq -r '.id // empty')
+if [ -z "$RUN_ID" ]; then
+ echo "$RESP"
+else
+ curl -N "https://api.nan.builders/v1/runs/$RUN_ID/events" \
+ -H "Authorization: Bearer $NAN_TOKEN" \
+ -H "Accept: text/event-stream"
+fi
```
-### Cancel it
-
-```bash
-curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/cancel \
- -H "Authorization: Bearer $NAN_TOKEN"
-```
+The fields match the portal form: **Agent** is `agent`, **Task** is `prompt`, **Folder** is `cwd`, and the optional **Separate branch** and **Time limit** are `git_isolation` and `timeout_seconds`.
-### List your runs
+The stream sends one `run_event` frame per event of the log, a `state` frame when the run changes state, and an `end` frame with the finished run, after which it closes.
-```bash
-curl "https://api.nan.builders/v1/runs?workspace=develop&state=running&limit=20" \
- -H "Authorization: Bearer $NAN_TOKEN"
-```
-
-All filters are optional. If there are more runs than fit in a page, the answer has a `next_cursor`: pass it as `?cursor=` to get the next page.
-
-### Errors
-
-Errors use the same format as the inference API:
-
-```json
-{"error": {"message": "...", "type": "...", "param": null, "code": "tier_restricted"}}
-```
-
-| Status and code | What to do |
-|---|---|
-| `400 invalid_request` | A field is wrong. `param` says which one. |
-| `403 tier_restricted` | Your plan does not include inference. Upgrade it to create runs. |
-| `403 workspace_key_not_allowed` | You used the key from inside a workspace. Use an API key or a token. |
-| `404` | The workspace or the run does not exist, or is not yours. |
-| `409 idempotency_conflict` | You reused an `Idempotency-Key` with a different body. Use a new key. |
-| `409 workspace_not_running` | Start the workspace first. |
-| `409 agent_not_installed` | Install the agent from the **Software** panel, or pick the other one. |
-| `409 no_inference_key` | The workspace has no inference key. Check that your plan includes inference. |
-| `429 run_queue_full` | The queue is full (20 per workspace, 50 in total). Wait for some runs to end. |
-| `429 rate_limited` | More than 60 runs in the last hour. Try again later. |
+Full reference: [API reference → Runs](/docs/api#tag/runs). It has every endpoint (list, retrieve, cancel), the request and response fields, the event types, how to resume a stream, idempotent retries and every error code. To find a workspace's name or id from a script, see [API reference → Workspaces](/docs/api#tag/workspaces).
## FAQ
diff --git a/src/data/openapi.json b/src/data/openapi.json
index 0a75d77..91e6e96 100644
--- a/src/data/openapi.json
+++ b/src/data/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "NaN API",
"version": "1.0.0",
- "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key, and that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"deepseek-v4-flash\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. `/chat/completions` streams incrementally on every chat model. `/responses` streams incrementally only on `deepseek-v4-flash`; on `qwen3.6` and `gemma4` it holds the whole answer and sends it in one burst at the end.\n\n## Rate limits\n\n{{RATE_LIMITS}}\n\nImage endpoints run on their own budget, separate from the model endpoints: 20 requests per minute and 100 requests per month. The usage endpoint is metered separately too: {{USAGE_RATE_LIMIT}} requests per minute per member. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request (a missing parameter, an invalid key, an unavailable model) and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); content blocked by the safety filter; a `max_tokens` above what the model can generate (a generic \"Invalid request\" message today); or a request that does not fit the model's context window. On the models hosted on the cluster (`qwen3.6`, `gemma4`) the overflow message is \"Context length exceeded for model '...'\" with the model's limit; on the other models it is usually that text, but it can arrive as the generic \"Invalid request. Check your request parameters.\" | `\"400\"` (with `type: invalid_request_error`) · `content_policy_violation` |\n| `401` | Missing or invalid API key, or a key whose tier does not reach the requested model (`glm5.3`): \"This API key does not have access to the requested model\", `type: auth_error`. Measured 2026-09-12. | `invalid_api_key` |\n| `402` | The token allowance is spent on a model that carries one. Not retryable: the counter returns to zero when that model's quota period does, the calendar month for the models counted per month and your billing period for `glm5.3`. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint. Image generation requires inference membership. A model your tier cannot reach answers `401`, not this. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.3`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | (none) |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | (none) |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly (a context overflow is not retried automatically either: shorten the input or pick a model with a larger window): they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either: it clears when that model's quota period resets.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, vision, reasoning | Streaming, tool calling, reasoning, image input, 1M-token context. 3B tokens/month per member |\n| `mimo-v2.6-flash` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.8-flash` | Chat, vision, agents | Streaming, tool calling, reasoning (on by default), vision, 1M-token context. 500M tokens/month per member |\n| `glm5.3-flash` | Chat, vision, agents | Streaming, tool calling, reasoning, vision, 1M-token context. 2B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (opt-in) |\n| `glm5.3` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, image, audio and video input, 1M-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n| `qwen-image-2.1` | Image generation (text→image) | 512-1280 px, 1-4 per request, seed 0-2147483647. 100 images/month per member (shared pool with flux-2-klein) |\n\n`glm5.3` is served only to keys on the GLM 5.3 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints, and we add capabilities without breaking existing fields.",
+ "description": "Open models on a shared EU inference cluster. Zero logs.\n\nThe NaN API is OpenAI-compatible: predictable, resource-oriented URLs, JSON request and response bodies, and standard HTTP verbs and status codes. Point any OpenAI SDK at our base URL and your existing code keeps working. Change the base URL and the API key, and that's it.\n\nOne schema across every model, so you only learn the API once. Change the `model` field to switch models; everything else stays the same.\n\n- Base URL: `https://api.nan.builders/v1`\n- OpenAPI spec: this document. Import it into Postman, Insomnia, or your own tooling.\n\nIf you use the [Helmcode](https://helmcode.com) enterprise service, the base URL is `https://api.helmcode.com/v1` instead. Every other endpoint is identical.\n\n## Authentication\n\nEvery inference request authenticates with an API key, sent as a Bearer token:\n\n```\nAuthorization: Bearer $NAN_API_KEY\n```\n\nYou must be a NaN community member. Generate your key from user settings, under \"API Keys\", on the [platform](https://cloud.nan.builders/). The key is personal and non-transferable. Keep it secret: never embed one in client-side code or commit it to source control. Requests must go over HTTPS; calls over plain HTTP fail.\n\n### Platform tokens\n\nFor scripts that only manage [Runs](#tag/runs) and list your [Workspaces](#tag/workspaces), create a **platform token** (`nan_pat_...`) under \"Tokens\" in your settings, and send it the same way: `Authorization: Bearer $NAN_TOKEN`. A platform token never works for inference: the model endpoints refuse it. An API key works on both. Each endpoint lists the credentials it accepts.\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"deepseek-v4-flash\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. `/chat/completions` streams incrementally on every chat model. `/responses` streams incrementally only on `deepseek-v4-flash`; on `qwen3.6` and `gemma4` it holds the whole answer and sends it in one burst at the end.\n\n## Rate limits\n\n{{RATE_LIMITS}}\n\nImage endpoints run on their own budget, separate from the model endpoints: 20 requests per minute and 100 requests per month. The usage endpoint is metered separately too: {{USAGE_RATE_LIMIT}} requests per minute per member. Exceed any limit and you get a `429`.\n\n## Errors\n\nNaN uses conventional HTTP status codes: `2xx` on success, `4xx` for a problem with the request (a missing parameter, an invalid key, an unavailable model) and `5xx` for a server-side error. Every error returns a JSON body in the OpenAI shape:\n\n```json\n{\n \"error\": {\n \"message\": \"The model 'foo' does not exist.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"model\",\n \"code\": \"model_not_found\"\n }\n}\n```\n\n`message` is human-readable, `param` names the offending field when applicable, and `code` is a short machine-readable string you can branch on.\n\n| Status | Meaning | `code` |\n| --- | --- | --- |\n| `400` | Invalid or malformed parameter (`param` says which); content blocked by the safety filter; a `max_tokens` above what the model can generate (a generic \"Invalid request\" message today); or a request that does not fit the model's context window. On the models hosted on the cluster (`qwen3.6`, `gemma4`) the overflow message is \"Context length exceeded for model '...'\" with the model's limit; on the other models it is usually that text, but it can arrive as the generic \"Invalid request. Check your request parameters.\" | `\"400\"` (with `type: invalid_request_error`) · `content_policy_violation` |\n| `401` | Missing or invalid API key, or a key whose tier does not reach the requested model (`glm5.3`): \"This API key does not have access to the requested model\", `type: auth_error`. Measured 2026-09-12. | `invalid_api_key` |\n| `402` | The token allowance is spent on a model that carries one. Not retryable: the counter returns to zero when that model's quota period does, the calendar month for the models counted per month and your billing period for `glm5.3`. | `monthly_cap_reached` |\n| `403` | Your tier can't access this endpoint. Image generation requires inference membership. A model your tier cannot reach answers `401`, not this. | `tier_restricted` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.3`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | (none) |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | (none) |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly (a context overflow is not retried automatically either: shorten the input or pick a model with a larger window): they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either: it clears when that model's quota period resets.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, vision, reasoning | Streaming, tool calling, reasoning, image input, 1M-token context. 3B tokens/month per member |\n| `mimo-v2.6-flash` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.8-flash` | Chat, vision, agents | Streaming, tool calling, reasoning (on by default), vision, 1M-token context. 500M tokens/month per member |\n| `glm5.3-flash` | Chat, vision, agents | Streaming, tool calling, reasoning, vision, 1M-token context. 2B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (opt-in) |\n| `glm5.3` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, image, audio and video input, 1M-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n| `qwen-image-2.1` | Image generation (text→image) | 512-1280 px, 1-4 per request, seed 0-2147483647. 100 images/month per member (shared pool with flux-2-klein) |\n\n`glm5.3` is served only to keys on the GLM 5.3 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints, plus the two read-only [Workspaces](#tag/workspaces) endpoints under `/api/workspaces`, and we add capabilities without breaking existing fields.",
"contact": {
"name": "NaN",
"url": "https://nan.builders"
@@ -18,7 +18,7 @@
],
"security": [
{
- "bearerAuth": []
+ "apiKey": []
}
],
"tags": [
@@ -57,6 +57,14 @@
{
"name": "Usage",
"description": "Query your own token usage."
+ },
+ {
+ "name": "Runs",
+ "description": "Give an agent (Pi or Hermes) a task inside one of your workspaces and follow it. A run keeps going if the client disconnects. Accepts a member API key or a platform token. Guide: [Agent runs](https://nan.builders/docs/runs)."
+ },
+ {
+ "name": "Workspaces",
+ "description": "Read-only access to your own workspaces, for scripts that need a workspace's name, id or status. Served at `https://api.nan.builders/api/workspaces`, outside `/v1`, to bearer credentials only (no cookies, no browser CORS). Creating, starting, stopping and deleting workspaces is done from the platform, not from this API. Guide: [Workspaces](https://nan.builders/docs/workspaces)."
}
],
"paths": {
@@ -1578,949 +1586,3010 @@
}
]
}
- }
- },
- "components": {
- "securitySchemes": {
- "bearerAuth": {
- "type": "http",
- "scheme": "bearer",
- "description": "Personal, non-transferable API key. Send as `Authorization: Bearer ` on every request."
- }
},
- "schemas": {
- "Model": {
- "type": "object",
- "description": "An available model, in the OpenAI-compatible model shape.",
- "properties": {
- "id": {
- "type": "string",
- "description": "The model's unique slug. Pass this value as the `model` field in your requests.",
- "example": "deepseek-v4-flash"
- },
- "object": {
- "type": "string",
- "enum": [
- "model"
- ],
- "description": "The object type, which is always `model`.",
- "example": "model"
- },
- "created": {
- "type": "integer",
- "description": "The Unix timestamp (in seconds) when the model became available.",
- "example": 1677610602
+ "/runs": {
+ "post": {
+ "operationId": "createRun",
+ "tags": [
+ "Runs"
+ ],
+ "summary": "Create a run",
+ "security": [
+ {
+ "apiKey": []
},
- "owned_by": {
- "type": "string",
- "description": "The organization that owns the model.",
- "example": "nan"
+ {
+ "platformToken": []
}
- }
- },
- "ContentPart": {
- "description": "One part of a multimodal message. Use an array of these as `content` for vision input.",
- "oneOf": [
+ ],
+ "description": "Gives an agent a task in one of your workspaces. The run is queued and starts as soon as there is a free place: a workspace runs 1 (Micro and the free workspace), 2 (Nano), 3 (Basic), 5 (Medium) or 8 (Large) runs at once, never more than your per-member total: at most 5 runs working across all your workspaces (8 on Premium). Extra runs wait, first in, first out.\n\nLimits: up to 20 queued runs per workspace and 50 in total (`429 run_queue_full`), and 60 new runs per rolling hour (`429 rate_limited`).\n\nCreating runs needs a plan with inference (`403 tier_restricted`): the run uses the workspace's inference key and counts against your plan's usage. The workspace must be `running` and have the agent installed.\n\n**Idempotency.** Send an `Idempotency-Key` header to make retries safe: the same key with the same body returns the run already created (`200`), the same key with a different body answers `409 idempotency_conflict`.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`).",
+ "parameters": [
{
- "type": "object",
- "required": [
- "type",
- "text"
- ],
- "properties": {
- "type": {
- "type": "string",
- "enum": [
- "text"
- ]
+ "name": "Idempotency-Key",
+ "in": "header",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9._:-]{1,128}$"
+ },
+ "description": "Optional. 1 to 128 characters of `A-Z a-z 0-9 . _ : -`."
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateRunRequest"
},
- "text": {
- "type": "string"
+ "example": {
+ "workspace": "develop",
+ "agent": "pi",
+ "prompt": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "The run was created (`state: queued`).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Run"
+ },
+ "example": {
+ "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
+ "object": "run",
+ "workspace": {
+ "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop"
+ },
+ "agent": "pi",
+ "model": null,
+ "trigger": "api",
+ "state": "queued",
+ "queue_position": 1,
+ "prompt_preview": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800,
+ "cancel_requested": false,
+ "exit_code": null,
+ "error_code": null,
+ "result": null,
+ "usage": {
+ "tokens_in": null,
+ "tokens_out": null,
+ "source": null
+ },
+ "last_seq": 0,
+ "created_at": "2026-10-10T12:00:00Z",
+ "started_at": null,
+ "finished_at": null
+ }
}
}
},
- {
- "type": "object",
- "required": [
- "type",
- "image_url"
- ],
- "properties": {
- "type": {
- "type": "string",
- "enum": [
- "image_url"
- ]
- },
- "image_url": {
- "type": "object",
- "properties": {
- "url": {
- "type": "string",
- "description": "Public URL or `data:` URI of the image."
- }
+ "200": {
+ "description": "Idempotent replay: the run already created with this `Idempotency-Key` and body.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Run"
+ },
+ "example": {
+ "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
+ "object": "run",
+ "workspace": {
+ "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop"
+ },
+ "agent": "pi",
+ "model": null,
+ "trigger": "api",
+ "state": "queued",
+ "queue_position": 1,
+ "prompt_preview": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800,
+ "cancel_requested": false,
+ "exit_code": null,
+ "error_code": null,
+ "result": null,
+ "usage": {
+ "tokens_in": null,
+ "tokens_out": null,
+ "source": null
+ },
+ "last_seq": 0,
+ "created_at": "2026-10-10T12:00:00Z",
+ "started_at": null,
+ "finished_at": null
}
}
}
- }
- ]
- },
- "Message": {
- "type": "object",
- "required": [
- "role"
- ],
- "description": "A single message in a conversation. The `role` determines which other fields apply.",
- "properties": {
- "role": {
- "type": "string",
- "enum": [
- "system",
- "user",
- "assistant",
- "tool"
- ],
- "description": "The role of the message author: `system`, `user`, `assistant`, or `tool`."
},
- "content": {
- "description": "The contents of the message. A plain string, or an array of content parts for multimodal input. Can be `null` on an `assistant` message that only returns tool calls.",
- "oneOf": [
- {
- "type": "string"
- },
- {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ContentPart"
+ "400": {
+ "description": "A field is invalid; `param` names it (`workspace`, `agent`, `prompt`, `cwd`, `timeout_seconds`, `model`, `trigger`, `Idempotency-Key`, or an unknown field). The body is limited to 128 KiB, and must be one JSON object.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "must be between 60 and 7200",
+ "type": "invalid_request_error",
+ "param": "timeout_seconds",
+ "code": "invalid_request"
+ }
}
}
- ]
+ }
},
- "name": {
- "type": "string",
- "description": "An optional name for the author of this message."
+ "401": {
+ "$ref": "#/components/responses/RunsUnauthorized"
},
- "tool_calls": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ToolCall"
- },
- "description": "The tool calls generated by the model. Present on `assistant` messages when the model decides to invoke one or more tools."
+ "403": {
+ "description": "`tier_restricted`: your plan does not include inference. `workspace_key_not_allowed`: the bearer is the key inside a workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "examples": {
+ "tier_restricted": {
+ "value": {
+ "error": {
+ "message": "your membership does not include agent runs",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "tier_restricted"
+ }
+ }
+ },
+ "workspace_key_not_allowed": {
+ "value": {
+ "error": {
+ "message": "workspace keys cannot manage runs",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "workspace_key_not_allowed"
+ }
+ }
+ }
+ }
+ }
+ }
},
- "tool_call_id": {
- "type": "string",
- "description": "The id of the tool call this message answers. Required when `role` is `tool`."
+ "404": {
+ "description": "No workspace with that name or id among yours.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "workspace not found",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "not_found"
+ }
+ }
+ }
+ }
},
- "reasoning_content": {
- "type": "string",
- "description": "The model's reasoning trace. Present only for reasoning models; safe to display or ignore."
+ "409": {
+ "description": "`workspace_not_running`: start the workspace first. `agent_not_installed`: install the agent from the workspace's Software panel. `no_inference_key`: the workspace has no inference key. `idempotency_conflict`: the `Idempotency-Key` was used with a different body.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "the workspace is not running",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "workspace_not_running"
+ }
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "`run_queue_full`: 20 queued runs in the workspace or 50 in total. `rate_limited`: 60 runs created in the last hour.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "too many queued runs",
+ "type": "rate_limit_exceeded",
+ "param": null,
+ "code": "run_queue_full"
+ }
+ }
+ }
+ }
}
- }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL",
+ "source": "curl https://api.nan.builders/v1/runs \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -H \"Idempotency-Key: fix-date-parser-1\" \\\n -d '{ \"workspace\": \"develop\", \"agent\": \"pi\", \"prompt\": \"add tests for the date parser and make them pass\", \"cwd\": \"/home/nan/projects/api\" }'"
+ }
+ ]
},
- "Tool": {
- "type": "object",
- "required": [
- "type",
- "function"
+ "get": {
+ "operationId": "listRuns",
+ "tags": [
+ "Runs"
],
- "description": "A function the model may call. Provide these in the request's `tools` array.",
- "properties": {
- "type": {
- "type": "string",
- "enum": [
- "function"
- ],
- "description": "The type of the tool. Currently only `function` is supported."
+ "summary": "List runs",
+ "security": [
+ {
+ "apiKey": []
},
- "function": {
- "type": "object",
- "required": [
- "name"
- ],
- "properties": {
- "name": {
- "type": "string",
- "pattern": "^[a-zA-Z0-9_-]{1,64}$",
- "description": "The name of the function to call. Up to 64 characters; letters, digits, underscores and dashes. A name outside that pattern is rejected with a `400` before the request reaches the model, on every model."
- },
- "description": {
- "type": "string",
+ {
+ "platformToken": []
+ }
+ ],
+ "description": "Your runs, newest first, with optional filters. Reading, following and cancelling runs does not need a plan with inference.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`).",
+ "parameters": [
+ {
+ "name": "workspace",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Only the runs of this workspace (name or id). A workspace that is not yours gives an empty page."
+ },
+ {
+ "name": "state",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "starting",
+ "running",
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out"
+ ]
+ },
+ "description": "Only the runs in this state. An unknown state answers `400`."
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 20
+ },
+ "description": "Runs per page, 1 to 100. Out of range answers `400`."
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Opaque: the `next_cursor` of the previous page."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "One page of runs.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RunList"
+ },
+ "example": {
+ "object": "list",
+ "data": [
+ {
+ "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
+ "object": "run",
+ "workspace": {
+ "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop"
+ },
+ "agent": "pi",
+ "model": null,
+ "trigger": "api",
+ "state": "queued",
+ "queue_position": 1,
+ "prompt_preview": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800,
+ "cancel_requested": false,
+ "exit_code": null,
+ "error_code": null,
+ "result": null,
+ "usage": {
+ "tokens_in": null,
+ "tokens_out": null,
+ "source": null
+ },
+ "last_seq": 0,
+ "created_at": "2026-10-10T12:00:00Z",
+ "started_at": null,
+ "finished_at": null
+ }
+ ],
+ "next_cursor": null
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid filter or cursor; `param` names it.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "limit must be between 1 and 100",
+ "type": "invalid_request_error",
+ "param": "limit",
+ "code": "invalid_request"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/RunsUnauthorized"
+ },
+ "403": {
+ "description": "The bearer is the key that lives inside a workspace. Agents cannot manage runs: use your API key or a platform token.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "workspace keys cannot manage runs",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "workspace_key_not_allowed"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL",
+ "source": "curl \"https://api.nan.builders/v1/runs?workspace=develop&state=running&limit=20\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\""
+ }
+ ]
+ }
+ },
+ "/runs/{id}": {
+ "get": {
+ "operationId": "getRun",
+ "tags": [
+ "Runs"
+ ],
+ "summary": "Retrieve a run",
+ "security": [
+ {
+ "apiKey": []
+ },
+ {
+ "platformToken": []
+ }
+ ],
+ "description": "One run, including the full `prompt`. Poll it, or follow the events stream, to see how it ends.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`).",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "description": "The run id. A malformed id answers `404`."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The run.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Run"
+ },
+ "example": {
+ "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
+ "object": "run",
+ "workspace": {
+ "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop"
+ },
+ "agent": "pi",
+ "model": null,
+ "trigger": "api",
+ "state": "succeeded",
+ "queue_position": null,
+ "prompt_preview": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800,
+ "cancel_requested": false,
+ "exit_code": 0,
+ "error_code": null,
+ "result": {
+ "status": "succeeded",
+ "summary": "Added 6 tests for the date parser; all pass.",
+ "branch": "nan-run/6f1c2a9b",
+ "commit": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567",
+ "changed_files": 2,
+ "artifacts": []
+ },
+ "usage": {
+ "tokens_in": 15234,
+ "tokens_out": 1812,
+ "source": "litellm"
+ },
+ "last_seq": 42,
+ "created_at": "2026-10-10T12:00:00Z",
+ "started_at": "2026-10-10T12:00:02Z",
+ "finished_at": "2026-10-10T12:03:41Z",
+ "prompt": "add tests for the date parser and make them pass"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/RunsUnauthorized"
+ },
+ "403": {
+ "description": "The bearer is the key that lives inside a workspace. Agents cannot manage runs: use your API key or a platform token.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "workspace keys cannot manage runs",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "workspace_key_not_allowed"
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No run with that id among yours (another member's run is also `404`).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "run not found",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "not_found"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL",
+ "source": "curl https://api.nan.builders/v1/runs/$RUN_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\""
+ }
+ ]
+ }
+ },
+ "/runs/{id}/events": {
+ "get": {
+ "operationId": "getRunEvents",
+ "tags": [
+ "Runs"
+ ],
+ "summary": "Follow a run's events",
+ "security": [
+ {
+ "apiKey": []
+ },
+ {
+ "platformToken": []
+ }
+ ],
+ "description": "The run's log, as a live stream or as JSON pages.\n\n**Stream.** Send `Accept: text/event-stream` (with `curl`, add `-N`). The server first replays every stored event after your resume point, then sends new ones as they happen. Frames:\n\n- `id: ` + `event: run_event` + `data: `: one event of the log.\n- `event: state` + `data: {\"state\", \"error_code\"}`: the run changed state.\n- `event: end` + `data: `: the run is over and every event has been sent. The server closes the stream after it.\n- `: hb`: a comment line every 15 seconds that keeps the connection open. Ignore it.\n\nTo resume after a dropped connection, send the last `id` you received as the `Last-Event-ID` header (it wins over `after`): you get the rest without gaps or repeats. A stream lasts at most 30 minutes; if it closes before `end`, reconnect with `Last-Event-ID`. Up to 10 open streams per member (`429 too_many_streams`).\n\n**JSON pages.** Without that `Accept` header the same endpoint answers one page of events after `after`. Pass the page's `next_after` as `after` until `done` is `true`.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`).",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "description": "The run id. A malformed id answers `404`."
+ },
+ {
+ "name": "Accept",
+ "in": "header",
+ "schema": {
+ "type": "string"
+ },
+ "description": "`text/event-stream` for the live stream; anything else for a JSON page."
+ },
+ {
+ "name": "Last-Event-ID",
+ "in": "header",
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "description": "Stream only: resume after this `seq`. Wins over `after`."
+ },
+ {
+ "name": "after",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0
+ },
+ "description": "Return the events with `seq` greater than this."
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 500
+ },
+ "description": "JSON pages only: events per page, 1 to 1000."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The stream (`text/event-stream`) or one page of events (`application/json`).",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "string",
+ "description": "Server-sent events: `run_event`, `state` and `end` frames, and `: hb` comments."
+ },
+ "example": "id: 1\nevent: run_event\ndata: {\"seq\":1,\"ts\":\"2026-10-10T12:00:02.120Z\",\"type\":\"started\",\"data\":{\"agent\":\"pi\",\"agent_version\":\"1.0.2\",\"model\":null,\"cwd\":\"/home/nan/projects/api\",\"worktree\":null,\"branch\":null,\"unit\":\"nan-run-6f1c2a9b\"}}\n\nevent: state\ndata: {\"state\":\"running\",\"error_code\":null}\n\nid: 2\nevent: run_event\ndata: {\"seq\":2,\"ts\":\"2026-10-10T12:00:05.310Z\",\"type\":\"message\",\"data\":{\"role\":\"assistant\",\"text\":\"Looking at the parser\",\"final\":false}}\n\n: hb\n\nevent: end\ndata: {\"id\":\"6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\"object\":\"run\",\"state\":\"succeeded\",...}\n\n"
+ },
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RunEventPage"
+ },
+ "example": {
+ "data": [
+ {
+ "seq": 1,
+ "ts": "2026-10-10T12:00:02.120Z",
+ "type": "started",
+ "data": {
+ "agent": "pi",
+ "agent_version": "1.0.2",
+ "model": null,
+ "cwd": "/home/nan/projects/api",
+ "worktree": null,
+ "branch": null,
+ "unit": "nan-run-6f1c2a9b"
+ }
+ },
+ {
+ "seq": 2,
+ "ts": "2026-10-10T12:00:05.310Z",
+ "type": "message",
+ "data": {
+ "role": "assistant",
+ "text": "Looking at the parser",
+ "final": false
+ }
+ }
+ ],
+ "next_after": 2,
+ "done": false
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "`after`, `limit` or `Last-Event-ID` is not a valid number.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "after must be a non-negative integer",
+ "type": "invalid_request_error",
+ "param": "after",
+ "code": "invalid_request"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/RunsUnauthorized"
+ },
+ "403": {
+ "description": "The bearer is the key that lives inside a workspace. Agents cannot manage runs: use your API key or a platform token.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "workspace keys cannot manage runs",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "workspace_key_not_allowed"
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No run with that id among yours (another member's run is also `404`).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "run not found",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "not_found"
+ }
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "More than 10 open streams for your account.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "too many open run streams",
+ "type": "rate_limit_exceeded",
+ "param": null,
+ "code": "too_many_streams"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL (stream)",
+ "source": "curl -N https://api.nan.builders/v1/runs/$RUN_ID/events \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Accept: text/event-stream\""
+ }
+ ]
+ }
+ },
+ "/runs/{id}/cancel": {
+ "post": {
+ "operationId": "cancelRun",
+ "tags": [
+ "Runs"
+ ],
+ "summary": "Cancel a run",
+ "security": [
+ {
+ "apiKey": []
+ },
+ {
+ "platformToken": []
+ }
+ ],
+ "description": "Stops the run. A queued run is cancelled at once; a running one stops within a few seconds and ends as `cancelled`. Safe to repeat.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`).",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "description": "The run id. A malformed id answers `404`."
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": "Cancellation accepted (`cancel_requested: true`). Follow the run to see it end.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Run"
+ },
+ "example": {
+ "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
+ "object": "run",
+ "workspace": {
+ "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop"
+ },
+ "agent": "pi",
+ "model": null,
+ "trigger": "api",
+ "state": "running",
+ "queue_position": null,
+ "prompt_preview": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800,
+ "cancel_requested": true,
+ "exit_code": null,
+ "error_code": null,
+ "result": null,
+ "usage": {
+ "tokens_in": null,
+ "tokens_out": null,
+ "source": null
+ },
+ "last_seq": 0,
+ "created_at": "2026-10-10T12:00:00Z",
+ "started_at": "2026-10-10T12:00:02Z",
+ "finished_at": null
+ }
+ }
+ }
+ },
+ "200": {
+ "description": "The run had already ended: nothing changed.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Run"
+ },
+ "example": {
+ "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
+ "object": "run",
+ "workspace": {
+ "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop"
+ },
+ "agent": "pi",
+ "model": null,
+ "trigger": "api",
+ "state": "succeeded",
+ "queue_position": null,
+ "prompt_preview": "add tests for the date parser and make them pass",
+ "cwd": "/home/nan/projects/api",
+ "git_isolation": null,
+ "timeout_seconds": 1800,
+ "cancel_requested": false,
+ "exit_code": 0,
+ "error_code": null,
+ "result": {
+ "status": "succeeded",
+ "summary": "Added 6 tests for the date parser; all pass.",
+ "branch": "nan-run/6f1c2a9b",
+ "commit": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567",
+ "changed_files": 2,
+ "artifacts": []
+ },
+ "usage": {
+ "tokens_in": 15234,
+ "tokens_out": 1812,
+ "source": "litellm"
+ },
+ "last_seq": 42,
+ "created_at": "2026-10-10T12:00:00Z",
+ "started_at": "2026-10-10T12:00:02Z",
+ "finished_at": "2026-10-10T12:03:41Z"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/RunsUnauthorized"
+ },
+ "403": {
+ "description": "The bearer is the key that lives inside a workspace. Agents cannot manage runs: use your API key or a platform token.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "workspace keys cannot manage runs",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "workspace_key_not_allowed"
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No run with that id among yours (another member's run is also `404`).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "run not found",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "not_found"
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL",
+ "source": "curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/cancel \\\n -H \"Authorization: Bearer $NAN_TOKEN\""
+ }
+ ]
+ }
+ },
+ "/api/workspaces": {
+ "servers": [
+ {
+ "url": "https://api.nan.builders",
+ "description": "Production. These routes live outside `/v1`."
+ }
+ ],
+ "get": {
+ "operationId": "listWorkspaces",
+ "tags": [
+ "Workspaces"
+ ],
+ "summary": "List workspaces",
+ "security": [
+ {
+ "apiKey": []
+ },
+ {
+ "platformToken": []
+ }
+ ],
+ "description": "Your own workspaces, and only yours. No keys, environment values or agent configuration are ever included. Use it to find the `name` or `workspace_uuid` to pass as `workspace` when you create a run.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`). The key that lives inside a workspace is not accepted.\n\nOn `api.nan.builders` this route takes a bearer credential only: cookies are ignored, and there is no browser CORS support (`OPTIONS` answers `404`), so call it from a server or a script, not from a web page.",
+ "responses": {
+ "200": {
+ "description": "Your workspaces (a JSON array, empty if you have none).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Workspace"
+ }
+ },
+ "example": [
+ {
+ "workspace_uuid": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop",
+ "agent_selection": [
+ "pi",
+ "hermes"
+ ],
+ "status": "running",
+ "ssh_command": "ssh 3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15@ssh.nan.builders -p 30222",
+ "vcpus": 2,
+ "memory_mib": 4096,
+ "disk_gib": 20,
+ "base_image_ver": "2026.10.05-180da44",
+ "tier": "nano",
+ "created_at": "2026-10-01T09:30:00Z",
+ "updated_at": "2026-10-10T08:12:00Z",
+ "state_reason": null,
+ "grace_until": null,
+ "slot": {
+ "id": 412,
+ "tier": "nano",
+ "status": "active",
+ "cancel_at_period_end": false,
+ "current_period_end": "2026-11-01T09:30:00Z"
+ },
+ "inference_attached": true,
+ "rescue": {
+ "allowed": false,
+ "active_until": null,
+ "reason": null,
+ "rescues_left_today": null
+ },
+ "backup": {
+ "status": "off",
+ "stale": false
+ }
+ }
+ ]
+ }
+ }
+ },
+ "401": {
+ "description": "No credentials (`unauthorized`); an unknown, revoked or expired API key or platform token (`invalid session`); or the key that lives inside a workspace (`session_required`).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformError"
+ },
+ "examples": {
+ "unauthorized": {
+ "value": {
+ "error": "unauthorized"
+ }
+ },
+ "invalid_session": {
+ "value": {
+ "error": "invalid session"
+ }
+ },
+ "session_required": {
+ "value": {
+ "error": "session_required",
+ "message": "This endpoint requires a signed-in session. API keys only work for inference (https://api.nan.builders/v1)."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL",
+ "source": "curl https://api.nan.builders/api/workspaces \\\n -H \"Authorization: Bearer $NAN_TOKEN\""
+ }
+ ]
+ }
+ },
+ "/api/workspaces/{id}": {
+ "servers": [
+ {
+ "url": "https://api.nan.builders",
+ "description": "Production. These routes live outside `/v1`."
+ }
+ ],
+ "get": {
+ "operationId": "getWorkspace",
+ "tags": [
+ "Workspaces"
+ ],
+ "summary": "Retrieve a workspace",
+ "security": [
+ {
+ "apiKey": []
+ },
+ {
+ "platformToken": []
+ }
+ ],
+ "description": "One of your own workspaces, plus `env_var_keys`: the names (never the values) of the variables in its environment file. Only your own workspaces are returned: another member's workspace answers `404`, exactly like an id that does not exist.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`). The key that lives inside a workspace is not accepted.\n\nOn `api.nan.builders` this route takes a bearer credential only: cookies are ignored, and there is no browser CORS support (`OPTIONS` answers `404`), so call it from a server or a script, not from a web page.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "description": "The `workspace_uuid`, in lowercase. Anything else is answered `404` before it reaches the API."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The workspace.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkspaceDetail"
+ },
+ "example": {
+ "workspace_uuid": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15",
+ "name": "develop",
+ "agent_selection": [
+ "pi",
+ "hermes"
+ ],
+ "status": "running",
+ "ssh_command": "ssh 3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15@ssh.nan.builders -p 30222",
+ "vcpus": 2,
+ "memory_mib": 4096,
+ "disk_gib": 20,
+ "base_image_ver": "2026.10.05-180da44",
+ "tier": "nano",
+ "created_at": "2026-10-01T09:30:00Z",
+ "updated_at": "2026-10-10T08:12:00Z",
+ "state_reason": null,
+ "grace_until": null,
+ "slot": {
+ "id": 412,
+ "tier": "nano",
+ "status": "active",
+ "cancel_at_period_end": false,
+ "current_period_end": "2026-11-01T09:30:00Z"
+ },
+ "inference_attached": true,
+ "rescue": {
+ "allowed": false,
+ "active_until": null,
+ "reason": null,
+ "rescues_left_today": null
+ },
+ "backup": {
+ "status": "off",
+ "stale": false
+ },
+ "env_var_keys": [
+ "OPENAI_API_KEY",
+ "TELEGRAM_BOT_TOKEN"
+ ]
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "No credentials (`unauthorized`); an unknown, revoked or expired API key or platform token (`invalid session`); or the key that lives inside a workspace (`session_required`).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlatformError"
+ },
+ "examples": {
+ "unauthorized": {
+ "value": {
+ "error": "unauthorized"
+ }
+ },
+ "invalid_session": {
+ "value": {
+ "error": "invalid session"
+ }
+ },
+ "session_required": {
+ "value": {
+ "error": "session_required",
+ "message": "This endpoint requires a signed-in session. API keys only work for inference (https://api.nan.builders/v1)."
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No workspace of yours with that id: it does not exist, belongs to another member (the answer is identical), or the id is not a lowercase UUID (answered by the edge in the OpenAI error shape).",
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/PlatformError"
+ },
+ {
+ "$ref": "#/components/schemas/Error"
+ }
+ ]
+ },
+ "examples": {
+ "workspace_not_found": {
+ "value": {
+ "error": "workspace not found"
+ }
+ },
+ "malformed_id": {
+ "value": {
+ "error": {
+ "message": "Not found.",
+ "type": "invalid_request_error",
+ "code": "404"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "curl",
+ "label": "cURL",
+ "source": "curl https://api.nan.builders/api/workspaces/$WORKSPACE_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\""
+ }
+ ]
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": {
+ "type": "http",
+ "scheme": "bearer",
+ "description": "Member API key (`sk-...`), from \"API Keys\" in your settings on the platform. Send it as `Authorization: Bearer `. It works on every inference endpoint, on the Runs endpoints and on the two Workspaces endpoints. Personal and non-transferable. The key that lives inside a workspace (the one its agents use) is refused by the Runs endpoints with `403 workspace_key_not_allowed`."
+ },
+ "platformToken": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "nan_pat_...",
+ "description": "Platform token (`nan_pat_...`), from \"Tokens\" in your settings on the platform. Send it as `Authorization: Bearer `. It works only on the platform API: the Runs endpoints (`/v1/runs...`) and the two Workspaces endpoints. It never works for inference: inference endpoints refuse it (`401`, or `403 token_scope_denied`). Shown once when you create it; up to 10 active; expires after 30, 90 or 365 days, or never; revocable at any time. Any member with an active subscription can create one; creating runs still needs a plan with inference."
+ }
+ },
+ "schemas": {
+ "Model": {
+ "type": "object",
+ "description": "An available model, in the OpenAI-compatible model shape.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "The model's unique slug. Pass this value as the `model` field in your requests.",
+ "example": "deepseek-v4-flash"
+ },
+ "object": {
+ "type": "string",
+ "enum": [
+ "model"
+ ],
+ "description": "The object type, which is always `model`.",
+ "example": "model"
+ },
+ "created": {
+ "type": "integer",
+ "description": "The Unix timestamp (in seconds) when the model became available.",
+ "example": 1677610602
+ },
+ "owned_by": {
+ "type": "string",
+ "description": "The organization that owns the model.",
+ "example": "nan"
+ }
+ }
+ },
+ "ContentPart": {
+ "description": "One part of a multimodal message. Use an array of these as `content` for vision input.",
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "type",
+ "text"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "text"
+ ]
+ },
+ "text": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type",
+ "image_url"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "image_url"
+ ]
+ },
+ "image_url": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Public URL or `data:` URI of the image."
+ }
+ }
+ }
+ }
+ }
+ ]
+ },
+ "Message": {
+ "type": "object",
+ "required": [
+ "role"
+ ],
+ "description": "A single message in a conversation. The `role` determines which other fields apply.",
+ "properties": {
+ "role": {
+ "type": "string",
+ "enum": [
+ "system",
+ "user",
+ "assistant",
+ "tool"
+ ],
+ "description": "The role of the message author: `system`, `user`, `assistant`, or `tool`."
+ },
+ "content": {
+ "description": "The contents of the message. A plain string, or an array of content parts for multimodal input. Can be `null` on an `assistant` message that only returns tool calls.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ContentPart"
+ }
+ }
+ ]
+ },
+ "name": {
+ "type": "string",
+ "description": "An optional name for the author of this message."
+ },
+ "tool_calls": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ToolCall"
+ },
+ "description": "The tool calls generated by the model. Present on `assistant` messages when the model decides to invoke one or more tools."
+ },
+ "tool_call_id": {
+ "type": "string",
+ "description": "The id of the tool call this message answers. Required when `role` is `tool`."
+ },
+ "reasoning_content": {
+ "type": "string",
+ "description": "The model's reasoning trace. Present only for reasoning models; safe to display or ignore."
+ }
+ }
+ },
+ "Tool": {
+ "type": "object",
+ "required": [
+ "type",
+ "function"
+ ],
+ "description": "A function the model may call. Provide these in the request's `tools` array.",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "function"
+ ],
+ "description": "The type of the tool. Currently only `function` is supported."
+ },
+ "function": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9_-]{1,64}$",
+ "description": "The name of the function to call. Up to 64 characters; letters, digits, underscores and dashes. A name outside that pattern is rejected with a `400` before the request reaches the model, on every model."
+ },
+ "description": {
+ "type": "string",
"description": "What the function does, used by the model to decide when and how to call it. Be descriptive."
},
- "strict": {
- "type": "boolean",
- "default": false,
- "description": "Request that the generated arguments follow `parameters` exactly (fields, types and required keys); enforced where the model supports strict decoding. Combined with a forced `tool_choice`, it is the way to get schema-shaped output on `deepseek-v4-flash`, which returns arguments that follow the schema."
+ "strict": {
+ "type": "boolean",
+ "default": false,
+ "description": "Request that the generated arguments follow `parameters` exactly (fields, types and required keys); enforced where the model supports strict decoding. Combined with a forced `tool_choice`, it is the way to get schema-shaped output on `deepseek-v4-flash`, which returns arguments that follow the schema."
+ },
+ "parameters": {
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "object"
+ ],
+ "description": "The root type of the schema. Must be `object`."
+ }
+ },
+ "additionalProperties": true,
+ "description": "The parameters the function accepts, described as a JSON Schema object. When present, its root must be `\"type\": \"object\"`: a root of any other type, or with no `type`, is rejected with a `400` before the request reaches the model, on every model. A function that takes no arguments can omit `parameters` or send `{\"type\": \"object\", \"properties\": {}}`."
+ }
+ }
+ }
+ }
+ },
+ "ToolChoice": {
+ "description": "Controls tool use: `\"auto\"` (default), `\"none\"`, `\"required\"`, or force one with `{ \"type\": \"function\", \"function\": { \"name\": \"...\" } }`.",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "auto",
+ "none",
+ "required"
+ ]
+ },
+ {
+ "type": "object"
+ }
+ ]
+ },
+ "ToolCall": {
+ "type": "object",
+ "description": "A tool the model chose to call. Execute it, then send the result back in a `tool` message referencing `id`.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique id for this tool call. Pass it as `tool_call_id` in your follow-up `tool` message."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "function"
+ ],
+ "description": "The type of the tool call. Always `function`."
+ },
+ "function": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the function to call."
+ },
+ "arguments": {
+ "type": "string",
+ "description": "The function arguments as a JSON-encoded string. Parse and validate it before use; models can occasionally emit malformed JSON."
+ }
+ }
+ }
+ }
+ },
+ "ResponseFormat": {
+ "description": "Force structured output. What each mode guarantees:\n\n- `json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt. On `deepseek-v4-flash` the prompt must also contain the word JSON, otherwise `400`.\n- `json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).\n\n`json_schema` works on `qwen3.6` and `gemma4`. On `deepseek-v4-flash` it is rejected with a `400` before the request reaches the model; `json_object` still works there. For output that follows a schema on `deepseek-v4-flash`, define one function tool whose `parameters` is your JSON Schema (root `type: object`, `strict: true`), force it with `tool_choice: {\"type\": \"function\", \"function\": {\"name\": \"...\"}}` and read `choices[0].message.tool_calls[0].function.arguments`.",
+ "oneOf": [
+ {
+ "type": "object",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "json_object"
+ ]
+ }
+ }
+ },
+ {
+ "type": "object",
+ "required": [
+ "type",
+ "json_schema"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "json_schema"
+ ]
},
- "parameters": {
+ "json_schema": {
"type": "object",
"required": [
- "type"
+ "name",
+ "schema"
],
"properties": {
- "type": {
- "type": "string",
- "enum": [
- "object"
- ],
- "description": "The root type of the schema. Must be `object`."
+ "name": {
+ "type": "string"
+ },
+ "strict": {
+ "type": "boolean"
+ },
+ "schema": {
+ "type": "object"
}
+ }
+ }
+ }
+ }
+ ]
+ },
+ "Usage": {
+ "type": "object",
+ "description": "Token accounting for the request.",
+ "properties": {
+ "prompt_tokens": {
+ "type": "integer",
+ "description": "The number of tokens in the prompt (input)."
+ },
+ "completion_tokens": {
+ "type": "integer",
+ "description": "The number of tokens in the generated completion (output)."
+ },
+ "total_tokens": {
+ "type": "integer",
+ "description": "The total tokens used: `prompt_tokens` plus `completion_tokens`. This is what counts against your quota."
+ }
+ }
+ },
+ "ChatCompletion": {
+ "type": "object",
+ "description": "The model's response to a chat request. Represents a single, non-streamed completion.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the completion."
+ },
+ "object": {
+ "type": "string",
+ "enum": [
+ "chat.completion"
+ ],
+ "description": "The object type, always `chat.completion`."
+ },
+ "created": {
+ "type": "integer",
+ "description": "The Unix timestamp (in seconds) when the completion was created."
+ },
+ "model": {
+ "type": "string",
+ "description": "The model that produced the completion."
+ },
+ "choices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "index": {
+ "type": "integer",
+ "description": "The index of this choice in the `choices` array."
},
- "additionalProperties": true,
- "description": "The parameters the function accepts, described as a JSON Schema object. When present, its root must be `\"type\": \"object\"`: a root of any other type, or with no `type`, is rejected with a `400` before the request reaches the model, on every model. A function that takes no arguments can omit `parameters` or send `{\"type\": \"object\", \"properties\": {}}`."
+ "finish_reason": {
+ "type": "string",
+ "enum": [
+ "stop",
+ "length",
+ "tool_calls"
+ ],
+ "description": "Why the model stopped: `stop` (natural end or a stop sequence), `length` (hit `max_tokens`), or `tool_calls` (the model called a tool)."
+ },
+ "message": {
+ "$ref": "#/components/schemas/Message"
+ }
+ }
+ },
+ "description": "The list of completion choices. Contains a single choice unless you request more."
+ },
+ "usage": {
+ "$ref": "#/components/schemas/Usage",
+ "description": "Token usage for the request."
+ }
+ }
+ },
+ "ChatCompletionChunk": {
+ "type": "object",
+ "description": "One Server-Sent Event chunk when `stream: true`. New text arrives in `choices[0].delta.content`; the stream ends with `data: [DONE]`.",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "object": {
+ "type": "string",
+ "enum": [
+ "chat.completion.chunk"
+ ],
+ "description": "The object type, always `chat.completion.chunk`."
+ },
+ "created": {
+ "type": "integer",
+ "description": "The Unix timestamp (in seconds) when the chunk was created. Identical across all chunks of one response."
+ },
+ "model": {
+ "type": "string",
+ "description": "The model producing the response."
+ },
+ "choices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "index": {
+ "type": "integer"
+ },
+ "delta": {
+ "type": "object",
+ "properties": {
+ "role": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ },
+ "reasoning_content": {
+ "type": "string"
+ },
+ "tool_calls": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ToolCall"
+ }
+ }
+ },
+ "description": "The incremental update for this chunk. `role` is set on the first chunk; `content` carries the new text to append."
+ },
+ "finish_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "`null` on intermediate chunks; set to the stop reason on the final content chunk."
+ }
+ }
+ }
+ }
+ }
+ },
+ "Error": {
+ "type": "object",
+ "description": "OpenAI-standard error envelope: a non-2xx HTTP status with a JSON body describing the problem.",
+ "properties": {
+ "error": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "type": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "param": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "code": {
+ "type": [
+ "string",
+ "null"
+ ]
}
}
}
+ },
+ "example": {
+ "error": {
+ "message": "...",
+ "type": null,
+ "param": null,
+ "code": "..."
+ }
}
},
- "ToolChoice": {
- "description": "Controls tool use: `\"auto\"` (default), `\"none\"`, `\"required\"`, or force one with `{ \"type\": \"function\", \"function\": { \"name\": \"...\" } }`.",
- "oneOf": [
- {
+ "Embedding": {
+ "type": "object",
+ "description": "A single embedding vector for one input.",
+ "properties": {
+ "object": {
"type": "string",
"enum": [
- "auto",
- "none",
- "required"
- ]
+ "embedding"
+ ],
+ "description": "The object type, always `embedding`."
},
- {
- "type": "object"
+ "index": {
+ "type": "integer",
+ "description": "The position of this embedding in the request `input` array."
+ },
+ "embedding": {
+ "type": "array",
+ "items": {
+ "type": "number"
+ },
+ "description": "The embedding vector: a list of 4096 floats. Cosine similarity between vectors measures semantic closeness."
}
- ]
+ }
},
- "ToolCall": {
+ "EmbeddingList": {
"type": "object",
- "description": "A tool the model chose to call. Execute it, then send the result back in a `tool` message referencing `id`.",
+ "description": "A list of embeddings, one per input.",
"properties": {
- "id": {
- "type": "string",
- "description": "A unique id for this tool call. Pass it as `tool_call_id` in your follow-up `tool` message."
- },
- "type": {
+ "object": {
"type": "string",
"enum": [
- "function"
+ "list"
],
- "description": "The type of the tool call. Always `function`."
+ "description": "Always `list`."
},
- "function": {
+ "model": {
+ "type": "string",
+ "description": "The model used to create the embeddings."
+ },
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Embedding"
+ },
+ "description": "The embeddings, in the same order as the request `input`."
+ },
+ "usage": {
"type": "object",
+ "description": "Token usage for the request.",
"properties": {
- "name": {
- "type": "string",
- "description": "The name of the function to call."
+ "prompt_tokens": {
+ "type": "integer",
+ "description": "Tokens in the input."
},
- "arguments": {
- "type": "string",
- "description": "The function arguments as a JSON-encoded string. Parse and validate it before use; models can occasionally emit malformed JSON."
+ "total_tokens": {
+ "type": "integer",
+ "description": "Total tokens used."
}
}
}
}
},
- "ResponseFormat": {
- "description": "Force structured output. What each mode guarantees:\n\n- `json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt. On `deepseek-v4-flash` the prompt must also contain the word JSON, otherwise `400`.\n- `json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).\n\n`json_schema` works on `qwen3.6` and `gemma4`. On `deepseek-v4-flash` it is rejected with a `400` before the request reaches the model; `json_object` still works there. For output that follows a schema on `deepseek-v4-flash`, define one function tool whose `parameters` is your JSON Schema (root `type: object`, `strict: true`), force it with `tool_choice: {\"type\": \"function\", \"function\": {\"name\": \"...\"}}` and read `choices[0].message.tool_calls[0].function.arguments`.",
- "oneOf": [
- {
+ "RerankResult": {
+ "type": "object",
+ "description": "One reranked document.",
+ "properties": {
+ "index": {
+ "type": "integer",
+ "description": "The position of this document in the request `documents` array."
+ },
+ "relevance_score": {
+ "type": "number",
+ "description": "Relevance to the query, in the range [0, 1]. Higher is more relevant. Results are returned sorted by this, descending."
+ },
+ "document": {
"type": "object",
- "required": [
- "type"
- ],
"properties": {
- "type": {
+ "text": {
"type": "string",
- "enum": [
- "json_object"
- ]
+ "description": "The document text."
}
- }
+ },
+ "description": "The document, echoed back."
+ }
+ }
+ },
+ "RerankResponse": {
+ "type": "object",
+ "description": "The reranked documents, most-relevant first.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the rerank request."
},
- {
+ "results": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RerankResult"
+ },
+ "description": "The documents, sorted by descending `relevance_score`. Each carries its original `index`."
+ },
+ "meta": {
"type": "object",
- "required": [
- "type",
- "json_schema"
- ],
+ "description": "Request metadata, including token accounting.",
"properties": {
- "type": {
- "type": "string",
- "enum": [
- "json_schema"
- ]
+ "billed_units": {
+ "type": "object",
+ "properties": {
+ "total_tokens": {
+ "type": "integer"
+ }
+ }
},
- "json_schema": {
+ "tokens": {
"type": "object",
- "required": [
- "name",
- "schema"
- ],
"properties": {
- "name": {
- "type": "string"
- },
- "strict": {
- "type": "boolean"
- },
- "schema": {
- "type": "object"
+ "input_tokens": {
+ "type": "integer"
}
}
}
}
}
- ]
+ }
+ },
+ "Transcription": {
+ "type": "object",
+ "description": "A transcription of the supplied audio.",
+ "properties": {
+ "text": {
+ "type": "string",
+ "description": "The full transcribed text."
+ },
+ "language": {
+ "type": "string",
+ "description": "The detected (or supplied) language, as an ISO-639-1 code."
+ },
+ "task": {
+ "type": "string",
+ "description": "The task performed, always `transcribe`."
+ },
+ "duration": {
+ "type": "number",
+ "description": "The audio duration in seconds. Present with `verbose_json`."
+ },
+ "segments": {
+ "type": "array",
+ "description": "Time-aligned segments. Present with `verbose_json`.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "start": {
+ "type": "number",
+ "description": "Segment start time in seconds."
+ },
+ "end": {
+ "type": "number",
+ "description": "Segment end time in seconds."
+ },
+ "text": {
+ "type": "string"
+ },
+ "avg_logprob": {
+ "type": "number"
+ },
+ "no_speech_prob": {
+ "type": "number"
+ }
+ }
+ }
+ },
+ "words": {
+ "type": [
+ "array",
+ "null"
+ ],
+ "description": "Per-word timestamps `[{ word, start, end, probability }]`. Populated only when `timestamp_granularities[]=word`."
+ }
+ }
+ },
+ "ResponseObject": {
+ "type": "object",
+ "description": "A response produced by the Responses API.",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the response."
+ },
+ "created_at": {
+ "type": "integer",
+ "description": "The Unix timestamp (in seconds) when the response was created."
+ },
+ "model": {
+ "type": "string",
+ "description": "The model that produced the response."
+ },
+ "object": {
+ "type": "string",
+ "enum": [
+ "response"
+ ],
+ "description": "The object type, always `response`."
+ },
+ "status": {
+ "type": "string",
+ "description": "The response status, e.g. `completed`."
+ },
+ "output": {
+ "type": "array",
+ "description": "The output items. May contain `reasoning` blocks (reasoning models) followed by a `message` block with the reply text in `content[].text`.",
+ "items": {
+ "type": "object"
+ }
+ },
+ "usage": {
+ "type": "object",
+ "description": "Token usage for the request.",
+ "properties": {
+ "input_tokens": {
+ "type": "integer"
+ },
+ "output_tokens": {
+ "type": "integer"
+ },
+ "total_tokens": {
+ "type": "integer"
+ }
+ }
+ }
+ }
},
- "Usage": {
+ "ImageResponse": {
"type": "object",
- "description": "Token accounting for the request.",
+ "description": "The generated image(s).",
"properties": {
- "prompt_tokens": {
- "type": "integer",
- "description": "The number of tokens in the prompt (input)."
- },
- "completion_tokens": {
+ "created": {
"type": "integer",
- "description": "The number of tokens in the generated completion (output)."
+ "description": "The Unix timestamp (in seconds) when the images were created."
},
- "total_tokens": {
- "type": "integer",
- "description": "The total tokens used: `prompt_tokens` plus `completion_tokens`. This is what counts against your quota."
+ "data": {
+ "type": "array",
+ "description": "The generated images. Each item has a `url` (default) or a `b64_json` payload, depending on `response_format`.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "A temporary URL to the image, valid ~60 minutes. Returned when `response_format` is `url`."
+ },
+ "b64_json": {
+ "type": "string",
+ "description": "The base64-encoded image bytes. Returned when `response_format` is `b64_json`."
+ }
+ }
+ }
}
}
},
- "ChatCompletion": {
+ "Completion": {
"type": "object",
- "description": "The model's response to a chat request. Represents a single, non-streamed completion.",
+ "description": "A completion for the provided prompt (legacy).",
"properties": {
"id": {
- "type": "string",
- "description": "A unique identifier for the completion."
+ "type": "string"
},
"object": {
"type": "string",
"enum": [
- "chat.completion"
+ "text_completion"
],
- "description": "The object type, always `chat.completion`."
+ "description": "Always `text_completion`."
},
"created": {
"type": "integer",
"description": "The Unix timestamp (in seconds) when the completion was created."
},
"model": {
- "type": "string",
- "description": "The model that produced the completion."
+ "type": "string"
},
"choices": {
"type": "array",
"items": {
"type": "object",
"properties": {
+ "text": {
+ "type": "string",
+ "description": "The generated text."
+ },
"index": {
- "type": "integer",
- "description": "The index of this choice in the `choices` array."
+ "type": "integer"
},
"finish_reason": {
"type": "string",
"enum": [
"stop",
- "length",
- "tool_calls"
+ "length"
],
- "description": "Why the model stopped: `stop` (natural end or a stop sequence), `length` (hit `max_tokens`), or `tool_calls` (the model called a tool)."
+ "description": "Why generation stopped."
},
- "message": {
- "$ref": "#/components/schemas/Message"
+ "logprobs": {
+ "type": [
+ "object",
+ "null"
+ ]
}
}
- },
- "description": "The list of completion choices. Contains a single choice unless you request more."
+ }
},
"usage": {
- "$ref": "#/components/schemas/Usage",
- "description": "Token usage for the request."
+ "$ref": "#/components/schemas/Usage"
}
}
},
- "ChatCompletionChunk": {
+ "UsageReport": {
"type": "object",
- "description": "One Server-Sent Event chunk when `stream: true`. New text arrives in `choices[0].delta.content`; the stream ends with `data: [DONE]`.",
+ "description": "Your token usage for the requested window.",
"properties": {
- "id": {
- "type": "string"
- },
"object": {
"type": "string",
"enum": [
- "chat.completion.chunk"
+ "usage.report"
],
- "description": "The object type, always `chat.completion.chunk`."
+ "description": "The object type, always `usage.report`."
},
- "created": {
- "type": "integer",
- "description": "The Unix timestamp (in seconds) when the chunk was created. Identical across all chunks of one response."
+ "start_date": {
+ "type": "string",
+ "format": "date",
+ "description": "First day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover."
+ },
+ "end_date": {
+ "type": "string",
+ "format": "date",
+ "description": "Last day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover."
+ },
+ "data": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/UsageRow"
+ },
+ "description": "One row per (date, model) pair in the window, ordered by date ascending, then model ascending. Long windows come back paginated."
+ },
+ "totals": {
+ "$ref": "#/components/schemas/UsageTotals",
+ "description": "Totals over the full requested window, not just the current page."
+ },
+ "all_time": {
+ "$ref": "#/components/schemas/UsageAllTime",
+ "description": "Your lifetime usage, always present."
+ },
+ "has_more": {
+ "type": "boolean",
+ "description": "Whether more pages follow. When `true`, pass `next_cursor` back as the `cursor` parameter."
+ },
+ "next_cursor": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The cursor for the next page, or `null` on the last page."
+ }
+ }
+ },
+ "UsageRow": {
+ "type": "object",
+ "description": "Usage for one model on one day.",
+ "properties": {
+ "date": {
+ "type": "string",
+ "description": "The UTC day this row covers, `YYYY-MM-DD`."
},
"model": {
"type": "string",
- "description": "The model producing the response."
+ "description": "The model id, as in the Model catalog."
},
- "choices": {
+ "prompt_tokens": {
+ "type": "integer",
+ "description": "Input tokens used that day on that model."
+ },
+ "completion_tokens": {
+ "type": "integer",
+ "description": "Output tokens generated that day on that model."
+ },
+ "total_tokens": {
+ "type": "integer",
+ "description": "`prompt_tokens` plus `completion_tokens`."
+ },
+ "api_requests": {
+ "type": "integer",
+ "description": "Requests made that day on that model. Request counts are only available from 2026-09-02 onward; older days report `0`."
+ }
+ }
+ },
+ "UsageTotals": {
+ "type": "object",
+ "description": "Totals over the full requested window, independent of pagination.",
+ "properties": {
+ "prompt_tokens": {
+ "type": "integer",
+ "description": "Total input tokens in the window."
+ },
+ "completion_tokens": {
+ "type": "integer",
+ "description": "Total output tokens in the window."
+ },
+ "total_tokens": {
+ "type": "integer",
+ "description": "`prompt_tokens` plus `completion_tokens`."
+ },
+ "api_requests": {
+ "type": "integer",
+ "description": "Total requests in the window. Request counts are only available from 2026-09-02 onward; older days report `0`."
+ },
+ "by_model": {
"type": "array",
"items": {
- "type": "object",
- "properties": {
- "index": {
- "type": "integer"
- },
- "delta": {
- "type": "object",
- "properties": {
- "role": {
- "type": "string"
- },
- "content": {
- "type": "string"
- },
- "reasoning_content": {
- "type": "string"
- },
- "tool_calls": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ToolCall"
- }
- }
- },
- "description": "The incremental update for this chunk. `role` is set on the first chunk; `content` carries the new text to append."
- },
- "finish_reason": {
- "type": [
- "string",
- "null"
- ],
- "description": "`null` on intermediate chunks; set to the stop reason on the final content chunk."
- }
- }
- }
+ "$ref": "#/components/schemas/UsageModelTotals"
+ },
+ "description": "The same totals, broken down per model."
+ }
+ }
+ },
+ "UsageModelTotals": {
+ "type": "object",
+ "description": "Window totals for one model.",
+ "properties": {
+ "model": {
+ "type": "string",
+ "description": "The model id, as in the Model catalog."
+ },
+ "prompt_tokens": {
+ "type": "integer",
+ "description": "Input tokens used on this model in the window."
+ },
+ "completion_tokens": {
+ "type": "integer",
+ "description": "Output tokens generated on this model in the window."
+ },
+ "total_tokens": {
+ "type": "integer",
+ "description": "`prompt_tokens` plus `completion_tokens`."
+ },
+ "api_requests": {
+ "type": "integer",
+ "description": "Requests made on this model in the window. Request counts are only available from 2026-09-02 onward; older days report `0`."
+ }
+ }
+ },
+ "UsageAllTime": {
+ "type": "object",
+ "description": "Your lifetime usage across all models.",
+ "properties": {
+ "prompt_tokens": {
+ "type": "integer",
+ "description": "Total input tokens ever used."
+ },
+ "completion_tokens": {
+ "type": "integer",
+ "description": "Total output tokens ever generated."
+ },
+ "total_tokens": {
+ "type": "integer",
+ "description": "`prompt_tokens` plus `completion_tokens`."
+ },
+ "api_requests": {
+ "type": "integer",
+ "description": "Total requests recorded for this member. Request counts are only available from 2026-09-02 onward; older days report `0`."
+ },
+ "cached_at": {
+ "type": "string",
+ "description": "When the all-time summary was last computed, as an ISO 8601 UTC timestamp."
}
}
},
- "Error": {
+ "PlatformError": {
"type": "object",
- "description": "OpenAI-standard error envelope: a non-2xx HTTP status with a JSON body describing the problem.",
+ "description": "Error body of the Workspaces endpoints (outside `/v1`): a short `error` string, plus `message` on `session_required`.",
"properties": {
"error": {
- "type": "object",
- "properties": {
- "message": {
- "type": "string"
- },
- "type": {
- "type": [
- "string",
- "null"
- ]
- },
- "param": {
- "type": [
- "string",
- "null"
- ]
- },
- "code": {
- "type": [
- "string",
- "null"
- ]
- }
- }
+ "type": "string",
+ "description": "Short error: `unauthorized`, `invalid session`, `session_required` or `workspace not found`."
+ },
+ "message": {
+ "type": "string",
+ "description": "Present on `session_required`: what the credential can be used for."
}
},
- "example": {
- "error": {
- "message": "...",
- "type": null,
- "param": null,
- "code": "..."
- }
- }
+ "required": [
+ "error"
+ ]
},
- "Embedding": {
+ "CreateRunRequest": {
"type": "object",
- "description": "A single embedding vector for one input.",
+ "description": "Body of `POST /v1/runs`. Unknown fields are refused with `400`.",
+ "required": [
+ "workspace",
+ "agent",
+ "prompt"
+ ],
+ "additionalProperties": false,
"properties": {
- "object": {
+ "workspace": {
+ "type": "string",
+ "description": "Name or id of one of your workspaces.",
+ "example": "develop"
+ },
+ "agent": {
"type": "string",
"enum": [
- "embedding"
+ "pi",
+ "hermes"
],
- "description": "The object type, always `embedding`."
+ "description": "The agent. It must be installed in the workspace, otherwise `409 agent_not_installed`."
},
- "index": {
+ "prompt": {
+ "type": "string",
+ "minLength": 1,
+ "description": "The task. 1 to 32768 bytes of UTF-8 (32 KiB), no NUL bytes. Called Task in the portal.",
+ "example": "add tests for the date parser and make them pass"
+ },
+ "cwd": {
+ "type": "string",
+ "default": "/home/nan",
+ "maxLength": 512,
+ "description": "Working directory: an absolute path inside `/home/nan`, without `.` or `..` segments. Called Folder in the portal."
+ },
+ "git_isolation": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "default": null,
+ "description": "`null` (auto): when `cwd` is inside a git repository, the agent works on its own branch `nan-run/` in a separate worktree, commits its changes there and never pushes; outside a repository it works in the folder. `true`: always a separate branch (fails with `config_error` outside a repository). `false`: always in the folder. Called Separate branch in the portal."
+ },
+ "timeout_seconds": {
"type": "integer",
- "description": "The position of this embedding in the request `input` array."
+ "minimum": 60,
+ "maximum": 7200,
+ "default": 1800,
+ "description": "Time limit. The run is stopped (`timed_out`) when it reaches it. Called Time limit in the portal."
},
- "embedding": {
- "type": "array",
- "items": {
- "type": "number"
- },
- "description": "The embedding vector: a list of 4096 floats. Cosine similarity between vectors measures semantic closeness."
+ "model": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^[A-Za-z0-9._:/-]{1,128}$",
+ "default": null,
+ "description": "Model for the agent. `null` uses the agent's own default."
+ },
+ "trigger": {
+ "type": "string",
+ "enum": [
+ "api",
+ "cli",
+ "ui"
+ ],
+ "default": "api",
+ "description": "Who started the run. Informational only."
}
}
},
- "EmbeddingList": {
+ "RunArtifact": {
"type": "object",
- "description": "A list of embeddings, one per input.",
+ "description": "A file the agent left in its run's `artifacts/` folder.",
"properties": {
- "object": {
+ "path": {
+ "type": "string",
+ "description": "Path relative to the run folder, under `artifacts/`.",
+ "example": "artifacts/report.md"
+ },
+ "bytes": {
+ "type": "integer"
+ },
+ "sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ }
+ }
+ },
+ "RunResult": {
+ "type": "object",
+ "description": "How the run ended, written once it is over. Text from the agent is data: render it as plain text.",
+ "properties": {
+ "status": {
"type": "string",
"enum": [
- "list"
- ],
- "description": "Always `list`."
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out"
+ ]
},
- "model": {
+ "summary": {
"type": "string",
- "description": "The model used to create the embeddings."
+ "description": "The agent's final answer, up to 4 KiB."
},
- "data": {
+ "branch": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The `nan-run/...` branch with the agent's commit, when it worked on a separate branch and changed something. `null` otherwise (with no changes the branch is deleted).",
+ "example": "nan-run/6f1c2a9b"
+ },
+ "commit": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The commit on `branch`."
+ },
+ "changed_files": {
+ "type": "integer"
+ },
+ "artifacts": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/Embedding"
- },
- "description": "The embeddings, in the same order as the request `input`."
- },
- "usage": {
- "type": "object",
- "description": "Token usage for the request.",
- "properties": {
- "prompt_tokens": {
- "type": "integer",
- "description": "Tokens in the input."
- },
- "total_tokens": {
- "type": "integer",
- "description": "Total tokens used."
- }
+ "$ref": "#/components/schemas/RunArtifact"
}
}
}
},
- "RerankResult": {
+ "Run": {
"type": "object",
- "description": "One reranked document.",
+ "description": "A run. Every response returns this object; `prompt` (the full task) is only in `GET /v1/runs/{id}`.",
"properties": {
- "index": {
- "type": "integer",
- "description": "The position of this document in the request `documents` array."
+ "id": {
+ "type": "string",
+ "format": "uuid"
},
- "relevance_score": {
- "type": "number",
- "description": "Relevance to the query, in the range [0, 1]. Higher is more relevant. Results are returned sorted by this, descending."
+ "object": {
+ "type": "string",
+ "enum": [
+ "run"
+ ]
},
- "document": {
+ "workspace": {
"type": "object",
"properties": {
- "text": {
+ "id": {
"type": "string",
- "description": "The document text."
+ "format": "uuid"
+ },
+ "name": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "`null` once the workspace is deleted."
}
- },
- "description": "The document, echoed back."
- }
- }
- },
- "RerankResponse": {
- "type": "object",
- "description": "The reranked documents, most-relevant first.",
- "properties": {
- "id": {
+ }
+ },
+ "agent": {
"type": "string",
- "description": "A unique identifier for the rerank request."
+ "enum": [
+ "pi",
+ "hermes"
+ ]
},
- "results": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/RerankResult"
- },
- "description": "The documents, sorted by descending `relevance_score`. Each carries its original `index`."
+ "model": {
+ "type": [
+ "string",
+ "null"
+ ]
},
- "meta": {
+ "trigger": {
+ "type": "string",
+ "enum": [
+ "api",
+ "cli",
+ "ui"
+ ]
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "queued",
+ "starting",
+ "running",
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out"
+ ],
+ "description": "`queued` waits for a free place (per-workspace and per-member limits), then `starting`, `running`, and one of the four final states."
+ },
+ "queue_position": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "description": "1-based position while `queued`, otherwise `null`."
+ },
+ "prompt_preview": {
+ "type": "string",
+ "description": "The first 200 characters of the task."
+ },
+ "prompt": {
+ "type": "string",
+ "description": "The full task. Only in `GET /v1/runs/{id}`."
+ },
+ "cwd": {
+ "type": "string"
+ },
+ "git_isolation": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ },
+ "timeout_seconds": {
+ "type": "integer"
+ },
+ "cancel_requested": {
+ "type": "boolean"
+ },
+ "exit_code": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "error_code": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "unreachable",
+ "access_denied",
+ "admin_unavailable",
+ "host_key_mismatch",
+ "workspace_not_running",
+ "workspace_deleted",
+ "workspace_unavailable",
+ "not_entitled",
+ "no_inference_key",
+ "agent_not_installed",
+ "config_error",
+ "install_failed",
+ "busy",
+ "agent_failed",
+ "timed_out",
+ "cancelled",
+ "interrupted",
+ "protocol_error",
+ "executor_error",
+ null
+ ],
+ "description": "Why the run did not succeed, when there is a reason."
+ },
+ "result": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/RunResult"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "usage": {
"type": "object",
- "description": "Request metadata, including token accounting.",
+ "description": "Tokens the run used. Each field is `null` until it is known.",
"properties": {
- "billed_units": {
- "type": "object",
- "properties": {
- "total_tokens": {
- "type": "integer"
- }
- }
+ "tokens_in": {
+ "type": [
+ "integer",
+ "null"
+ ]
},
- "tokens": {
- "type": "object",
- "properties": {
- "input_tokens": {
- "type": "integer"
- }
- }
+ "tokens_out": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ },
+ "source": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "litellm",
+ "agent",
+ null
+ ],
+ "description": "Where the counts come from: `litellm` (measured by the NaN gateway) or `agent` (reported by the agent)."
}
}
+ },
+ "last_seq": {
+ "type": "integer",
+ "description": "`seq` of the last stored event."
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "started_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "finished_at": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
}
}
},
- "Transcription": {
+ "RunList": {
"type": "object",
- "description": "A transcription of the supplied audio.",
"properties": {
- "text": {
- "type": "string",
- "description": "The full transcribed text."
- },
- "language": {
- "type": "string",
- "description": "The detected (or supplied) language, as an ISO-639-1 code."
- },
- "task": {
+ "object": {
"type": "string",
- "description": "The task performed, always `transcribe`."
- },
- "duration": {
- "type": "number",
- "description": "The audio duration in seconds. Present with `verbose_json`."
+ "enum": [
+ "list"
+ ]
},
- "segments": {
+ "data": {
"type": "array",
- "description": "Time-aligned segments. Present with `verbose_json`.",
"items": {
- "type": "object",
- "properties": {
- "id": {
- "type": "integer"
- },
- "start": {
- "type": "number",
- "description": "Segment start time in seconds."
- },
- "end": {
- "type": "number",
- "description": "Segment end time in seconds."
- },
- "text": {
- "type": "string"
- },
- "avg_logprob": {
- "type": "number"
- },
- "no_speech_prob": {
- "type": "number"
- }
- }
- }
+ "$ref": "#/components/schemas/Run"
+ },
+ "description": "Newest first. `prompt` is not included: use `prompt_preview`."
},
- "words": {
+ "next_cursor": {
"type": [
- "array",
+ "string",
"null"
],
- "description": "Per-word timestamps `[{ word, start, end, probability }]`. Populated only when `timestamp_granularities[]=word`."
+ "description": "Pass it as `cursor` for the next page. `null` on the last page."
}
}
},
- "ResponseObject": {
+ "RunEventDataStarted": {
"type": "object",
- "description": "A response produced by the Responses API.",
+ "description": "`data` of a `started` event. The run started.",
"properties": {
- "id": {
+ "agent": {
"type": "string",
- "description": "A unique identifier for the response."
+ "enum": [
+ "pi",
+ "hermes"
+ ]
},
- "created_at": {
- "type": "integer",
- "description": "The Unix timestamp (in seconds) when the response was created."
+ "agent_version": {
+ "type": "string"
},
"model": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "cwd": {
"type": "string",
- "description": "The model that produced the response."
+ "description": "Where the agent works (inside the worktree when there is one)."
},
- "object": {
+ "worktree": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The separate worktree, or `null` when the agent works in the folder."
+ },
+ "branch": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "`nan-run/...` when there is a worktree, otherwise `null`."
+ },
+ "unit": {
+ "type": "string"
+ }
+ }
+ },
+ "RunEventDataLog": {
+ "type": "object",
+ "description": "`data` of a `log` event. A line of output that is not a message.",
+ "properties": {
+ "stream": {
"type": "string",
"enum": [
- "response"
- ],
- "description": "The object type, always `response`."
+ "agent_stdout",
+ "agent_stderr",
+ "nan-run"
+ ]
},
- "status": {
+ "text": {
"type": "string",
- "description": "The response status, e.g. `completed`."
+ "description": "Up to 8 KiB."
+ }
+ }
+ },
+ "RunEventDataMessage": {
+ "type": "object",
+ "description": "`data` of a `message` event. Assistant text, sent incrementally: each event carries only its own chunk, and the last chunk of a message has `final: true` (its text may be empty). Concatenate the chunks to rebuild the message.",
+ "properties": {
+ "role": {
+ "type": "string",
+ "enum": [
+ "assistant"
+ ]
},
- "output": {
- "type": "array",
- "description": "The output items. May contain `reasoning` blocks (reasoning models) followed by a `message` block with the reply text in `content[].text`.",
- "items": {
- "type": "object"
- }
+ "text": {
+ "type": "string",
+ "description": "This chunk only, up to 16 KiB."
},
- "usage": {
- "type": "object",
- "description": "Token usage for the request.",
- "properties": {
- "input_tokens": {
- "type": "integer"
- },
- "output_tokens": {
- "type": "integer"
- },
- "total_tokens": {
- "type": "integer"
- }
- }
+ "final": {
+ "type": "boolean"
}
}
},
- "ImageResponse": {
+ "RunEventDataToolCall": {
"type": "object",
- "description": "The generated image(s).",
+ "description": "`data` of a `tool_call` event. The agent called a tool.",
"properties": {
- "created": {
- "type": "integer",
- "description": "The Unix timestamp (in seconds) when the images were created."
+ "id": {
+ "type": "string"
},
- "data": {
- "type": "array",
- "description": "The generated images. Each item has a `url` (default) or a `b64_json` payload, depending on `response_format`.",
- "items": {
- "type": "object",
- "properties": {
- "url": {
- "type": "string",
- "description": "A temporary URL to the image, valid ~60 minutes. Returned when `response_format` is `url`."
- },
- "b64_json": {
- "type": "string",
- "description": "The base64-encoded image bytes. Returned when `response_format` is `b64_json`."
- }
- }
- }
+ "name": {
+ "type": "string"
+ },
+ "args_preview": {
+ "type": "string",
+ "description": "Arguments, up to 2 KiB."
}
}
},
- "Completion": {
+ "RunEventDataToolResult": {
"type": "object",
- "description": "A completion for the provided prompt (legacy).",
+ "description": "`data` of a `tool_result` event. What a tool answered. `id` matches its `tool_call`.",
"properties": {
"id": {
"type": "string"
},
- "object": {
+ "name": {
+ "type": "string"
+ },
+ "is_error": {
+ "type": "boolean"
+ },
+ "output_preview": {
+ "type": "string",
+ "description": "Output, up to 4 KiB."
+ }
+ }
+ },
+ "RunEventDataUsage": {
+ "type": "object",
+ "description": "`data` of a `usage` event. Tokens used so far, as the agent reports them (cumulative).",
+ "properties": {
+ "model": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "prompt_tokens": {
+ "type": "integer"
+ },
+ "completion_tokens": {
+ "type": "integer"
+ }
+ }
+ },
+ "RunEventDataError": {
+ "type": "object",
+ "description": "`data` of an `error` event. Something went wrong.",
+ "properties": {
+ "code": {
"type": "string",
"enum": [
- "text_completion"
- ],
- "description": "Always `text_completion`."
+ "unreachable",
+ "access_denied",
+ "admin_unavailable",
+ "host_key_mismatch",
+ "workspace_not_running",
+ "workspace_deleted",
+ "workspace_unavailable",
+ "not_entitled",
+ "no_inference_key",
+ "agent_not_installed",
+ "config_error",
+ "install_failed",
+ "busy",
+ "agent_failed",
+ "timed_out",
+ "cancelled",
+ "interrupted",
+ "protocol_error",
+ "executor_error"
+ ]
},
- "created": {
- "type": "integer",
- "description": "The Unix timestamp (in seconds) when the completion was created."
+ "message": {
+ "type": "string",
+ "description": "Up to 2 KiB."
+ }
+ }
+ },
+ "RunEventDataTruncated": {
+ "type": "object",
+ "description": "`data` of a `truncated` event. The run hit its event limit (20000 events) or size limit (16 MiB): later events are dropped, except `artifact`, `error` and `finished`.",
+ "properties": {
+ "reason": {
+ "type": "string",
+ "enum": [
+ "event_cap",
+ "size_cap"
+ ]
+ }
+ }
+ },
+ "RunEventDataFinished": {
+ "type": "object",
+ "description": "`data` of a `finished` event. The run ended. Always the last event.",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out"
+ ]
},
- "model": {
- "type": "string"
+ "exit_code": {
+ "type": "integer"
},
- "choices": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "text": {
- "type": "string",
- "description": "The generated text."
- },
- "index": {
- "type": "integer"
- },
- "finish_reason": {
- "type": "string",
- "enum": [
- "stop",
- "length"
- ],
- "description": "Why generation stopped."
- },
- "logprobs": {
- "type": [
- "object",
- "null"
- ]
- }
- }
- }
+ "error_code": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "unreachable",
+ "access_denied",
+ "admin_unavailable",
+ "host_key_mismatch",
+ "workspace_not_running",
+ "workspace_deleted",
+ "workspace_unavailable",
+ "not_entitled",
+ "no_inference_key",
+ "agent_not_installed",
+ "config_error",
+ "install_failed",
+ "busy",
+ "agent_failed",
+ "timed_out",
+ "cancelled",
+ "interrupted",
+ "protocol_error",
+ "executor_error",
+ null
+ ]
},
- "usage": {
- "$ref": "#/components/schemas/Usage"
+ "summary": {
+ "type": "string"
}
}
},
- "UsageReport": {
+ "RunEvent": {
"type": "object",
- "description": "Your token usage for the requested window.",
+ "description": "One event of a run's log. `type` says what `data` holds. Everything in `data` comes from the agent inside your workspace: treat it as untrusted text, never as markup. Secrets from the workspace's environment file are masked as `***`.",
"properties": {
- "object": {
- "type": "string",
- "enum": [
- "usage.report"
- ],
- "description": "The object type, always `usage.report`."
+ "seq": {
+ "type": "integer",
+ "description": "1, 2, 3... in order, per run. Use it to resume (`after`, `Last-Event-ID`)."
},
- "start_date": {
+ "ts": {
"type": "string",
- "format": "date",
- "description": "First day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover."
+ "format": "date-time"
},
- "end_date": {
+ "type": {
"type": "string",
- "format": "date",
- "description": "Last day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover."
+ "enum": [
+ "started",
+ "log",
+ "message",
+ "tool_call",
+ "tool_result",
+ "usage",
+ "artifact",
+ "error",
+ "truncated",
+ "finished"
+ ],
+ "description": "- `started`: The run started.\n- `log`: A line of output that is not a message.\n- `message`: Assistant text, sent incrementally: each event carries only its own chunk, and the last chunk of a message has `final: true` (its text may be empty). Concatenate the chunks to rebuild the message.\n- `tool_call`: The agent called a tool.\n- `tool_result`: What a tool answered. `id` matches its `tool_call`.\n- `usage`: Tokens used so far, as the agent reports them (cumulative).\n- `artifact`: A file the agent left in `artifacts/`.\n- `error`: Something went wrong.\n- `truncated`: The run hit its event limit (20000 events) or size limit (16 MiB): later events are dropped, except `artifact`, `error` and `finished`.\n- `finished`: The run ended. Always the last event."
},
+ "data": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/RunEventDataStarted"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataLog"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataMessage"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataToolCall"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataToolResult"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataUsage"
+ },
+ {
+ "$ref": "#/components/schemas/RunArtifact"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataError"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataTruncated"
+ },
+ {
+ "$ref": "#/components/schemas/RunEventDataFinished"
+ }
+ ],
+ "description": "Depends on `type`."
+ }
+ },
+ "example": {
+ "seq": 4,
+ "ts": "2026-10-10T12:00:09.412Z",
+ "type": "tool_call",
+ "data": {
+ "id": "call_01",
+ "name": "bash",
+ "args_preview": "{\"command\":\"go test ./...\"}"
+ }
+ }
+ },
+ "RunEventPage": {
+ "type": "object",
+ "properties": {
"data": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/UsageRow"
- },
- "description": "One row per (date, model) pair in the window, ordered by date ascending, then model ascending. Long windows come back paginated."
- },
- "totals": {
- "$ref": "#/components/schemas/UsageTotals",
- "description": "Totals over the full requested window, not just the current page."
+ "$ref": "#/components/schemas/RunEvent"
+ }
},
- "all_time": {
- "$ref": "#/components/schemas/UsageAllTime",
- "description": "Your lifetime usage, always present."
+ "next_after": {
+ "type": "integer",
+ "description": "The last `seq` returned (or your `after` when the page is empty). Pass it as `after` for the next page."
},
- "has_more": {
+ "done": {
"type": "boolean",
- "description": "Whether more pages follow. When `true`, pass `next_cursor` back as the `cursor` parameter."
+ "description": "`true` when the run is over and every event has been returned."
+ }
+ }
+ },
+ "WorkspaceSlot": {
+ "type": "object",
+ "description": "The purchased slot paying for the workspace.",
+ "properties": {
+ "id": {
+ "type": "integer"
},
- "next_cursor": {
+ "tier": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ },
+ "cancel_at_period_end": {
+ "type": "boolean"
+ },
+ "current_period_end": {
"type": [
"string",
"null"
],
- "description": "The cursor for the next page, or `null` on the last page."
+ "format": "date-time"
}
}
},
- "UsageRow": {
+ "Workspace": {
"type": "object",
- "description": "Usage for one model on one day.",
+ "description": "One of your workspaces. Never carries keys, environment values or agent configuration.",
"properties": {
- "date": {
+ "workspace_uuid": {
"type": "string",
- "description": "The UTC day this row covers, `YYYY-MM-DD`."
+ "format": "uuid",
+ "description": "The workspace id. Accepted by `workspace` in `POST /v1/runs`."
},
- "model": {
+ "name": {
"type": "string",
- "description": "The model id, as in the Model catalog."
+ "description": "Unique among your workspaces. Accepted by `workspace` in `POST /v1/runs`."
},
- "prompt_tokens": {
- "type": "integer",
- "description": "Input tokens used that day on that model."
+ "agent_selection": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Agents and tools chosen for the workspace."
},
- "completion_tokens": {
- "type": "integer",
- "description": "Output tokens generated that day on that model."
+ "status": {
+ "type": "string",
+ "enum": [
+ "provisioning",
+ "running",
+ "stopped",
+ "crashed",
+ "unknown"
+ ],
+ "description": "Runs can only be created while it is `running`."
},
- "total_tokens": {
- "type": "integer",
- "description": "`prompt_tokens` plus `completion_tokens`."
+ "ssh_command": {
+ "type": "string",
+ "description": "The command to connect over SSH."
},
- "api_requests": {
- "type": "integer",
- "description": "Requests made that day on that model. Request counts are only available from 2026-09-02 onward; older days report `0`."
- }
- }
- },
- "UsageTotals": {
- "type": "object",
- "description": "Totals over the full requested window, independent of pagination.",
- "properties": {
- "prompt_tokens": {
- "type": "integer",
- "description": "Total input tokens in the window."
+ "vcpus": {
+ "type": "integer"
},
- "completion_tokens": {
- "type": "integer",
- "description": "Total output tokens in the window."
+ "memory_mib": {
+ "type": "integer"
},
- "total_tokens": {
- "type": "integer",
- "description": "`prompt_tokens` plus `completion_tokens`."
+ "disk_gib": {
+ "type": "integer"
},
- "api_requests": {
- "type": "integer",
- "description": "Total requests in the window. Request counts are only available from 2026-09-02 onward; older days report `0`."
+ "base_image_ver": {
+ "type": "string"
},
- "by_model": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/UsageModelTotals"
- },
- "description": "The same totals, broken down per model."
- }
- }
- },
- "UsageModelTotals": {
- "type": "object",
- "description": "Window totals for one model.",
- "properties": {
- "model": {
+ "tier": {
"type": "string",
- "description": "The model id, as in the Model catalog."
+ "enum": [
+ "free",
+ "micro",
+ "nano",
+ "basic",
+ "medium",
+ "large"
+ ],
+ "description": "Size. `free` is the Micro-sized workspace included with Premium."
},
- "prompt_tokens": {
- "type": "integer",
- "description": "Input tokens used on this model in the window."
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
},
- "completion_tokens": {
- "type": "integer",
- "description": "Output tokens generated on this model in the window."
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
},
- "total_tokens": {
- "type": "integer",
- "description": "`prompt_tokens` plus `completion_tokens`."
+ "state_reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "enum": [
+ "dunning",
+ "canceled",
+ "disputed",
+ "churn",
+ "banned",
+ "pending_capacity",
+ "rescue",
+ null
+ ],
+ "description": "Why the workspace is not running normally, or `null`."
},
- "api_requests": {
- "type": "integer",
- "description": "Requests made on this model in the window. Request counts are only available from 2026-09-02 onward; older days report `0`."
- }
- }
- },
- "UsageAllTime": {
- "type": "object",
- "description": "Your lifetime usage across all models.",
- "properties": {
- "prompt_tokens": {
- "type": "integer",
- "description": "Total input tokens ever used."
+ "grace_until": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time",
+ "description": "When an open grace period ends (the data is deleted then), or `null`."
},
- "completion_tokens": {
- "type": "integer",
- "description": "Total output tokens ever generated."
+ "slot": {
+ "oneOf": [
+ {
+ "$ref": "#/components/schemas/WorkspaceSlot"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "`null` for the free workspace."
},
- "total_tokens": {
- "type": "integer",
- "description": "`prompt_tokens` plus `completion_tokens`."
+ "inference_attached": {
+ "type": "boolean",
+ "description": "Whether the workspace has an inference key (never the key itself). Runs need it."
},
- "api_requests": {
- "type": "integer",
- "description": "Total requests recorded for this member. Request counts are only available from 2026-09-02 onward; older days report `0`."
+ "rescue": {
+ "type": "object",
+ "description": "Rescue sessions during a grace period.",
+ "properties": {
+ "allowed": {
+ "type": "boolean"
+ },
+ "active_until": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "format": "date-time"
+ },
+ "reason": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "rescues_left_today": {
+ "type": [
+ "integer",
+ "null"
+ ]
+ }
+ }
},
- "cached_at": {
- "type": "string",
- "description": "When the all-time summary was last computed, as an ISO 8601 UTC timestamp."
+ "backup": {
+ "type": [
+ "object",
+ "null"
+ ],
+ "description": "The backup add-on.",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "off",
+ "pending_payment",
+ "active",
+ "disabling",
+ "disabled",
+ "failed"
+ ]
+ },
+ "stale": {
+ "type": "boolean",
+ "description": "`true` when the last snapshot is older than expected."
+ }
+ }
}
}
+ },
+ "WorkspaceDetail": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/Workspace"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env_var_keys": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Names of the variables in the workspace's environment file. Never the values."
+ }
+ }
+ }
+ ]
}
},
"responses": {
@@ -2581,6 +4650,24 @@
}
}
}
+ },
+ "RunsUnauthorized": {
+ "description": "Missing or invalid credentials: no `Authorization` header, an unknown or revoked API key or platform token, or an expired one (`invalid_api_key`). The message may vary.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ },
+ "example": {
+ "error": {
+ "message": "Invalid authentication credentials. Provide a valid NaN API key in the Authorization header.",
+ "type": "invalid_request_error",
+ "param": null,
+ "code": "invalid_api_key"
+ }
+ }
+ }
+ }
}
}
},
diff --git a/src/lib/openapiRuns.test.ts b/src/lib/openapiRuns.test.ts
new file mode 100644
index 0000000..755f433
--- /dev/null
+++ b/src/lib/openapiRuns.test.ts
@@ -0,0 +1,397 @@
+import { describe, expect, it } from 'vitest';
+import spec from '../data/openapi.json';
+import { resolveSpec } from './apiDoc';
+import { DEFAULT_RATE_LIMITS } from './rateLimits';
+import { openapiToText } from './openapiToText';
+
+/**
+ * The Runs and Workspaces surface of the API reference.
+ *
+ * Shapes come from cloud-api origin/main: internal/runs/api.go (limits,
+ * states, error codes), internal/runs/protocol (event kinds and their data),
+ * internal/handlers/runs.go + runs_sse.go (routes, bodies, statuses, SSE
+ * frames), internal/handlers/workspace.go + internal/workspace/models.go
+ * (the workspace view) and the platform-token middleware
+ * (internal/middleware/auth.go, handlers.PlatformTokenAPIRoutes).
+ *
+ * There is no OpenAPI validator among the dependencies, so the examples are
+ * checked against their schemas with the small validator below: enough of
+ * JSON Schema for what this spec uses ($ref, type, enum, required,
+ * properties, additionalProperties: false, items, oneOf, allOf, pattern,
+ * minimum/maximum, minLength).
+ */
+
+type Schema = Record;
+const s = spec as any;
+
+function deref(schema: Schema): Schema {
+ if (schema && typeof schema.$ref === 'string') {
+ const target = schema.$ref
+ .replace(/^#\//, '')
+ .split('/')
+ .reduce((node: any, part: string) => node?.[part], s);
+ expect(target, schema.$ref).toBeDefined();
+ return deref(target);
+ }
+ return schema;
+}
+
+function typeOf(v: unknown): string {
+ if (v === null) return 'null';
+ if (Array.isArray(v)) return 'array';
+ if (typeof v === 'number') return Number.isInteger(v) ? 'integer' : 'number';
+ return typeof v;
+}
+
+/** Errors of `value` against `schema`, as `path: reason` strings. */
+function validate(value: unknown, schema: Schema, path = '$'): string[] {
+ const sc = deref(schema);
+ const errors: string[] = [];
+ if (sc.oneOf) {
+ const ok = sc.oneOf.filter((alt: Schema) => validate(value, alt, path).length === 0).length;
+ if (ok !== 1) errors.push(`${path}: matches ${ok} of oneOf`);
+ return errors;
+ }
+ if (sc.allOf) {
+ // Merge the parts into one object schema, so one part's fields are not
+ // "unknown" to the other.
+ const parts = sc.allOf.map(deref);
+ const merged = {
+ type: 'object',
+ required: parts.flatMap((p: Schema) => p.required ?? []),
+ properties: Object.assign({}, ...parts.map((p: Schema) => p.properties ?? {})),
+ };
+ return validate(value, merged, path);
+ }
+ if (sc.type) {
+ const types = Array.isArray(sc.type) ? sc.type : [sc.type];
+ const t = typeOf(value);
+ if (!types.includes(t) && !(t === 'integer' && types.includes('number'))) {
+ return [`${path}: ${t} is not ${types.join('|')}`];
+ }
+ }
+ if (sc.enum && !sc.enum.includes(value)) errors.push(`${path}: ${JSON.stringify(value)} not in enum`);
+ if (typeof value === 'string') {
+ if (sc.pattern && !new RegExp(sc.pattern).test(value)) errors.push(`${path}: does not match ${sc.pattern}`);
+ if (sc.minLength !== undefined && value.length < sc.minLength) errors.push(`${path}: too short`);
+ if (sc.maxLength !== undefined && value.length > sc.maxLength) errors.push(`${path}: too long`);
+ }
+ if (typeof value === 'number') {
+ if (sc.minimum !== undefined && value < sc.minimum) errors.push(`${path}: below minimum`);
+ if (sc.maximum !== undefined && value > sc.maximum) errors.push(`${path}: above maximum`);
+ }
+ if (typeOf(value) === 'object') {
+ const obj = value as Record;
+ for (const key of sc.required ?? []) if (!(key in obj)) errors.push(`${path}: missing ${key}`);
+ for (const [key, v] of Object.entries(obj)) {
+ const prop = sc.properties?.[key];
+ if (prop) errors.push(...validate(v, prop, `${path}.${key}`));
+ else if (sc.properties && sc.additionalProperties !== true) errors.push(`${path}: unknown property ${key}`);
+ }
+ }
+ if (Array.isArray(value) && sc.items) value.forEach((v, i) => errors.push(...validate(v, sc.items, `${path}[${i}]`)));
+ return errors;
+}
+
+const RUN_OPS: Array<[string, string, string]> = [
+ ['/runs', 'post', 'createRun'],
+ ['/runs', 'get', 'listRuns'],
+ ['/runs/{id}', 'get', 'getRun'],
+ ['/runs/{id}/events', 'get', 'getRunEvents'],
+ ['/runs/{id}/cancel', 'post', 'cancelRun'],
+];
+const WS_OPS: Array<[string, string, string]> = [
+ ['/api/workspaces', 'get', 'listWorkspaces'],
+ ['/api/workspaces/{id}', 'get', 'getWorkspace'],
+];
+const op = (path: string, method: string) => s.paths[path][method];
+
+describe('openapi.json: Runs and Workspaces tags', () => {
+ it('declares both tags with a link to their guide', () => {
+ const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description]));
+ expect(tags.Runs).toContain('https://nan.builders/docs/runs');
+ expect(tags.Workspaces).toContain('https://nan.builders/docs/workspaces');
+ });
+
+ it.each([...RUN_OPS.map((o) => [...o, 'Runs']), ...WS_OPS.map((o) => [...o, 'Workspaces'])])(
+ '%s %s (%s) is tagged %s',
+ (path, method, id, tag) => {
+ expect(op(path, method).operationId).toBe(id);
+ expect(op(path, method).tags).toEqual([tag]);
+ },
+ );
+});
+
+describe('openapi.json: credentials', () => {
+ const schemes = s.components.securitySchemes;
+
+ it('documents the API key and the platform token as two bearer schemes', () => {
+ expect(schemes.apiKey).toMatchObject({ type: 'http', scheme: 'bearer' });
+ expect(schemes.apiKey.description).toContain('`sk-...`');
+ expect(schemes.platformToken).toMatchObject({ type: 'http', scheme: 'bearer', bearerFormat: 'nan_pat_...' });
+ expect(schemes.platformToken.description).toMatch(/never works for inference/);
+ expect(schemes.platformToken.description).toContain('token_scope_denied');
+ });
+
+ it('inference operations take the API key only (the global default)', () => {
+ expect(s.security).toEqual([{ apiKey: [] }]);
+ for (const [path, item] of Object.entries(s.paths) as Array<[string, any]>) {
+ if (path.startsWith('/runs') || path.startsWith('/api/')) continue;
+ for (const method of ['get', 'post']) if (item[method]) expect(item[method].security, path).toBeUndefined();
+ }
+ });
+
+ it.each(RUN_OPS)('%s %s accepts an API key or a platform token', (path, method) => {
+ expect(op(path, method).security).toEqual([{ apiKey: [] }, { platformToken: [] }]);
+ expect(op(path, method).description).toContain('a member API key (`sk-...`) or a platform token (`nan_pat_...`)');
+ });
+
+ it('both Workspaces operations accept an API key or a platform token, never a workspace key (cloud-api #325)', () => {
+ for (const [path, method] of WS_OPS) {
+ expect(op(path, method).security).toEqual([{ apiKey: [] }, { platformToken: [] }]);
+ expect(op(path, method).description).toContain('a member API key (`sk-...`) or a platform token (`nan_pat_...`)');
+ expect(op(path, method).description).toMatch(/key that lives inside a workspace is not accepted/);
+ }
+ });
+
+ it('the overview explains platform tokens', () => {
+ expect(s.info.description).toContain('### Platform tokens');
+ expect(s.info.description).toMatch(/never works for inference/);
+ });
+});
+
+describe('openapi.json: the Runs contract (cloud-api)', () => {
+ const body = s.components.schemas.CreateRunRequest;
+
+ it('POST /runs body: fields, limits and defaults', () => {
+ expect(body.required).toEqual(['workspace', 'agent', 'prompt']);
+ expect(body.additionalProperties).toBe(false);
+ expect(Object.keys(body.properties)).toEqual([
+ 'workspace', 'agent', 'prompt', 'cwd', 'git_isolation', 'timeout_seconds', 'model', 'trigger',
+ ]);
+ expect(body.properties.agent.enum).toEqual(['pi', 'hermes']);
+ expect(body.properties.prompt.description).toContain('32768 bytes');
+ expect(body.properties.cwd.default).toBe('/home/nan');
+ expect(body.properties.git_isolation.type).toEqual(['boolean', 'null']);
+ expect(body.properties.timeout_seconds).toMatchObject({ minimum: 60, maximum: 7200, default: 1800 });
+ expect(body.properties.trigger).toMatchObject({ enum: ['api', 'cli', 'ui'], default: 'api' });
+ expect(body.properties.model.pattern).toBe('^[A-Za-z0-9._:/-]{1,128}$');
+ });
+
+ it('POST /runs: Idempotency-Key, 201/200 and every documented error code', () => {
+ const create = op('/runs', 'post');
+ const idem = create.parameters.find((p: any) => p.name === 'Idempotency-Key');
+ expect(idem).toMatchObject({ in: 'header', required: false });
+ expect(idem.schema.pattern).toBe('^[A-Za-z0-9._:-]{1,128}$');
+ expect(Object.keys(create.responses).sort()).toEqual(['200', '201', '400', '401', '403', '404', '409', '429']);
+ const text = JSON.stringify(create.responses);
+ for (const code of [
+ 'tier_restricted', 'workspace_key_not_allowed', 'workspace_not_running', 'agent_not_installed',
+ 'no_inference_key', 'idempotency_conflict', 'run_queue_full', 'rate_limited',
+ ]) {
+ expect(text, code).toContain(code);
+ }
+ expect(create.description).toMatch(/20 queued runs per workspace and 50 in total/);
+ expect(create.description).toMatch(/60 new runs per rolling hour/);
+ });
+
+ it('only GET /runs/{id} returns the full prompt (the handler renders it nowhere else)', () => {
+ for (const [path, method] of RUN_OPS) {
+ for (const [status, r] of Object.entries(op(path, method).responses) as Array<[string, any]>) {
+ const ex = r.content?.['application/json']?.example;
+ if (!ex || typeof ex !== 'object') continue;
+ const runs = Array.isArray(ex.data) ? ex.data : [ex];
+ for (const run of runs) {
+ if (run?.object !== 'run') continue;
+ expect('prompt' in run, `${method} ${path} ${status}`).toBe(path === '/runs/{id}' && method === 'get');
+ }
+ }
+ }
+ });
+
+ it('GET /runs: an unknown workspace filter is an empty page, not a 404', () => {
+ expect(op('/runs', 'get').responses['404']).toBeUndefined();
+ const ws = op('/runs', 'get').parameters.find((p: any) => p.name === 'workspace');
+ expect(ws.description).toMatch(/empty page/);
+ });
+
+ it('runs 401s describe both credentials', () => {
+ for (const [path, method] of RUN_OPS) {
+ expect(op(path, method).responses['401']).toEqual({ $ref: '#/components/responses/RunsUnauthorized' });
+ }
+ expect(s.components.responses.RunsUnauthorized.description).toMatch(/API key or platform token/);
+ });
+
+ it('GET /runs: filters and the list object', () => {
+ const params = Object.fromEntries(op('/runs', 'get').parameters.map((p: any) => [p.name, p]));
+ expect(Object.keys(params)).toEqual(['workspace', 'state', 'limit', 'cursor']);
+ expect(params.limit.schema).toMatchObject({ minimum: 1, maximum: 100, default: 20 });
+ expect(params.state.schema.enum).toEqual(['queued', 'starting', 'running', 'succeeded', 'failed', 'cancelled', 'timed_out']);
+ expect(Object.keys(s.components.schemas.RunList.properties)).toEqual(['object', 'data', 'next_cursor']);
+ });
+
+ it('the Run object has the fields the handler renders, in its order', () => {
+ expect(Object.keys(s.components.schemas.Run.properties)).toEqual([
+ 'id', 'object', 'workspace', 'agent', 'model', 'trigger', 'state', 'queue_position', 'prompt_preview',
+ 'prompt', 'cwd', 'git_isolation', 'timeout_seconds', 'cancel_requested', 'exit_code', 'error_code',
+ 'result', 'usage', 'last_seq', 'created_at', 'started_at', 'finished_at',
+ ]);
+ expect(Object.keys(s.components.schemas.RunResult.properties)).toEqual([
+ 'status', 'summary', 'branch', 'commit', 'changed_files', 'artifacts',
+ ]);
+ });
+
+ it('events: the ten event kinds, the message chunks and the page shape', () => {
+ const event = s.components.schemas.RunEvent;
+ expect(Object.keys(event.properties)).toEqual(['seq', 'ts', 'type', 'data']);
+ expect(event.properties.type.enum).toEqual([
+ 'started', 'log', 'message', 'tool_call', 'tool_result', 'usage', 'artifact', 'error', 'truncated', 'finished',
+ ]);
+ expect(event.properties.data.oneOf).toHaveLength(10);
+ expect(s.components.schemas.RunEventDataMessage.description).toMatch(/incrementally.*`final: true`/s);
+ expect(Object.keys(s.components.schemas.RunEventPage.properties)).toEqual(['data', 'next_after', 'done']);
+ });
+
+ it('events: SSE frames, heartbeat, resume, lifetime and stream cap', () => {
+ const events = op('/runs/{id}/events', 'get');
+ for (const needle of ['event: run_event', 'event: state', 'event: end', '`: hb`', '15 seconds', 'Last-Event-ID', '30 minutes', '10 open streams']) {
+ expect(events.description, needle).toContain(needle);
+ }
+ expect(Object.keys(events.responses['200'].content)).toEqual(['text/event-stream', 'application/json']);
+ const params = Object.fromEntries(events.parameters.map((p: any) => [p.name, p]));
+ expect(params.limit.schema).toMatchObject({ minimum: 1, maximum: 1000, default: 500 });
+ expect(params['Last-Event-ID'].in).toBe('header');
+ expect(JSON.stringify(events.responses['429'])).toContain('too_many_streams');
+ const sse = events.responses['200'].content['text/event-stream'].example as string;
+ expect(sse).toMatch(/^id: 1\nevent: run_event\ndata: \{/);
+ expect(sse).toContain(': hb\n\n');
+ });
+
+ it('cancel: 202 accepted, 200 when the run had already ended', () => {
+ expect(Object.keys(op('/runs/{id}/cancel', 'post').responses).sort()).toEqual(['200', '202', '401', '403', '404']);
+ });
+});
+
+describe('openapi.json: the Workspaces contract (cloud-api)', () => {
+ it('both routes live on api.nan.builders, outside /v1, and nothing else under /api', () => {
+ const apiPaths = Object.keys(s.paths).filter((p) => p.startsWith('/api/'));
+ expect(apiPaths).toEqual(['/api/workspaces', '/api/workspaces/{id}']);
+ for (const p of apiPaths) {
+ expect(s.paths[p].servers).toEqual([expect.objectContaining({ url: 'https://api.nan.builders' })]);
+ expect(Object.keys(s.paths[p]).filter((k) => k !== 'servers')).toEqual(['get']);
+ }
+ expect(JSON.stringify(s)).not.toContain('cloud-api.nan.builders');
+ });
+
+ it('GET /api/workspaces/{id}: a foreign workspace is the same 404 as an unknown one, never 403 (cloud-api #324)', () => {
+ const r = op('/api/workspaces/{id}', 'get').responses;
+ expect(Object.keys(r).sort()).toEqual(['200', '401', '404']);
+ expect(r['404'].content['application/json'].examples.workspace_not_found.value).toEqual({ error: 'workspace not found' });
+ expect(r['404'].description).toMatch(/belongs to another member \(the answer is identical\)/);
+ // A non-UUID id never reaches the API on api.nan.builders: the edge answers 404 in the OpenAI shape.
+ expect(r['404'].content['application/json'].examples.malformed_id.value).toEqual({
+ error: { message: 'Not found.', type: 'invalid_request_error', code: '404' },
+ });
+ expect(JSON.stringify(s.paths['/api/workspaces/{id}'])).not.toMatch(/forbidden|token_scope_denied/);
+ });
+
+ /**
+ * The 401 bodies of cloud-api's /api Auth (internal/middleware/auth.go),
+ * byte for byte: no credential, a credential that resolves to nobody, and a
+ * key the route refuses (the workspace key).
+ */
+ it.each(WS_OPS)('%s %s: the 401 examples are the real bodies', (path, method) => {
+ const ex = op(path, method).responses['401'].content['application/json'].examples;
+ expect(Object.keys(ex)).toEqual(['unauthorized', 'invalid_session', 'session_required']);
+ expect(ex.unauthorized.value).toEqual({ error: 'unauthorized' });
+ expect(ex.invalid_session.value).toEqual({ error: 'invalid session' });
+ expect(ex.session_required.value).toEqual({
+ error: 'session_required',
+ message: 'This endpoint requires a signed-in session. API keys only work for inference (https://api.nan.builders/v1).',
+ });
+ });
+
+ it.each(WS_OPS)('%s %s: says api.nan.builders is bearer-only with no browser CORS', (path, method) => {
+ const d = op(path, method).description;
+ expect(d).toMatch(/bearer credential only: cookies are ignored/);
+ expect(d).toMatch(/no browser CORS support \(`OPTIONS` answers `404`\)/);
+ });
+
+ it('only the caller\'s own workspaces, and never a secret', () => {
+ expect(op('/api/workspaces', 'get').description).toMatch(/Your own workspaces, and only yours/);
+ expect(op('/api/workspaces/{id}', 'get').description).toMatch(/Only your own workspaces are returned: another member's workspace answers `404`/);
+ const props = Object.keys(s.components.schemas.Workspace.properties);
+ for (const secret of ['agent_configs', 'hermes_env', 'member_uuid', 'api_key', 'token']) {
+ expect(props).not.toContain(secret);
+ }
+ expect(s.components.schemas.WorkspaceDetail.allOf[1].properties.env_var_keys.description).toMatch(/Never the values/);
+ });
+});
+
+describe('openapi.json: every Runs and Workspaces example matches its schema', () => {
+ const cases: Array<[string, unknown, Schema]> = [];
+ for (const [path, method] of [...RUN_OPS, ...WS_OPS]) {
+ const o = op(path, method);
+ const req = o.requestBody?.content?.['application/json'];
+ if (req?.example) cases.push([`${method} ${path} request`, req.example, req.schema]);
+ for (const [status, r] of Object.entries(o.responses) as Array<[string, any]>) {
+ const json = r.content?.['application/json'];
+ if (!json) continue;
+ if (json.example !== undefined) cases.push([`${method} ${path} ${status}`, json.example, json.schema]);
+ for (const [name, ex] of Object.entries(json.examples ?? {}) as Array<[string, any]>) {
+ cases.push([`${method} ${path} ${status} ${name}`, ex.value, json.schema]);
+ }
+ }
+ }
+ cases.push(['RunEvent example', s.components.schemas.RunEvent.example, s.components.schemas.RunEvent]);
+
+ it('finds the examples to check', () => {
+ expect(cases.length).toBeGreaterThan(20);
+ });
+
+ it.each(cases)('%s', (_name, example, schema) => {
+ expect(validate(example, schema)).toEqual([]);
+ });
+
+ it('the SSE example frames carry valid events', () => {
+ const sse = op('/runs/{id}/events', 'get').responses['200'].content['text/event-stream'].example as string;
+ const frames = sse.split('\n\n').filter((f) => f.includes('event: run_event'));
+ expect(frames.length).toBeGreaterThan(0);
+ for (const frame of frames) {
+ const data = JSON.parse(/^data: (.*)$/m.exec(frame)![1]);
+ expect(validate(data, { $ref: '#/components/schemas/RunEvent' })).toEqual([]);
+ expect(frame).toContain(`id: ${data.seq}\n`);
+ }
+ });
+
+ it('the validator itself rejects a wrong example', () => {
+ expect(validate({ workspace: 'w', agent: 'codex', prompt: 'x' }, s.components.schemas.CreateRunRequest)).not.toEqual([]);
+ expect(validate({ workspace: 'w', agent: 'pi', prompt: 'x', extra: 1 }, s.components.schemas.CreateRunRequest)).not.toEqual([]);
+ expect(validate({ timeout_seconds: 30 }, s.components.schemas.CreateRunRequest)).not.toEqual([]);
+ });
+});
+
+describe('the text the docs API serves for the reference', () => {
+ it('includes the Runs and Workspaces operations', () => {
+ const text = openapiToText(resolveSpec(DEFAULT_RATE_LIMITS));
+ for (const heading of [
+ '### POST /runs: Create a run',
+ '### GET /runs/{id}/events: Follow a run',
+ '### POST /runs/{id}/cancel: Cancel a run',
+ '### GET /api/workspaces: List workspaces',
+ '### GET /api/workspaces/{id}: Retrieve a workspace',
+ ]) {
+ expect(text, heading).toContain(heading);
+ }
+ });
+
+ it('no em-dashes in what this PR adds', () => {
+ const added = JSON.stringify([
+ s.tags.filter((t: any) => ['Runs', 'Workspaces'].includes(t.name)),
+ ...[...RUN_OPS, ...WS_OPS].map(([p, m]) => op(p, m)),
+ s.components.securitySchemes,
+ Object.entries(s.components.schemas).filter(([k]) => /^(Run|Workspace|CreateRun|PlatformError)/.test(k)),
+ ]);
+ expect(added).not.toContain('—');
+ });
+});
diff --git a/src/lib/openapiSpec.test.ts b/src/lib/openapiSpec.test.ts
index 1f5d42e..f00970b 100644
--- a/src/lib/openapiSpec.test.ts
+++ b/src/lib/openapiSpec.test.ts
@@ -17,13 +17,16 @@ import { DEFAULT_RATE_LIMITS, formatTokens, getRateLimitsConfig, USAGE_REQUESTS_
* this mostly watches for is anything from there creeping back in.
*
* The endpoint surface was checked against the real backend by probing each
- * route: the 11 listed here answer 401 (they exist and want auth) while
- * /v1/moderations, /v1/batches and /v1/files answer 404 (not enabled on NaN).
+ * route: the 11 inference routes listed here answer 401 (they exist and want
+ * auth) while /v1/moderations, /v1/batches and /v1/files answer 404 (not
+ * enabled on NaN). The Runs and Workspaces operations come from cloud-api
+ * (internal/handlers/runs.go, PlatformTokenAPIRoutes) and are pinned in
+ * openapiRuns.test.ts.
*/
const raw = JSON.stringify(spec);
-/** The 11 public routes verified against api.nan.builders. */
+/** The public operations: the 11 inference routes verified against api.nan.builders, plus Runs and Workspaces. */
const PUBLIC_SURFACE: Array<[string, string]> = [
['/models', 'get'],
['/chat/completions', 'post'],
@@ -36,6 +39,13 @@ const PUBLIC_SURFACE: Array<[string, string]> = [
['/images/generations', 'post'],
['/images/edits', 'post'],
['/usage', 'get'],
+ ['/runs', 'post'],
+ ['/runs', 'get'],
+ ['/runs/{id}', 'get'],
+ ['/runs/{id}/events', 'get'],
+ ['/runs/{id}/cancel', 'post'],
+ ['/api/workspaces', 'get'],
+ ['/api/workspaces/{id}', 'get'],
];
/** NaN's real catalogue (src/data/modelos.json + the API reference). */
@@ -104,7 +114,14 @@ describe('openapi.json: the surface is what the backend actually serves', () =>
for (const absent of ['/moderations', '/batches', '/files']) {
expect(documented).not.toContain(absent);
}
- expect(documented.length).toBe(PUBLIC_SURFACE.length);
+ // Every documented operation is on the list, and nothing else is.
+ const operations = Object.entries(spec.paths as Record>).flatMap(
+ ([path, item]) =>
+ Object.keys(item)
+ .filter((k) => ['get', 'post', 'put', 'patch', 'delete'].includes(k))
+ .map((method) => `${method} ${path}`),
+ );
+ expect(operations.sort()).toEqual(PUBLIC_SURFACE.map(([p, m]) => `${m} ${p}`).sort());
});
});
diff --git a/src/lib/openapiToText.ts b/src/lib/openapiToText.ts
index f2d2dc7..f812df3 100644
--- a/src/lib/openapiToText.ts
+++ b/src/lib/openapiToText.ts
@@ -28,13 +28,22 @@ interface Spec {
info?: { title?: string; description?: string; version?: string };
servers?: Array<{ url?: string; description?: string }>;
tags?: Array<{ name?: string; description?: string }>;
- paths?: Record>;
+ paths?: Record;
components?: {
schemas?: Record;
responses?: Record;
};
}
+/**
+ * A Path Item: its operations keyed by HTTP method, plus an optional
+ * path-level `servers` override (OpenAPI 3: e.g. /api/workspaces lives on
+ * https://api.nan.builders, outside the spec-wide /v1 base URL).
+ */
+type PathItem = { servers?: Array<{ url?: string; description?: string }> } & {
+ [method: string]: Operation | Array<{ url?: string; description?: string }> | undefined;
+};
+
interface Operation {
operationId?: string;
summary?: string;
@@ -196,11 +205,19 @@ function responseDescription(spec: Spec, res: ResponseOrRef | undefined): string
return '';
}
-function renderOperation(spec: Spec, path: string, method: string, op: Operation): string {
+function renderOperation(
+ spec: Spec,
+ path: string,
+ method: string,
+ op: Operation,
+ pathServer?: string,
+): string {
const out: string[] = [];
const title = op.summary || op.operationId || `${method.toUpperCase()} ${path}`;
out.push(`### ${method.toUpperCase()} ${path}: ${title}`);
+ // A path-level server overrides the spec-wide base URL for this operation.
+ if (pathServer && pathServer !== spec.servers?.[0]?.url) out.push(`Base URL: \`${pathServer}\``);
if (op.deprecated) out.push('**Deprecated.**');
// The operation is a `###`, so its subsections drop to `#####` so they do
// not compete with the `####` this same function emits (Parameters, etc.).
@@ -297,15 +314,16 @@ export function openapiToText(spec: Spec): string {
const tagOrder: string[] = (spec.tags ?? []).map((t) => t.name ?? '').filter(Boolean);
for (const [path, item] of Object.entries(spec.paths ?? {})) {
+ const pathServer = item?.servers?.[0]?.url;
for (const method of HTTP_METHODS) {
- const op = item?.[method];
- if (!op) continue;
+ const op = item?.[method] as Operation | undefined;
+ if (!op || Array.isArray(op)) continue;
const tag = op.tags?.[0] || 'Endpoints';
if (!byTag.has(tag)) {
byTag.set(tag, []);
if (!tagOrder.includes(tag)) tagOrder.push(tag);
}
- byTag.get(tag)!.push(renderOperation(spec, path, method, op));
+ byTag.get(tag)!.push(renderOperation(spec, path, method, op, pathServer));
}
}
diff --git a/src/lib/openapiToTextPathServers.test.ts b/src/lib/openapiToTextPathServers.test.ts
new file mode 100644
index 0000000..31f787d
--- /dev/null
+++ b/src/lib/openapiToTextPathServers.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import rawSpec from '../data/openapi.json';
+import { openapiToText } from './openapiToText';
+
+// /api/workspaces lives on https://api.nan.builders, outside the spec-wide
+// https://api.nan.builders/v1 base URL, through a path-level `servers`
+// override. The text rendering served at /api/docs must not send agents to
+// /v1/api/workspaces, a route that does not exist.
+describe('openapiToText: path-level servers', () => {
+ const text = openapiToText(rawSpec as Parameters[0]);
+
+ it('prints the override for the workspace operations', () => {
+ for (const path of ['/api/workspaces', '/api/workspaces/{id}']) {
+ const start = text.indexOf(`### GET ${path}:`);
+ expect(start).toBeGreaterThan(-1);
+ const section = text.slice(start, text.indexOf('\n### ', start + 1) === -1 ? undefined : text.indexOf('\n### ', start + 1));
+ expect(section).toContain('Base URL: `https://api.nan.builders`');
+ }
+ });
+
+ it('does not print a per-operation base URL for operations on the default server', () => {
+ const start = text.indexOf('### POST /runs:');
+ expect(start).toBeGreaterThan(-1);
+ const end = text.indexOf('\n### ', start + 1);
+ expect(text.slice(start, end === -1 ? undefined : end)).not.toContain('Base URL:');
+ });
+});
diff --git a/src/tests/layouts/runsDocs.test.ts b/src/tests/layouts/runsDocs.test.ts
index 4ac44ef..b2a5a49 100644
--- a/src/tests/layouts/runsDocs.test.ts
+++ b/src/tests/layouts/runsDocs.test.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { parseFrontmatter } from '@astrojs/markdown-remark';
import { mdxToText } from '../../lib/mdxToText';
+import openapi from '../../data/openapi.json';
/**
* The Agent runs guide: what members read before they start their first run,
@@ -132,7 +133,7 @@ describe.each(Object.entries(pages))('agent runs guide (%s)', (locale, page) =>
test('maps the portal labels to the CLI flags and the API fields', () => {
expect(flat).toMatch(/\*\*Agent\*\* (is|es) `--agent`, \*\*Folder\*\* (is|es) `--cwd`, \*\*Separate branch\*\* (is|es) `--worktree` \/ `--no-worktree` (and|y) \*\*Time limit\*\* (is|es) `--timeout`/);
- expect(flat).toMatch(/\*\*Agent\*\* (is|es) `agent`, \*\*Task\*\* (is|es) `prompt`, \*\*Folder\*\* (is|es) `cwd`, \*\*Separate branch\*\* (is|es) `git_isolation` (and|y) \*\*Time limit\*\* (is|es) `timeout_seconds`/);
+ expect(flat).toMatch(/\*\*Agent\*\* (is|es) `agent`, \*\*Task\*\* (is|es) `prompt`, \*\*Folder\*\* (is|es) `cwd`, (and the optional|y los opcionales) \*\*Separate branch\*\* (and|y) \*\*Time limit\*\* (are|son) `git_isolation` (and|y) `timeout_seconds`/);
});
test('describes the portal form with its limits', () => {
@@ -174,7 +175,7 @@ describe.each(Object.entries(pages))('agent runs guide (%s)', (locale, page) =>
});
test('says runs need inference, count as usage and mask secrets', () => {
- expect(body).toContain('tier_restricted');
+ expect(flat).toMatch(/\*\*(A plan with inference|Un plan con inferencia)\.\*\*/);
expect(body).toContain('`***`');
});
@@ -194,29 +195,38 @@ describe.each(Object.entries(pages))('agent runs guide (%s)', (locale, page) =>
expect(flat).toMatch(/2 (seconds|segundos)/);
});
- test('documents the API with working curl examples: create, follow, cancel', () => {
- expect(body).toContain('-H "Idempotency-Key:');
- expect(body).toContain('curl -N https://api.nan.builders/v1/runs/$RUN_ID/events');
+ test('keeps one end-to-end API example: create a run, then follow it with curl -N', () => {
+ expect(body).toContain('RESP=$(curl -s https://api.nan.builders/v1/runs');
+ // A failed create must show its error, not follow /runs/null/events.
+ expect(body).toContain(`RUN_ID=$(echo "$RESP" | jq -r '.id // empty')`);
+ // Safe to paste into an interactive shell: no `exit` that would close it.
+ expect(body).toContain('if [ -z "$RUN_ID" ]; then\n echo "$RESP"\nelse');
+ expect(body).not.toMatch(/\bexit 1\b/);
+ expect(body).toContain('curl -N "https://api.nan.builders/v1/runs/$RUN_ID/events"');
expect(body).toContain('-H "Accept: text/event-stream"');
- expect(body).toContain('curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/cancel');
- // One table, rows back to back: a paragraph between two rows splits it and
- // turns the rest into loose text with pipes.
- const rows = ['workspace', 'agent', 'prompt', 'cwd', 'git_isolation', 'timeout_seconds', 'model'];
- const table = rows.map((f) => `\\| \`${f}\` \\|[^\\n]*`).join('\\n');
- expect(body).toMatch(new RegExp(table));
- for (const frame of ['event: run_event', 'event: state', 'event: end']) expect(body).toContain(frame);
- expect(body).toContain('Last-Event-ID');
- expect(body).toContain('next_cursor');
- expect(body).toContain('"error": {"message"');
- for (const code of ['400 invalid_request', '404', '409 idempotency_conflict', '429 run_queue_full', '429 rate_limited']) expect(body, code).toContain(`| \`${code}\` |`);
- });
-
- test('the create example is valid JSON with the documented fields', () => {
+ for (const frame of ['`run_event`', '`state`', '`end`']) expect(body).toContain(frame);
+ });
+
+ test('the create example is valid JSON that the API reference schema accepts', () => {
const json = /-d '(\{[\s\S]*?\})'/.exec(body)![1];
const req = JSON.parse(json);
- expect(Object.keys(req).sort()).toEqual(['agent', 'cwd', 'git_isolation', 'prompt', 'timeout_seconds', 'workspace']);
- expect(req.timeout_seconds).toBeGreaterThanOrEqual(60);
- expect(req.timeout_seconds).toBeLessThanOrEqual(7200);
+ const schema = (openapi as any).components.schemas.CreateRunRequest;
+ for (const key of schema.required) expect(req, key).toHaveProperty(key);
+ for (const key of Object.keys(req)) expect(schema.properties, key).toHaveProperty(key);
+ expect(schema.properties.agent.enum).toContain(req.agent);
+ });
+
+ test('sends readers to the API reference for the full Runs and Workspaces contract', () => {
+ expect(body).toContain(`(${page.prefix}/docs/api#tag/runs)`);
+ expect(body).toContain(`(${page.prefix}/docs/api#tag/workspaces)`);
+ expect(body).toContain(`(${page.prefix}/docs/api#description/platform-tokens)`);
+ });
+
+ test('leaves the endpoint, error and event tables to the reference', () => {
+ expect(body).not.toMatch(/^### (Errors|Errores|Cancel it|Cancélalo|List your runs|Lista tus runs)$/m);
+ expect(body).not.toMatch(/^\| `(400 invalid_request|409 idempotency_conflict|429 run_queue_full)` \|/m);
+ expect(body).not.toMatch(/^\| `timeout_seconds` \|/m);
+ expect(body).not.toContain('curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/cancel');
});
test('explains API keys vs tokens', () => {
@@ -225,7 +235,6 @@ describe.each(Object.entries(pages))('agent runs guide (%s)', (locale, page) =>
expect(flat).toMatch(/\*\*(No, never|No, nunca)\*\*/);
expect(flat).toMatch(/\*\*10\*\*/);
expect(flat).toMatch(/\*\*30, 90 (or|o) 365/);
- expect(body).toContain('workspace_key_not_allowed');
expect(flat).toMatch(/(spend your plan|gastan tu plan)/);
});
@@ -359,3 +368,24 @@ describe.each(Object.entries(pages))('CLI authentication for runs (%s)', (locale
expect(cli).toContain('`NAN_TOKEN`, `--token-file` o'.replace(' o', locale === 'en' ? ' or' : ' o'));
});
});
+
+describe('the API reference documents what the guide links to', () => {
+ const spec = openapi as any;
+
+ test('the Runs and Workspaces tags exist', () => {
+ const tags = spec.tags.map((t: any) => t.name);
+ expect(tags).toEqual(expect.arrayContaining(['Runs', 'Workspaces']));
+ });
+
+ test('every runs operation is in the reference', () => {
+ expect(spec.paths['/runs'].post.operationId).toBe('createRun');
+ expect(spec.paths['/runs'].get.operationId).toBe('listRuns');
+ expect(spec.paths['/runs/{id}'].get.operationId).toBe('getRun');
+ expect(spec.paths['/runs/{id}/events'].get.operationId).toBe('getRunEvents');
+ expect(spec.paths['/runs/{id}/cancel'].post.operationId).toBe('cancelRun');
+ });
+
+ test('the overview has the Platform tokens section the guide links to', () => {
+ expect(spec.info.description).toContain('### Platform tokens');
+ });
+});