From 51bc74916c5d7b8233f94051c0fc5b6b71055f6a Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 22:18:54 +0200 Subject: [PATCH 1/4] docs: Automations and Webhooks and schedules guides (Agent Automations Phase 2) New guides in both locales, right after Agent runs: Automations (templates, settings, member secrets and the GitHub token for actions, actions and per-kind approval, approving and rejecting, run now from the portal, CLI 0.1.26 and API, token scopes, limits, browser sign-in) and Webhooks and schedules (GitHub, Stripe and NaN HMAC senders with signing examples, event and actor filters, what the agent receives, answers to the sender, delivery log, rotation, loops, cron schedules). Agent runs, NaN CLI and the introduction link them; awaiting_approval, the new events and exit code 10 are added to the runs guide. Examples and Apps move two places down. Tests pin the facts against cloud-api, the portal labels and nan-cli, check every cross-page anchor in both locales and the Spanish structure. Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/apps.md | 2 +- src/content/docs-es/automations.mdx | 342 ++++++++++++++++++++++ src/content/docs-es/examples.md | 2 +- src/content/docs-es/intro.md | 2 + src/content/docs-es/nan-cli.mdx | 15 + src/content/docs-es/runs.mdx | 13 +- src/content/docs-es/webhooks.mdx | 323 ++++++++++++++++++++ src/content/docs/apps.md | 2 +- src/content/docs/automations.mdx | 342 ++++++++++++++++++++++ src/content/docs/examples.md | 2 +- src/content/docs/intro.md | 2 + src/content/docs/nan-cli.mdx | 15 + src/content/docs/runs.mdx | 13 +- src/content/docs/webhooks.mdx | 323 ++++++++++++++++++++ src/tests/layouts/automationsDocs.test.ts | 303 +++++++++++++++++++ src/tests/lib/docsSnippets.test.ts | 4 +- 16 files changed, 1695 insertions(+), 10 deletions(-) create mode 100644 src/content/docs-es/automations.mdx create mode 100644 src/content/docs-es/webhooks.mdx create mode 100644 src/content/docs/automations.mdx create mode 100644 src/content/docs/webhooks.mdx create mode 100644 src/tests/layouts/automationsDocs.test.ts diff --git a/src/content/docs-es/apps.md b/src/content/docs-es/apps.md index 5140472..1402bd0 100644 --- a/src/content/docs-es/apps.md +++ b/src/content/docs-es/apps.md @@ -1,7 +1,7 @@ --- title: Apps description: Despliega tus apps desde GitHub a NaN Cloud en minutos. -order: 21 +order: 23 group: Guías --- diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx new file mode 100644 index 0000000..6e15574 --- /dev/null +++ b/src/content/docs-es/automations.mdx @@ -0,0 +1,342 @@ +--- +title: Automatizaciones +description: Guarda una tarea de agente una vez y deja que la lancen webhooks, programaciones o tus propios scripts, con las acciones que elijas esperando tu aprobación. +order: 20 +group: Guías +--- + +import Steps from '../../components/docs/Steps.astro'; +import Details from '../../components/docs/Details.astro'; +import Callout from '../../components/docs/Callout.astro'; + +# Automatizaciones. + +Una **automatización** es un [run de agente](/es/docs/runs) que configuras una vez y lanzas tantas veces como necesites: el workspace, el agente, las instrucciones, la carpeta, los secretos que recibe y qué puede lanzarla. Cada vez que arranca, crea un run en tu workspace con esos ajustes. + +Qué puede lanzar una automatización: + +- **Tú**, con **Run now** en el portal, `nan automations run` en la terminal o una llamada a la API. +- **Un webhook**: GitHub, Stripe o cualquier sistema que pueda firmar una petición. Consulta [Webhooks y programaciones](/es/docs/webhooks). +- **Una programación**: cada día laborable a las 9:00, cada hora, el día 1 de cada mes. También en [Webhooks y programaciones](/es/docs/webhooks). + +El agente de una automatización no hace push ni publica nada por su cuenta. **Propone acciones** (hacer push de una rama, abrir una pull request, comentar, revisar, añadir etiquetas) y NaN las aplica con un token que guardas aparte del agente. Tú decides qué tipos de acción esperan tu aprobación y cuáles se aplican solas: todas, ninguna o cualquier combinación. + +## Antes de empezar + +- **Un workspace encendido**, con Pi o Hermes instalado, como para cualquier [run](/es/docs/runs#antes-de-empezar). +- **Un plan con inferencia.** Los runs de una automatización usan la clave de inferencia de tu workspace y cuentan para el uso de tu plan como cualquier otro run. +- **Una sesión en el navegador.** Crear o cambiar automatizaciones, escribir secretos y aprobar acciones exige que hayas iniciado sesión en [cloud.nan.builders](https://cloud.nan.builders/) desde tu navegador, con el enlace por correo. Una sesión de la CLI, una API key o un token pueden listar automatizaciones y lanzarlas, pero nunca cambiarlas. Consulta [Por qué algunas acciones necesitan el navegador](#por-qué-algunas-acciones-necesitan-el-navegador). + +## Crea una automatización + + + +### Abre Automations + +Entra en [cloud.nan.builders/automations](https://cloud.nan.builders/automations), o en **Automations** en la barra lateral, bajo **Workspaces**. La página tiene dos pestañas: **Automations**, tu lista, y **Needs approval**, las propuestas que esperan por ti. + +### Elige una plantilla o empieza de cero + +Pulsa **New automation**. Una plantilla rellena valores razonables que puedes cambiar después; **From scratch** te lo deja todo a ti (el portal está en inglés). + +| Plantilla | Qué hace | Se lanza con | +|---|---|---| +| **Pull request review** | Revisa cada pull request nueva o actualizada y propone una review. | Webhook de GitHub: `pull_request` opened, synchronize, reopened o ready for review. Los borradores se ignoran. | +| **Issue triage** | Analiza las issues nuevas, comenta y propone etiquetas. | Webhook de GitHub: `issues` opened o reopened. | +| **CI failure** | Investiga las ejecuciones de workflow fallidas y propone un arreglo (un push y una pull request) o una explicación. | Webhook de GitHub: `workflow_run` completed con conclusion `failure`. | +| **Generic webhook** | Ejecuta tus instrucciones con cualquier evento JSON firmado. | Un webhook con la [firma de NaN](/es/docs/webhooks#envía-tus-propios-eventos). | +| **From scratch** | Tus propias instrucciones, triggers y acciones. | Lo que añadas. | + +Las tres plantillas de GitHub actúan sobre un repositorio, así que necesitan un **GitHub repository** y un token de GitHub para las acciones. Consulta [Un token de GitHub para las acciones](#un-token-de-github-para-las-acciones). + +### Rellena los ajustes + +| Campo | Qué es | +|---|---| +| **Name** | El nombre. Hasta 64 caracteres, único entre tus automatizaciones. | +| **Description (optional)** | Una descripción, para ti. | +| **Instructions** | Lo que tiene que hacer el agente, hasta 16 KiB. Tus instrucciones son de confianza. Lo que envíe un webhook o un run manual le llega al agente como datos, nunca como instrucciones. | +| **Workspace** | El workspace donde trabaja el agente. | +| **Agent** | Pi o Hermes. | +| **Folder** | Dónde trabaja el agente, dentro de `/home/nan`. | +| **Separate branch** | Rama aparte: **On**, **Auto** u **Off**, como en un [run](/es/docs/runs#dónde-trabaja-el-agente). | +| **Time limit (minutes)** | El tiempo máximo, de 1 a 120. 30 por defecto. | +| **GitHub repository (optional)** | `owner/name`, por ejemplo `acme/api`. El repositorio sobre el que actúan las acciones. | +| **Secrets** | Cuáles de tus [secretos](#secretos) reciben los runs, y quién recibe cada uno. | +| **Approval** | Qué tipos de acción esperan por ti. Consulta [Acciones y aprobación](#acciones-y-aprobación). | +| **Runs per hour** | Runs por hora, de 1 a 120, contados sobre la última hora. 30 por defecto. Por encima, los nuevos arranques se rechazan hasta que los runs más antiguos salen de esa hora. | +| **When a run is active** | **Skip the new event** (descarta el evento nuevo) o **Queue it** (lo pone en cola). Consulta [Cuando ya hay un run activo](#cuando-ya-hay-un-run-activo). | +| **Chain depth** | Profundidad de cadena, de 0 a 5. 2 por defecto. Cuántas veces un comentario o un push del propio run puede lanzar otro run. Consulta [Bucles](/es/docs/webhooks#bucles). | +| **Enabled** | Si está desactivada, nada la lanza: ni sus triggers, ni **Run now**. | + +Pulsa **Create automation**. + +### Conecta lo que la lanza + +Después de crearla, el portal abre su pestaña **Triggers**. Añade un webhook o una programación: [Webhooks y programaciones](/es/docs/webhooks) te guía paso a paso con cada uno. Una automatización sin triggers solo se ejecuta cuando la lanzas tú. + + + +Cada automatización tiene cuatro pestañas: **Settings**, **Triggers**, **Runs** (sus runs, del más reciente al más antiguo) y **Deliveries** (los webhooks que ha recibido). **Run now** y **Delete** están arriba. + +## Secretos + +Los secretos son los tokens y claves que usan tus automatizaciones: un token de GitHub, una API key de algún servicio tuyo. Los guardas en tus ajustes y los asignas a las automatizaciones que los necesitan. + +### Añade un secreto + +Abre tus ajustes en el portal, **Secrets**, debajo de **Tokens**, y pulsa **Add secret**: + +- **Name**: el nombre de la variable que ven tus automatizaciones, en mayúsculas, dígitos y `_`, empezando por una letra. Por ejemplo `GITHUB_TOKEN`. Hasta 64 caracteres. +- **Value**: se guarda tal cual lo pegas, hasta 16 KiB. Los saltos de línea dentro del valor están bien (una clave PEM, por ejemplo), pero no al principio ni al final. +- **Description**, opcional. + +Los secretos son de **solo escritura**: una vez guardados, nadie puede volver a leer el valor, ni siquiera tú. La lista muestra el nombre, los 4 últimos caracteres cuando el valor es lo bastante largo, la descripción y qué automatizaciones usan cada uno. Puedes: + +- **Replace value**: el valor nuevo se aplica en el siguiente run. El anterior no se puede recuperar. +- Cambiar su **Description**. +- **Delete**. El borrado se rechaza mientras una automatización lo use: quítalo antes de esas automatizaciones. + +Puedes guardar hasta 100 secretos. Mejor tokens de vida corta, limitados a lo que necesita la automatización. + +### Asigna secretos a una automatización + +En la tarjeta **Secrets** de la automatización, **Add a secret** y elige, para cada uno: + +- **El secreto**, por su nombre. +- **El nombre de la variable** con el que lo ve el run. Puede ser distinto del nombre del secreto, y no puede ser un nombre que fije el propio entorno de ejecución: `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `HOME`, `USER`, `PATH`, `LANG`, `CREDENTIALS_DIRECTORY` ni nada que empiece por `NAN_`, `PI_` o `HERMES_`. +- **Quién lo recibe**: + +| Opción | Quién recibe el valor | Para qué usarlo | +|---|---|---| +| **Agent (read access)** | El agente, en su entorno, en cada run. | Tokens que el agente necesita para leer: traer una pull request, leer logs de CI. | +| **Actions only (write access)** | Solo el paso que aplica las acciones, nunca el agente ni ningún modelo de IA. | Tokens de escritura: push, comentar, revisar, etiquetar, abrir pull requests. | + +El mismo nombre de variable puede aparecer una vez por opción. La configuración habitual de GitHub le da al agente un token de solo lectura y a las acciones un token de escritura, los dos como `GITHUB_TOKEN`. + +Una automatización admite hasta 20 secretos, y todos los secretos que recibe un run pueden sumar como mucho 48 KiB. Los secretos solo llegan a los runs de las automatizaciones a las que los asignas: un run que lanzas con `nan run` o desde la pestaña **Runs** no recibe ninguno. + +En los logs, resultados y propuestas de los runs, todos los valores de los secretos aparecen enmascarados. Un run solo arranca si todos los secretos que necesita están disponibles; si no, falla con `secrets_unavailable` y nada se ejecuta sin ellos. + + +Un agente que recibe un secreto puede leerlo, y podría mandarlo a algún sitio si algo le convence de hacerlo. El enmascarado protege nuestros logs, no la red del agente. Dale al agente solo tokens de lectura, deja los tokens de escritura como **Actions only** y limita cada token al repositorio para el que es. + + +### Un token de GitHub para las acciones + +Para actuar sobre un repositorio, una automatización necesita: + +1. Su **GitHub repository** puesto a `owner/name`. +2. Un secreto asignado como `GITHUB_TOKEN` con **Actions only (write access)**. Sin él, la automatización no se puede guardar. + +Crea un [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) limitado a ese único repositorio, con los permisos que necesitan las acciones: **Contents** de lectura y escritura para hacer push, **Pull requests** de lectura y escritura para abrir, comentar y revisar pull requests, e **Issues** de lectura y escritura para comentar en issues y añadir etiquetas. Guárdalo en **Secrets**, por ejemplo como `GITHUB_WRITE_TOKEN`, y asígnalo a la automatización como `GITHUB_TOKEN`, **Actions only**. + +Si el agente también necesita leer el repositorio (uno privado, o para traer logs de CI), crea un segundo token solo con permisos de lectura y asígnalo como `GITHUB_TOKEN`, **Agent**. + +## Acciones y aprobación + +### Cómo actúa una automatización + +El agente de una automatización trabaja como en cualquier run: lee el código, ejecuta comandos y hace commits en su carpeta. Cuando quiere actuar sobre el repositorio, en lugar de actuar escribe una propuesta: + +| Acción | En el portal aparece como | Qué hace NaN | +|---|---|---| +| `git_push` | **Push commits** | Hace push de los cambios del agente a una rama, como un único commit encima de la rama base. | +| `github_create_pr` | **Open pull requests** | Abre una pull request (o un borrador). | +| `github_pr_comment` | **Comment on pull requests** | Publica un comentario en una pull request. | +| `github_pr_review` | **Review pull requests** | Publica una review: comentar, aprobar o pedir cambios. | +| `github_issue_comment` | **Comment on issues** | Publica un comentario en una issue. | +| `github_add_labels` | **Add labels** | Añade etiquetas a una issue o pull request. | + +Hasta 10 acciones por propuesta, siempre sobre el **GitHub repository** de la automatización. Las plantillas ya le dicen al agente cómo escribir la propuesta; con **From scratch**, el agente lo aprende de esas mismas instrucciones, que el run añade por ti. + +Después, un paso aparte, sin agente y sin ningún modelo de IA, aplica las acciones con tu token **Actions only**. El agente nunca ve ese token. Un run que no propone nada simplemente termina como `succeeded`. + +Todo lo que hace NaN va marcado: los commits que sube llevan una línea `Nan-Run: `, los comentarios, reviews y pull requests terminan con una marca oculta, y las ramas creadas por runs empiezan por `nan-run/`. El autor de un commit subido es `NaN run `. + +Un push nunca lleva la clave de inferencia de tu workspace: una propuesta cuyos cambios la contengan se rechaza (`proposal_invalid`), necesite aprobación o no. Tus propios secretos son cosa tuya: si un diff contiene el valor de alguno de ellos, el portal te avisa antes de aprobar. + +### Elige qué espera por ti + +La tarjeta **Approval** de una automatización tiene tres opciones: + +- **Approve everything**: todas las acciones esperan por ti. Las plantillas empiezan con esta. +- **Fully automatic**: las acciones se aplican sin preguntar, push y pull requests incluidos. +- **Custom**: marca qué tipos de acción necesitan aprobación. Las demás se aplican solas. + +Cuando algo de fuera (un webhook o una programación) puede lanzar una automatización que aplica alguna acción sin preguntar, el editor muestra un aviso. Nunca te impide guardar: lo que pasa en tu repositorio es decisión tuya. + +**Proposals expire after**: cuánto tiempo espera una propuesta por ti: **1 hour**, **24 hours**, **72 hours** (por defecto) o **7 days**. + +La aprobación controla lo que NaN hace con tus secretos **Actions only**. El agente en sí se ejecuta en tu workspace y puede usar cualquier cosa que tengas ahí, como una clave SSH o una sesión de `gh`. + +## Aprueba o rechaza una propuesta + +Cuando una propuesta te necesita, el run se para en **awaiting approval** (`awaiting_approval`) y la pestaña **Needs approval** de **Automations** la muestra, con un contador junto a **Automations** en la barra lateral. + + + +### Revísala + +Pulsa **Review** para abrir la propuesta: cada acción, con sus textos completos, la rama y el mensaje de commit de un push, y los archivos que cambia. Cuando tu aprobación cubre un push, **Changes to push** muestra el diff completo. **Open run** te lleva a la página del run y a su log. + +### Apruébala + +**Approve and apply** aplica, en orden, las acciones que esperaban por ti. Aprobar exige una sesión en el navegador. Si la propuesta ha cambiado desde que la abriste, el portal te pide que la revises de nuevo. + +### O recházala + +**Reject**, con un motivo opcional. No se aplica nada de lo que esperaba aprobación. También puedes rechazarla desde la terminal con `nan runs reject ` o desde la API. + + + +Si nadie decide antes de que caduque, la propuesta se descarta y el run termina como `cancelled`. + +Cómo termina un run, según su propuesta: + +| La propuesta... | El run termina como | +|---|---| +| no tiene acciones | `succeeded` | +| solo tiene acciones que se aplican solas | `succeeded`, y las acciones se aplican | +| tiene acciones que necesitan aprobación, y apruebas | `succeeded`, y las acciones se aplican | +| se rechaza | `cancelled` (`approval_rejected`) | +| caduca | `cancelled` (`approval_expired`) | +| no es válida | `failed` (`proposal_invalid`) | + +La aprobación empieza en la primera acción que la necesita. Las acciones anteriores se aplican solas mientras la propuesta espera por ti, y en cuanto apruebas, el resto se aplica en orden, incluidas las acciones posteriores que por sí solas no necesitarían aprobación: una pull request necesita que antes se haya hecho push de su rama. Si una acción falla, las siguientes no se aplican y el run muestra `action_failed`. + +El paso que aplica las acciones aparece como un run propio en la pestaña **Runs** del workspace, sin agente y sin gastar tokens. Su log tiene una línea por acción. + +## Cuando ya hay un run activo + +**When a run is active** decide qué pasa si llega un evento nuevo sobre lo mismo mientras sigue en marcha un run para ello, incluido un run que espera tu aprobación: + +- **Skip the new event**: se descarta. Para una pull request, una issue o un workflow, "lo mismo" es esa pull request, issue o ejecución de workflow. Para una programación, es la automatización. Las plantillas de GitHub usan esta opción, así que hacer push de tres commits seguidos a una pull request no lanza tres reviews. +- **Queue it**: se pone en cola un run nuevo, como siempre. + +Los runs que lanzas tú nunca se descartan. + +## Lánzala ahora + +### Desde el portal + +**Run now**, arriba en la automatización, abre una caja para un **Input** opcional: un objeto JSON (hasta 48 KiB) que el agente recibe como datos junto a tus instrucciones, nunca como instrucciones. **Start run** te lleva a la página del run. + +### Desde la terminal + +Necesitas la [CLI de NaN](/es/docs/nan-cli) **0.1.26** o posterior. + +```bash +nan automations ls # tus automatizaciones +nan automations show "PR review" # ajustes, secretos (solo nombres) y triggers +nan automations run "PR review" --input '{"pr": 42}' +nan automations run nightly-triage --detach +gh api repos/acme/api/pulls/42 | nan automations run "PR review" --input - +nan automations deliveries "PR review" # webhooks recibidos, últimos 30 días +``` + +`--input` acepta el propio JSON, `@file.json` o `-` para stdin. No pongas secretos en él: el agente lo lee. Una automatización se indica por su nombre o por su id. + +Los comandos de aprobaciones y secretos: + +```bash +nan approvals ls # propuestas que esperan por ti +nan runs proposal # las acciones propuestas, y dónde aprobarlas +nan runs proposal --diff # con el diff completo, cuando se guarda +nan runs reject --reason "not now" +nan secrets ls # nombres y metadatos, nunca valores +``` + +`nan run`, `nan runs logs -f` y `nan automations run` salen con código **10** cuando el run se para esperando aprobación, e imprimen los comandos para leer y rechazar la propuesta y el enlace para aprobarla. + +No hay ningún comando para crear o cambiar automatizaciones, escribir secretos o aprobar: la CLI te dice dónde hacerlo en el portal. `nan secrets ls` necesita tu sesión de `nan auth login`; el resto de comandos también aceptan un token de plataforma, igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). + +### Desde la API + +Con un [token de plataforma](#tokens-para-automatizaciones) que tenga el scope `automations:run`, o con tu API key: + +```bash +curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"input": {"pr": 42}}' +``` + +La respuesta es el run, que sigues como cualquier otro ([Crea un run y síguelo](/es/docs/runs#crea-un-run-y-síguelo)). La referencia completa está en [Referencia de la API → Automations](/es/docs/api#tag/automations) y [Referencia de la API → Approvals](/es/docs/api#tag/approvals). + +### Tokens para automatizaciones + +Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crearlo, en **Settings → Tokens**: + +| Scope | Qué permite | +|---|---| +| `runs` | Lanzar, listar, seguir y cancelar runs. Listar aprobaciones, leer propuestas y rechazarlas. | +| `workspaces:read` | Listar tus workspaces y leer uno por su id. | +| `automations:read` | Leer tus automatizaciones, plantillas, triggers y entregas. Nunca los valores de los secretos. | +| `automations:run` | Lanzar una automatización existente, por ejemplo desde CI. No puede crear ni cambiar automatizaciones, ni aprobar. | + +Los tokens nuevos llevan `runs` y `workspaces:read` por defecto. Un token con `automations:read` o `automations:run` solo se puede crear desde una sesión en el navegador. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. + +## Límites + +| Límite | Valor | +|---|---| +| Automatizaciones | 50 por miembro | +| Triggers | 5 por automatización | +| Programaciones | 10 por miembro, entre todas tus automatizaciones | +| Runs por hora | 30 por automatización por defecto, hasta 120 | +| Runs desde webhooks y programaciones | 120 por hora por miembro, entre todas tus automatizaciones | +| Secretos | 100 por miembro, 20 por automatización, 48 KiB por run | +| Instrucciones | 16 KiB | +| Input de un run | Un objeto JSON de hasta 48 KiB, con 8 niveles de anidamiento como mucho | + +Los runs de las automatizaciones comparten los límites de todos los runs: cuántos trabajan a la vez en cada workspace, como mucho 5 a la vez entre todos tus workspaces (8 con Premium), y la cola. Consulta [Cuántos runs a la vez](/es/docs/runs#cuántos-runs-a-la-vez). Las automatizaciones necesitan un plan con inferencia: sin él, el portal te enseña las opciones para mejorarlo y los webhooks se responden sin lanzar ningún run. + +## Cambia o borra una automatización + +Un run conserva los ajustes con los que arrancó. Editar una automatización nunca cambia un run que ya está en cola o trabajando. + +**Delete** para sus triggers al momento y cancela sus runs en cola. Los runs que ya están trabajando terminan, y las acciones que ya aprobaste, o que se aplican solas, se completan igualmente. Los runs pasados se quedan en el historial del workspace. + +## Por qué algunas acciones necesitan el navegador + +Una automatización decide cuáles de tus secretos recibe un agente y qué puede hacer en tu repositorio sin preguntar. Si cualquier cosa capaz de leer una sesión de la CLI o un token pudiera cambiarla, también podría hacerlo un agente en un workspace o un job de CI. Por eso estas acciones te necesitan a ti, con sesión iniciada en el navegador: + +- Crear, cambiar o borrar una automatización o sus triggers. +- Añadir, reemplazar o borrar secretos. +- Aprobar una propuesta. +- Crear un token con `automations:read` o `automations:run`. + +Rechazar siempre es seguro, así que cualquier credencial que pueda leer tus runs puede rechazar. Si el portal te dice que tienes que volver a iniciar sesión, cierra sesión y entra con el enlace por correo desde el mismo navegador. + +## Preguntas frecuentes + +
+ +Sí, si su propuesta lo dice y tus ajustes de aprobación lo permiten. La rama, la rama base y si el push es forzado están en la propuesta, y los ves antes de aprobar. La aprobación es tu red de seguridad: deja **Push commits** bajo aprobación si quieres ver cada push antes. + +
+ +
+ +No, si lo asignas como **Actions only**. Ese valor solo llega al paso que aplica las acciones, que no ejecuta ningún agente ni ningún modelo de IA. + +
+ +
+ +Caduca tras el tiempo que hayas puesto (72 horas por defecto) y el run termina como `cancelled`. No se aplica nada de lo que esperaba aprobación. + +
+ +
+ +Todavía no. Desde la CLI y la API puedes listarlas, consultarlas y lanzarlas, y leer y rechazar propuestas. Crearlas y editarlas se hace en el portal. + +
+ +
+ +Escribe en `#support` en Discord. + +
diff --git a/src/content/docs-es/examples.md b/src/content/docs-es/examples.md index d1cac5d..9d2a1ba 100644 --- a/src/content/docs-es/examples.md +++ b/src/content/docs-es/examples.md @@ -1,7 +1,7 @@ --- title: Ejemplos description: Fragmentos de código para conectarte a la API de NaN con Python, Node.js, curl y más. -order: 20 +order: 22 group: Guías --- diff --git a/src/content/docs-es/intro.md b/src/content/docs-es/intro.md index 4d5f140..31b0dd3 100644 --- a/src/content/docs-es/intro.md +++ b/src/content/docs-es/intro.md @@ -32,5 +32,7 @@ Los límites van por API key — un tope de peticiones por minuto y un máximo d - [Modelos](/es/docs/models): las fichas técnicas y los límites. - [Workspaces](/es/docs/workspaces): tu propia máquina en la nube, por SSH y en el navegador, con agentes de código listos para usar. - [Runs de agentes](/es/docs/runs): dale una tarea a un agente dentro de tu workspace y deja que trabaje solo. +- [Automatizaciones](/es/docs/automations): guarda una tarea de agente y lánzala desde un webhook, una programación o tus scripts, con las aprobaciones que elijas. +- [Webhooks y programaciones](/es/docs/webhooks): conecta GitHub, Stripe o tus propios sistemas, o ejecútala con una programación. - [Ejemplos](/es/docs/examples): fragmentos en Python, Node.js y curl. - Soporte: reporta incidencias en `#support` de Discord. diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index 57d00c4..f23d0e4 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -168,6 +168,21 @@ nan runs logs -f El run se ejecuta en el workspace, no en tu máquina, así que sigue aunque cierres la terminal. Los comandos de runs usan tu sesión de `nan auth login`, o un token de plataforma desde `NAN_TOKEN`, `--token-file` o `nan auth login --api-token` para scripts, CI y servidores. Todos los flags, el orden de autenticación, los códigos de salida y la API están en [Runs de agentes](/es/docs/runs). +## Automatizaciones + +Desde la v0.1.26 la CLI también lista y lanza tus [automatizaciones](/es/docs/automations), y te enseña las propuestas que esperan tu aprobación: + +```bash +nan automations ls +nan automations run "PR review" --input '{"pr": 42}' +nan approvals ls +nan runs proposal +nan runs reject +nan secrets ls +``` + +Crear o editar automatizaciones y triggers, escribir secretos y aprobar propuestas solo se hace en el portal, con sesión iniciada en tu navegador: si lo intentas, la CLI te dice dónde. Un run que espera aprobación hace que `nan run`, `nan runs logs -f` y `nan automations run` salgan con código 10. Los detalles están en [Automatizaciones](/es/docs/automations#desde-la-terminal). + ## Actualizar Vuelve a pasar el instalador. Siempre trae la última versión publicada y sobrescribe la que tengas: diff --git a/src/content/docs-es/runs.mdx b/src/content/docs-es/runs.mdx index 82e71d5..6a1748b 100644 --- a/src/content/docs-es/runs.mdx +++ b/src/content/docs-es/runs.mdx @@ -22,6 +22,8 @@ Puedes lanzar un run de tres formas, y las tres ven los mismos runs: - Desde tu terminal, con `nan run`. - Desde tus propios scripts, con la API. +Para lanzar la misma tarea una y otra vez, desde un webhook, con una programación o con acciones que esperan tu aprobación, guárdala como una [automatización](/es/docs/automations). + ## Antes de empezar - **Un workspace encendido**, con Pi o Hermes instalado. Puedes instalar cualquiera de los dos desde el panel de **Software** del workspace. @@ -100,6 +102,7 @@ La carpeta tiene que estar dentro de `/home/nan`, tu carpeta personal en el work | `failed` | El agente se ha parado con un error, o el run no ha podido empezar (por ejemplo, **Separate branch** en **On** en una carpeta que no es un repositorio). | | `cancelled` | Lo has cancelado tú. | | `timed_out` | Ha llegado a su tiempo máximo y se ha parado. | +| `awaiting_approval` | Solo para runs de [automatizaciones](/es/docs/automations): el agente ha terminado y ha propuesto acciones que esperan tu aprobación. | El portal muestra los mismos estados en inglés y con mayúscula, por ejemplo **Running**, **Succeeded** o **Cancelled**. @@ -129,6 +132,8 @@ Los runs usan la clave de inferencia del workspace, la misma que usan sus agente Los secretos del archivo de entorno de tu workspace, como esa clave de inferencia o un token de bot de Telegram, aparecen como `***` en los logs y resultados de los runs, así que un log no los filtra. +Los secretos que guardas en tus ajustes no se les dan a estos runs: solo llegan a los runs de las [automatizaciones](/es/docs/automations#secretos) a las que los asignas. + ## Desde la terminal: `nan run` La [CLI de NaN](/es/docs/nan-cli) lanza y sigue runs desde tu propia terminal. Necesitas la versión **0.1.25** o posterior: `nan --version` imprime `nan `. @@ -211,6 +216,8 @@ nan runs cancel | `usage` | Los tokens gastados hasta ahora. | | `error` | Algo ha ido mal, con un código. | | `finished` | El run ha terminado: su estado y el resumen del agente. | +| `approval_required` | Un run de una [automatización](/es/docs/automations) ha propuesto acciones que esperan tu aprobación. | +| `action` | Un paso de la aplicación de las acciones de una automatización. | ### Códigos de salida @@ -226,6 +233,7 @@ nan runs cancel | 64 | error de uso, también tener más de un workspace y no pasar `--ws` | | 65 | sin sesión, sesión caducada o sin permiso | | 69 | nan.builders no disponible, o se ha perdido el stream (el run sigue, y se imprime el id) | +| 10 | el run espera tu aprobación (solo runs de [automatizaciones](/es/docs/automations#aprueba-o-rechaza-una-propuesta)) | | 75 | el run se ha aceptado y sigue en marcha (`--detach`, o Ctrl-C una vez) | Con `set -e`, ten en cuenta el 75 de `--detach`: @@ -253,7 +261,7 @@ nan auth login --api-token < token.txt # o pásalo por stdin No pongas nunca un token en la propia línea de comandos: acabaría en el historial de tu shell y en `ps`. -En la CLI, un token de plataforma solo sirve para `nan run` y `nan runs` (consulta [API keys y tokens](#api-keys-y-tokens)). `nan me`, `nan metrics usage` y el panel siguen necesitando una sesión de `nan auth login`. +En la CLI, un token de plataforma solo sirve para `nan run`, `nan runs`, `nan automations` y `nan approvals` (consulta [API keys y tokens](#api-keys-y-tokens)). `nan me`, `nan metrics usage` y el panel siguen necesitando una sesión de `nan auth login`. ### Usa la CLI en CI o en un servidor @@ -292,7 +300,7 @@ Hay dos tipos de credenciales que sirven para la API de runs. Las dos están en |---|---|---| | Formato | `sk-...` | `nan_pat_...` | | Inferencia (modelos) | Sí | **No, nunca** | -| API de la plataforma (runs, tus workspaces) | Sí | Sí | +| API de la plataforma (runs, automatizaciones, aprobaciones, tus workspaces) | Sí | Sí | | Quién puede crearlas | Planes con inferencia | Cualquier miembro con una suscripción activa | Un **token** es lo que conviene darle a un script que solo gestiona runs. No sirve para llamar a los modelos directamente, pero sí para lanzar runs que gastan tu plan: guárdalo igual de bien que una API key y revócalo si se filtra. Créalo en **Settings → Tokens**: @@ -301,6 +309,7 @@ Un **token** es lo que conviene darle a un script que solo gestiona runs. No sir - Puedes tener hasta **10** tokens activos. - Cada uno caduca a los **30, 90 o 365 días**, o nunca. - Puedes revocar cualquiera en cualquier momento. +- Lleva **scopes**, lo que puede hacer: `runs` y `workspaces:read` por defecto. Para listar o lanzar automatizaciones, añade `automations:read` o `automations:run`. Consulta [Tokens para automatizaciones](/es/docs/automations#tokens-para-automatizaciones). 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. diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx new file mode 100644 index 0000000..333699e --- /dev/null +++ b/src/content/docs-es/webhooks.mdx @@ -0,0 +1,323 @@ +--- +title: Webhooks y programaciones +description: Lanza tus automatizaciones desde GitHub, Stripe o tus propios sistemas con webhooks firmados, o con una programación. +order: 21 +group: Guías +--- + +import Steps from '../../components/docs/Steps.astro'; +import Details from '../../components/docs/Details.astro'; +import Callout from '../../components/docs/Callout.astro'; + +# Webhooks y programaciones. + +Un **trigger** lanza una [automatización](/es/docs/automations) sin ti: un **webhook** cuando GitHub, Stripe o uno de tus sistemas envía un evento, o una **programación** a las horas que elijas. Cada arranque crea un run en tu workspace con los ajustes de la automatización, exactamente igual que **Run now**. + +Una automatización puede tener hasta **5 triggers**, de cualquiera de los dos tipos. Los gestionas en su pestaña **Triggers**, en [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule** y, en cada trigger, **Pause** / **Resume**, **Rotate** (webhooks) y borrar. Añadir, cambiar y borrar triggers exige una [sesión en el navegador](/es/docs/automations#por-qué-algunas-acciones-necesitan-el-navegador). + +## Añade un webhook + + + +### Elige el emisor + +En la pestaña **Triggers**, pulsa **+ Webhook** y elige el **Sender** (el emisor; el portal está en inglés): + +| Sender | Quién lo envía | Secreto de firma | +|---|---|---| +| **GitHub** | Los webhooks de un repositorio o de una organización. | Lo crea NaN. Tú lo pegas en GitHub. | +| **Stripe** | Un endpoint de webhook de tu cuenta de Stripe. | Lo crea Stripe (`whsec_...`). Tú lo pegas aquí. | +| **NaN (HMAC)** | Tus propios scripts y sistemas. | Lo crea NaN. Tu código firma con él. | + +Cuando la automatización viene de una plantilla, el emisor y el filtro de eventos ya están rellenos. + +### Elige qué eventos lanzan un run + +**Event filter (JSON, optional)**: qué eventos lanzan un run. Consulta [Filtros de eventos](#filtros-de-eventos). Para GitHub también puedes limitar **Who can start it** (quién puede lanzarla): consulta [Solo algunos usuarios de GitHub](#solo-algunos-usuarios-de-github). + +### Copia la URL y el secreto + +Pulsa **Add webhook**. El portal te muestra, **una sola vez**: + +- **Webhook URL**: `https://api.nan.builders/hooks/nan_whk_...`. La propia URL es una credencial: cualquiera que la tenga puede enviarle eventos, así que trátala como una contraseña. +- **Signing secret** (GitHub y NaN): con lo que el emisor firma cada evento. + +Copia los dos y pulsa **I've saved them**. Después, el portal solo muestra el principio de la URL. Si los pierdes, [rótalos](#rota-la-url-o-el-secreto) para conseguir unos nuevos. + +### Configúralo en el emisor + +Sigue la sección de tu emisor: [GitHub](#desde-github), [Stripe](#desde-stripe) o [tu propio código](#envía-tus-propios-eventos). + + + +## Desde GitHub + +En el repositorio, abre **Settings → Webhooks → Add webhook** (o lo mismo en los ajustes de tu organización): + +| Campo de GitHub | Valor | +|---|---| +| **Payload URL** | La URL del webhook. | +| **Content type** | `application/json`. Un webhook form-encoded se rechaza con `415`. | +| **Secret** | El secreto de firma. | +| **Which events** | **Let me select individual events**, y marca los que maneja tu automatización: **Pull requests** para una review, **Issues** para el triaje, **Workflow runs** para los fallos de CI. | + +GitHub firma cada entrega con `X-Hub-Signature-256` y NaN la comprueba. Al guardar, GitHub envía un `ping`: aparece como **Ignored by a filter** en **Deliveries** salvo que tu filtro acepte `ping`. Eso te dice que la URL y el secreto funcionan. + +GitHub no firma ninguna marca de tiempo, así que NaN recuerda el id de cada entrega (`X-GitHub-Delivery`) y lanza como mucho un run por entrega. **Redeliver** en GitHub envía el mismo id: si ya lanzó un run, se responde como duplicado. + +## Desde Stripe + + + +### Crea el endpoint en Stripe + +En el Dashboard de Stripe, **Developers → Webhooks → Add endpoint**. Elige los eventos que quieras y, de momento, cualquier URL provisional tuya: la cambias en el último paso. Stripe te muestra el **Signing secret** del endpoint (`whsec_...`). + +### Pega el secreto en NaN + +Añade el webhook en NaN con **Stripe** como emisor y pega ese `whsec_...` en **Stripe signing secret**. Se guarda cifrado y no se vuelve a mostrar. + +### Apunta Stripe a la URL + +Pon como URL del endpoint en Stripe la URL del webhook que te da NaN. + + + +NaN comprueba `Stripe-Signature`, acepta cualquiera de las firmas que envía Stripe (envía varias mientras rotas su secreto) y rechaza los eventos con más de 5 minutos de desfase. Funcionan tanto el modo test como el live: el endpoint que creas decide qué eventos recibes. Cada id de evento (`evt_...`) lanza como mucho un run. + +## Envía tus propios eventos + +El emisor **NaN (HMAC)** es para tus propios sistemas: un script de despliegue, una alerta de monitorización, otro servicio. Envía un `POST` con un objeto JSON como cuerpo y estas cabeceras: + +| Cabecera | Obligatoria | Valor | +|---|---|---| +| `X-Nan-Timestamp` | Sí | La hora actual, en segundos Unix. Los eventos con más de 5 minutos de desfase se rechazan. | +| `X-Nan-Signature` | Sí | `v1=` seguido del HMAC-SHA256 en hexadecimal de `.` con el secreto de firma. Puedes enviar varias, separadas por comas, mientras rotas el secreto. | +| `X-Nan-Delivery` | No | Un id único por evento, de hasta 128 caracteres entre `A-Z a-z 0-9 . _ : -`. El mismo id nunca lanza dos runs. Sin él, el id es el hash de la marca de tiempo y el cuerpo. | +| `X-Nan-Event` | No | El nombre del evento, para tu [filtro de eventos](#filtros-de-eventos). Por ejemplo `deploy.finished`. | +| `X-Nan-Run-Id` | No | Si un run ha causado este evento, su id (está en `NAN_RUN_ID` dentro del run). Alimenta la [protección contra bucles](#bucles). | + +Firma el cuerpo exactamente como lo envías, byte a byte. En bash: + +```bash +URL="https://api.nan.builders/hooks/nan_whk_..." +SECRET="..." # el secreto de firma +BODY='{"service":"api","version":"1.4.2"}' +TS=$(date +%s) +SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') + +curl -s "$URL" \ + -H "Content-Type: application/json" \ + -H "X-Nan-Timestamp: $TS" \ + -H "X-Nan-Signature: v1=$SIG" \ + -H "X-Nan-Event: deploy.finished" \ + -d "$BODY" +``` + +En Python: + +```python +import hashlib, hmac, json, time, urllib.request + +url = "https://api.nan.builders/hooks/nan_whk_..." +secret = b"..." +body = json.dumps({"service": "api", "version": "1.4.2"}).encode() +ts = str(int(time.time())) +sig = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest() + +req = urllib.request.Request(url, data=body, method="POST", headers={ + "Content-Type": "application/json", + "X-Nan-Timestamp": ts, + "X-Nan-Signature": f"v1={sig}", + "X-Nan-Event": "deploy.finished", +}) +print(urllib.request.urlopen(req).read().decode()) +``` + +En Node.js: + +```js +import { createHmac } from 'node:crypto'; + +const url = 'https://api.nan.builders/hooks/nan_whk_...'; +const secret = '...'; +const body = JSON.stringify({ service: 'api', version: '1.4.2' }); +const ts = Math.floor(Date.now() / 1000).toString(); +const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'); + +const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Nan-Timestamp': ts, + 'X-Nan-Signature': `v1=${sig}`, + 'X-Nan-Event': 'deploy.finished', + }, + body, +}); +console.log(res.status, await res.text()); +``` + + +Guarda la URL y el secreto de firma en el almacén de secretos de tus sistemas, nunca en un repositorio ni en código que se ejecute en el cliente. Con los dos, cualquiera puede lanzar tu automatización. + + +## Filtros de eventos + +El **Event filter** es un objeto JSON. Cada lista que rellenes tiene que coincidir; una lista vacía, o no poner filtro, lo deja pasar todo: + +| Campo | Coincide con | Ejemplo | +|---|---|---| +| `events` | El nombre del evento: `X-GitHub-Event` para GitHub, el `type` del evento para Stripe, `X-Nan-Event` para NaN. Una entrada que termina en `.*` coincide con un prefijo. | `["pull_request"]`, `["invoice.*"]` | +| `actions` | El campo `action` de GitHub. | `["opened", "synchronize"]` | +| `exclude_drafts` | `true` ignora las pull requests en borrador. | `true` | +| `conclusions` | La `conclusion` de un `workflow_run` de GitHub. | `["failure"]` | + +La plantilla **Pull request review**, por ejemplo, usa: + +```json +{ + "events": ["pull_request"], + "actions": ["opened", "synchronize", "reopened", "ready_for_review"], + "exclude_drafts": true +} +``` + +Hasta 32 entradas por lista, cada una con caracteres de `A-Z a-z 0-9 _ . : -`. Un evento que no coincide se responde con `200` y aparece como **Ignored by a filter**. + +## Solo algunos usuarios de GitHub + +Por defecto, cualquier evento con una firma válida lanza un run. En un repositorio público, eso significa cualquiera que pueda abrir una pull request o una issue. Para limitarlo, marca **Only some GitHub users** en **Who can start it**: + +- **GitHub logins**: solo los eventos enviados por estos usuarios. Vacío significa cualquiera. +- **Relationship to the repository**: solo los autores con esa relación con el repositorio (owner, member, collaborator, contributor, first-time contributor...). Ninguna marcada significa cualquiera. +- **Ignore bots**: descarta los eventos enviados por cuentas bot. + +Puedes cambiarlo después con **Change** en el trigger. + +## Qué recibe el agente + +El evento le llega al agente como datos, después de tus instrucciones y claramente marcado como algo que viene de fuera. Nunca se toma como instrucciones. + +- Los eventos de **GitHub** de pull requests, issues y ejecuciones de workflow llegan recortados a los campos que importan: el repositorio, el número, el título, el cuerpo (hasta 8 KiB), el autor y su relación con el repositorio, las ramas y el commit, el enlace, y los archivos cambiados o el workflow que ha fallado. +- **Cualquier otro evento** (otros eventos de GitHub, Stripe, NaN) llega como su cuerpo JSON, si ocupa como mucho 48 KiB y tiene 8 niveles de anidamiento como mucho. Si no, aparece como **Too large** y no arranca ningún run. + +Las cadenas largas se cortan con una marca. Los cuerpos de los webhooks tienen un tope de 1 MiB. + +## Qué recibe el emisor de vuelta + +| Estado | Cuándo | Cuerpo | +|---|---|---| +| `202` | Se ha creado un run. | `{"status":"run_created","run_id":"..."}` | +| `200` | Aceptado sin run: filtrado, una entrega repetida, un bucle, un run ya activo, una membresía sin runs, o demasiado grande. | `{"status":"ignored"}`, `"duplicate"`, `"loop_suppressed"`, `"skipped_active"`, `"not_accepted"` o `"too_large"` | +| `401` | La firma falta o es incorrecta, o la marca de tiempo tiene demasiado desfase. | Vacío | +| `404` | URL desconocida, o un trigger en pausa o una automatización desactivada. | Vacío | +| `413` | El cuerpo pasa de 1 MiB. | Vacío | +| `415` | Un webhook de GitHub que no es `application/json`. | Vacío | +| `429` | Demasiadas entregas de momento, la automatización ha llegado a sus runs por hora, o tu cola está llena. Reintenta más tarde (`Retry-After`). | Vacío | +| `503` | No disponible temporalmente. Reintenta más tarde. | Vacío | + +Un `200` es definitivo: el emisor no debería reintentarlo. Un `429` o un `503` no se registra, así que un reintento o un reenvío se procesa como nuevo. + +## Entregas + +La pestaña **Deliveries** lista los webhooks que ha recibido la automatización en los últimos **30 días**, del más reciente al más antiguo, con cuándo llegó, su id de entrega, qué pasó y el run que lanzó: + +| Resultado | Significado | +|---|---| +| **Run started** | Se ha creado un run. | +| **Ignored by a filter** | El filtro de eventos o de autores no ha coincidido. | +| **Stopped: caused by one of its own runs** | Se ha parado un [bucle](#bucles). | +| **Skipped: a run for the same subject is active** | Consulta [Cuando ya hay un run activo](/es/docs/automations#cuando-ya-hay-un-run-activo). | +| **Not accepted: your membership does not include runs** | Tu plan no tiene inferencia. | +| **Too large** | El evento no cabía en lo que puede recibir el agente. | + +No se guarda ningún payload ni ninguna cabecera. Las entregas con una firma incorrecta nunca se guardan, y las limitadas (límite de peticiones, cola llena, no disponible temporalmente) se responden pidiendo un reintento y no se listan. + +Desde la terminal: `nan automations deliveries `. Desde la API: [`GET /v1/automations/{id}/deliveries`](/es/docs/api#tag/automations). + +## Rota la URL o el secreto + +**Rotate**, en un webhook, te da credenciales nuevas. Marca qué reemplazar: + +- **New webhook URL**: la URL actual deja de funcionar al momento. +- **New signing secret**: por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. + +Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. + +## Bucles + +Una automatización que comenta en una pull request puede recibir un webhook por su propio comentario, y una automatización que hace push puede disparar el CI que a su vez la vuelve a lanzar. NaN para esos bucles: + +- Todo lo que hace un run lleva su marca: una línea `Nan-Run:` en los commits que sube, una marca oculta en comentarios, reviews y pull requests, y ramas con el nombre `nan-run/...`. Tus propios scripts pueden reenviar `NAN_RUN_ID` como `X-Nan-Run-Id`. +- Un evento causado por un run cuenta un paso más que ese run. Por encima de la **Chain depth** de la automatización (2 por defecto, de 0 a 5), se para y aparece como **Stopped: caused by one of its own runs**. + +Los runs por hora de cada automatización, los 120 runs por hora desde webhooks y programaciones entre todas tus automatizaciones, y **Skip the new event** paran un bucle que se cuele entre las marcas. + +## Programaciones + +### Añade una programación + +En la pestaña **Triggers**, pulsa **+ Schedule**: + +- **Schedule (cron)**: cinco campos, minuto, hora, día del mes, mes y día de la semana. Por ejemplo `0 9 * * 1-5` (días laborables a las 9:00) o `30 */2 * * *` (cada dos horas, a y media). También funcionan los atajos `@hourly`, `@daily`, `@weekly`, `@monthly` y `@yearly`. +- **Time zone**: la zona horaria; las horas la siguen. Empieza con la de tu navegador. + +El portal te enseña las próximas ejecuciones antes de guardar. Pulsa **Add schedule**. + +### Reglas + +- **Al menos 15 minutos entre runs.** Una programación cuyas próximas ejecuciones estén más juntas se rechaza. +- **Hasta 10 programaciones** entre todas tus automatizaciones. +- **Cambios de hora**: cuando se adelantan los relojes, la hora que se salta se ejecuta en la siguiente hora válida; cuando se atrasan, la hora repetida se ejecuta una vez. +- **Unos segundos después del minuto**: cada programación se dispara hasta un minuto después de su hora, siempre con el mismo desfase, para que las programaciones en punto no arranquen todas a la vez. +- **No se recupera lo perdido**: si un run no pudo arrancar a su hora (durante un mantenimiento, por ejemplo), aún arranca si llega como mucho 15 minutos tarde. Más tarde que eso, esa hora se salta y nunca se recupera. + +### Última ejecución + +Cada programación muestra su próxima ejecución y cómo fue la última: + +| Aparece como | Significado | +|---|---| +| **Run started** | Se ha creado un run. | +| **Skipped: the previous run was still active** | **Skip the new event** está activado y el run anterior no había terminado. | +| **Skipped: hourly limit reached** | La automatización ha llegado a sus runs por hora, o has llegado a los 120 por hora desde webhooks y programaciones. | +| **Skipped: too many runs waiting** | Tu cola de runs está llena. | +| **Skipped: automation disabled** | La automatización no está activada. | +| **Skipped: workspace deleted** | Su workspace ya no existe. | +| **Not accepted: your membership does not include runs** | Tu plan no tiene inferencia. | +| **Missed (more than 15 minutes late)** | No pudo arrancar a tiempo y se ha saltado. | + +Un run programado recibe, como datos, la hora para la que estaba programado, la expresión cron y la zona horaria: `scheduled_for`, `cron` y `timezone`. + +## Preguntas frecuentes + +
+ +Abre la entrega en GitHub y mira la respuesta. `401` significa que el secreto que hay en GitHub no es el secreto de firma de este webhook: rótalo y pega el nuevo. `415` significa que el content type no es `application/json`. `404` significa que la URL está mal, que el trigger está en pausa o que la automatización está desactivada. + +
+ +
+ +El cuerpo te dice por qué: `ignored` (el filtro no coincidió, a menudo el `ping` o una acción que no incluiste), `skipped_active`, `loop_suppressed` o `duplicate`. La pestaña **Deliveries** muestra lo mismo. + +
+ +
+ +No. Una URL de webhook pertenece a una automatización de un miembro, y solo lanza runs de esa automatización, en su workspace. + +
+ +
+ +Sí: con un token de plataforma que tenga el scope `automations:run`, llama a `nan automations run` o a la API. Consulta [Lánzala ahora](/es/docs/automations#lánzala-ahora). + +
+ +
+ +Escribe en `#support` en Discord. + +
diff --git a/src/content/docs/apps.md b/src/content/docs/apps.md index f5f4d36..8a58fc7 100644 --- a/src/content/docs/apps.md +++ b/src/content/docs/apps.md @@ -1,7 +1,7 @@ --- title: Apps description: Deploy your apps from GitHub to NaN Cloud in minutes. -order: 21 +order: 23 group: Guides --- diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx new file mode 100644 index 0000000..c1e9491 --- /dev/null +++ b/src/content/docs/automations.mdx @@ -0,0 +1,342 @@ +--- +title: Automations +description: Save an agent task once and let webhooks, schedules or your own scripts run it, with the actions you choose waiting for your approval. +order: 20 +group: Guides +--- + +import Steps from '../../components/docs/Steps.astro'; +import Details from '../../components/docs/Details.astro'; +import Callout from '../../components/docs/Callout.astro'; + +# Automations. + +An **automation** is an [agent run](/docs/runs) you set up once and start as often as you need: the workspace, the agent, the instructions, the folder, the secrets it receives and what may start it. Every time it starts, it creates a run in your workspace with those settings. + +What can start an automation: + +- **You**, with **Run now** in the portal, `nan automations run` in the terminal or a call to the API. +- **A webhook**: GitHub, Stripe or any system that can sign a request. See [Webhooks and schedules](/docs/webhooks). +- **A schedule**: every weekday at 9:00, every hour, the first of the month. Also in [Webhooks and schedules](/docs/webhooks). + +The agent of an automation does not push or post on its own. It **proposes actions** (push a branch, open a pull request, comment, review, add labels) and NaN applies them with a token you keep apart from the agent. You decide which kinds of action wait for your approval and which apply automatically: all of them, none of them or any mix. + +## Before you start + +- **A workspace that is running**, with Pi or Hermes installed, as for any [run](/docs/runs#before-you-start). +- **A plan with inference.** Automation runs use your workspace's inference key and count against your plan's usage like any other run. +- **A browser sign-in.** Creating or changing automations, writing secrets and approving actions need you signed in at [cloud.nan.builders](https://cloud.nan.builders/) in your browser, with the email link. A CLI session, an API key or a token can list automations and run them, but never change them. See [Why some actions need the browser](#why-some-actions-need-the-browser). + +## Create an automation + + + +### Open Automations + +Go to [cloud.nan.builders/automations](https://cloud.nan.builders/automations), or **Automations** in the sidebar, under **Workspaces**. The page has two tabs: **Automations**, your list, and **Needs approval**, the proposals waiting for you. + +### Pick a template or start from scratch + +Press **New automation**. A template fills in sensible defaults that you can change afterwards; **From scratch** leaves everything to you. + +| Template | What it does | Starts on | +|---|---|---| +| **Pull request review** | Reviews every new or updated pull request and proposes a review. | GitHub webhook: `pull_request` opened, synchronize, reopened or ready for review. Drafts are ignored. | +| **Issue triage** | Analyses new issues, comments and proposes labels. | GitHub webhook: `issues` opened or reopened. | +| **CI failure** | Investigates failed workflow runs and proposes a fix (a push and a pull request) or an explanation. | GitHub webhook: `workflow_run` completed with conclusion `failure`. | +| **Generic webhook** | Runs your instructions on any signed JSON event. | A webhook with the [NaN signature](/docs/webhooks#send-your-own-events). | +| **From scratch** | Your own instructions, triggers and actions. | Whatever you add. | + +The three GitHub templates act on a repository, so they need a **GitHub repository** and a GitHub token for actions. See [A GitHub token for actions](#a-github-token-for-actions). + +### Fill in the settings + +| Field | What it is | +|---|---| +| **Name** | Up to 64 characters, unique among your automations. | +| **Description (optional)** | For you. | +| **Instructions** | What the agent must do, up to 16 KiB. Your instructions are trusted. Whatever a webhook or a manual run sends is given to the agent as data, never as instructions. | +| **Workspace** | The workspace the agent runs in. | +| **Agent** | Pi or Hermes. | +| **Folder** | Where the agent works, inside `/home/nan`. | +| **Separate branch** | **On**, **Auto** or **Off**, as in a [run](/docs/runs#where-the-agent-works). | +| **Time limit (minutes)** | From 1 to 120. 30 by default. | +| **GitHub repository (optional)** | `owner/name`, for example `acme/api`. The repository the actions act on. | +| **Secrets** | Which of your [secrets](#secrets) the runs receive, and who receives each one. | +| **Approval** | Which kinds of action wait for you. See [Actions and approval](#actions-and-approval). | +| **Runs per hour** | From 1 to 120, counted over the last hour. 30 by default. Beyond it, new starts are refused until older runs leave that hour. | +| **When a run is active** | **Skip the new event** or **Queue it**. See [When a run is already active](#when-a-run-is-already-active). | +| **Chain depth** | From 0 to 5. 2 by default. How many times a run's own comment or push may start another run. See [Loops](/docs/webhooks#loops). | +| **Enabled** | Off, nothing starts it: not its triggers, not **Run now**. | + +Press **Create automation**. + +### Connect what starts it + +After creating it, the portal opens its **Triggers** tab. Add a webhook or a schedule: [Webhooks and schedules](/docs/webhooks) walks you through each one. An automation without triggers only runs when you start it yourself. + + + +Each automation has four tabs: **Settings**, **Triggers**, **Runs** (its runs, newest first) and **Deliveries** (the webhooks it received). **Run now** and **Delete** are at the top. + +## Secrets + +Secrets are the tokens and keys your automations use: a GitHub token, an API key for a service of yours. You keep them in your settings and assign them to the automations that need them. + +### Add a secret + +Open your settings on the portal, **Secrets**, below **Tokens**, and press **Add secret**: + +- **Name**: the variable name your automations see, in capitals, digits and `_`, starting with a letter. For example `GITHUB_TOKEN`. Up to 64 characters. +- **Value**: saved exactly as you paste it, up to 16 KiB. Line breaks inside the value are fine (a PEM key, for example), but not at its start or end. +- **Description**, optional. + +Secrets are **write-only**: once saved, nobody can read a value back, not even you. The list shows the name, the last 4 characters when the value is long enough, the description and which automations use each one. You can: + +- **Replace value**: the new value takes effect on the next run. The old one cannot be recovered. +- Change its **Description**. +- **Delete** it. Deleting is refused while an automation uses it: remove it from those automations first. + +You can keep up to 100 secrets. Prefer short-lived tokens limited to what the automation needs. + +### Assign secrets to an automation + +In the **Secrets** card of the automation, **Add a secret** and pick, for each one: + +- **The secret**, by name. +- **The variable name** the run sees it as. It can differ from the secret's name, and it cannot be a name the runtime sets itself: `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `HOME`, `USER`, `PATH`, `LANG`, `CREDENTIALS_DIRECTORY` or anything starting with `NAN_`, `PI_` or `HERMES_`. +- **Who receives it**: + +| Option | Who gets the value | Use it for | +|---|---|---| +| **Agent (read access)** | The agent, in its environment, in every run. | Tokens the agent needs to read things: fetch a pull request, read CI logs. | +| **Actions only (write access)** | Only the step that applies the actions, never the agent or any AI model. | Write tokens: push, comment, review, label, open pull requests. | + +The same variable name can appear once per option. The usual GitHub setup gives the agent a read-only token and the actions a write token, both as `GITHUB_TOKEN`. + +An automation takes up to 20 secrets, and all the secrets one run receives must add up to 48 KiB at most. Secrets go only to the runs of the automations you assign them to: a run you start with `nan run` or from the **Runs** tab never receives any. + +In run logs, results and proposals, every secret value is masked. A run only starts if every secret it needs is available; otherwise it fails with `secrets_unavailable` and nothing runs without them. + + +An agent that receives a secret can read it, and could send it somewhere if something convinces it to. Masking protects our logs, not the agent's network. Give the agent only read tokens, keep write tokens as **Actions only**, and limit every token to the repository it is for. + + +### A GitHub token for actions + +To act on a repository, an automation needs: + +1. Its **GitHub repository** set to `owner/name`. +2. A secret assigned as `GITHUB_TOKEN` with **Actions only (write access)**. Without it the automation cannot be saved. + +Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) limited to that one repository, with the permissions the actions need: **Contents** read and write to push, **Pull requests** read and write to open, comment on and review pull requests, and **Issues** read and write to comment on issues and add labels. Save it in **Secrets**, for example as `GITHUB_WRITE_TOKEN`, and assign it to the automation as `GITHUB_TOKEN`, **Actions only**. + +If the agent also needs to read the repository (a private one, or to fetch CI logs), create a second token with read permissions only and assign it as `GITHUB_TOKEN`, **Agent**. + +## Actions and approval + +### How an automation acts + +The agent of an automation works like any run: it reads the code, runs commands and commits in its folder. When it wants to act on the repository, it writes a proposal instead of acting: + +| Action | Shown in the portal as | What NaN does | +|---|---|---| +| `git_push` | **Push commits** | Pushes the agent's changes to a branch, as one commit on top of the base branch. | +| `github_create_pr` | **Open pull requests** | Opens a pull request (or a draft). | +| `github_pr_comment` | **Comment on pull requests** | Posts a comment on a pull request. | +| `github_pr_review` | **Review pull requests** | Posts a review: comment, approve or request changes. | +| `github_issue_comment` | **Comment on issues** | Posts a comment on an issue. | +| `github_add_labels` | **Add labels** | Adds labels to an issue or pull request. | + +Up to 10 actions per proposal, always on the automation's **GitHub repository**. The templates already tell the agent how to write the proposal; with **From scratch**, the agent learns it from the same instructions, which the run adds for you. + +Then a separate step, with no agent and no AI model in it, applies the actions with your **Actions only** token. The agent never sees that token. A run that proposes nothing just ends as `succeeded`. + +Everything NaN does is marked: pushed commits carry a `Nan-Run: ` line, comments, reviews and pull requests end with a hidden marker, and branches made by runs start with `nan-run/`. The author of a pushed commit is `NaN run `. + +A push never carries the inference key of your workspace: a proposal whose changes contain it is refused (`proposal_invalid`), whether it needs approval or not. Your own secrets are your call: if a diff contains the value of one of them, the portal warns you before you approve. + +### Choose what waits for you + +The **Approval** card of an automation has three presets: + +- **Approve everything**: every action waits for you. The templates start with this one. +- **Fully automatic**: actions apply without asking, pushes and pull requests included. +- **Custom**: tick which kinds of action need approval. The others apply automatically. + +When something from outside (a webhook or a schedule) can start an automation that applies any action without asking, the editor shows a notice. It never stops you from saving: what happens in your repository is your decision. + +**Proposals expire after**: how long a proposal waits for you: **1 hour**, **24 hours**, **72 hours** (the default) or **7 days**. + +Approval controls what NaN does with your **Actions only** secrets. The agent itself runs in your workspace and can use anything you keep there, such as an SSH key or a `gh` login. + +## Approve or reject a proposal + +When a proposal needs you, the run stops in **awaiting approval** (`awaiting_approval`) and the **Needs approval** tab of **Automations** shows it, with a counter next to **Automations** in the sidebar. + + + +### Review it + +Press **Review** to open the proposal: each action, with its texts in full, the branch and the commit message of a push, and the files it changes. When your approval covers a push, **Changes to push** shows the full diff. **Open run** takes you to the run's page and its log. + +### Approve it + +**Approve and apply** applies the actions that waited for you, in order. Approving needs a browser sign-in. If the proposal changed since you opened it, the portal asks you to review it again. + +### Or reject it + +**Reject**, with an optional reason. Nothing that waited for approval is applied. You can also reject from the terminal with `nan runs reject ` or from the API. + + + +If nobody decides before it expires, the proposal is dropped and the run ends as `cancelled`. + +How a run ends, depending on its proposal: + +| The proposal... | The run ends as | +|---|---| +| has no actions | `succeeded` | +| has only actions that apply automatically | `succeeded`, and the actions are applied | +| has actions that need approval, and you approve | `succeeded`, and the actions are applied | +| is rejected | `cancelled` (`approval_rejected`) | +| expires | `cancelled` (`approval_expired`) | +| is not valid | `failed` (`proposal_invalid`) | + +Approval starts at the first action that needs it. The actions before it apply automatically while the proposal waits for you, and once you approve, the rest apply in order, including later actions that would not need approval on their own: a pull request needs its branch pushed first. If an action fails, the ones after it are not applied and the run shows `action_failed`. + +The step that applies the actions appears as its own run in the workspace's **Runs** tab, with no agent and no tokens used. Its log has one line per action. + +## When a run is already active + +**When a run is active** decides what happens if a new event arrives for the same thing while a run for it is still going, including a run waiting for your approval: + +- **Skip the new event**: it is skipped. For a pull request, an issue or a workflow, "the same thing" is that pull request, issue or workflow run. For a schedule, it is the automation. The GitHub templates use this, so pushing three commits to a pull request in a row does not start three reviews. +- **Queue it**: a new run is queued as usual. + +Runs you start yourself are never skipped. + +## Run it now + +### From the portal + +**Run now**, at the top of the automation, opens a box for an optional **Input**: a JSON object (up to 48 KiB) that the agent receives as data next to your instructions, never as instructions. **Start run** takes you to the run's page. + +### From the terminal + +You need the [NaN CLI](/docs/nan-cli) **0.1.26** or newer. + +```bash +nan automations ls # your automations +nan automations show "PR review" # settings, secrets (names only) and triggers +nan automations run "PR review" --input '{"pr": 42}' +nan automations run nightly-triage --detach +gh api repos/acme/api/pulls/42 | nan automations run "PR review" --input - +nan automations deliveries "PR review" # webhooks received, last 30 days +``` + +`--input` takes the JSON itself, `@file.json` or `-` for stdin. Do not put secrets in it: the agent reads it. An automation is named by its name or its id. + +The approvals and secrets commands: + +```bash +nan approvals ls # proposals waiting for you +nan runs proposal # the proposed actions, and where to approve them +nan runs proposal --diff # with the full diff, when one is kept +nan runs reject --reason "not now" +nan secrets ls # names and metadata, never values +``` + +`nan run`, `nan runs logs -f` and `nan automations run` exit with code **10** when the run stops waiting for approval, and print the commands to read and reject the proposal and the link to approve it. + +There is no command to create or change automations, write secrets or approve: the CLI tells you where to do it in the portal. `nan secrets ls` needs your `nan auth login` session; the other commands also take a platform token, as `nan run` does ([Authentication](/docs/runs#authentication)). + +### From the API + +With a [platform token](#tokens-for-automations) that has the `automations:run` scope, or your API key: + +```bash +curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"input": {"pr": 42}}' +``` + +The answer is the run, which you follow like any other ([Create a run and follow it](/docs/runs#create-a-run-and-follow-it)). The full reference is in [API reference → Automations](/docs/api#tag/automations) and [API reference → Approvals](/docs/api#tag/approvals). + +### Tokens for automations + +A platform token carries **scopes**: what it may do. You pick them when you create it, under **Settings → Tokens**: + +| Scope | What it allows | +|---|---| +| `runs` | Start, list, follow and cancel runs. List approvals, read proposals and reject them. | +| `workspaces:read` | List your workspaces and read one by id. | +| `automations:read` | Read your automations, templates, triggers and deliveries. Never secret values. | +| `automations:run` | Run an existing automation, for example from CI. It cannot create or change automations, or approve. | + +New tokens get `runs` and `workspaces:read` by default. A token with `automations:read` or `automations:run` can only be created from a browser sign-in. A token without the scope an endpoint needs gets `403 token_scope_denied`. + +## Limits + +| Limit | Value | +|---|---| +| Automations | 50 per member | +| Triggers | 5 per automation | +| Schedules | 10 per member, across all your automations | +| Runs per hour | 30 per automation by default, up to 120 | +| Runs from webhooks and schedules | 120 per hour per member, across all your automations | +| Secrets | 100 per member, 20 per automation, 48 KiB per run | +| Instructions | 16 KiB | +| Input of a run | A JSON object of up to 48 KiB, nested at most 8 levels | + +Automation runs share the limits of every run: how many work at once in each workspace, at most 5 at once across all your workspaces (8 on Premium), and the queue. See [How many runs at once](/docs/runs#how-many-runs-at-once). Automations need a plan with inference: without one, the portal shows you the upgrade options and webhooks are answered without starting a run. + +## Change or delete an automation + +A run keeps the settings it started with. Editing an automation never changes a run that is already queued or working. + +**Delete** stops its triggers at once and cancels its queued runs. Runs already working finish, and actions you already approved, or that apply automatically, still complete. Past runs stay in the workspace's history. + +## Why some actions need the browser + +An automation decides which of your secrets an agent receives and what it may do in your repository without asking. If anything that can read a CLI session or a token could change it, an agent in a workspace or a CI job could too. So these need you, signed in through your browser: + +- Creating, changing or deleting an automation or its triggers. +- Adding, replacing or deleting secrets. +- Approving a proposal. +- Creating a token with `automations:read` or `automations:run`. + +Rejecting is always safe, so any credential that can read your runs can reject. If the portal says you need to sign in again, sign out and sign in with the email link from the same browser. + +## FAQ + +
+ +Yes, if its proposal says so and your approval settings allow it. The branch, the base branch and whether the push forces are in the proposal, and you see them before approving. Approval is your safety net: keep **Push commits** under approval if you want to see every push first. + +
+ +
+ +No, if you assign it as **Actions only**. That value goes only to the step that applies the actions, which runs no agent and no AI model. + +
+ +
+ +It expires after the time you set (72 hours by default) and the run ends as `cancelled`. Nothing that waited for approval is applied. + +
+ +
+ +Not yet. From the CLI and the API you can list, inspect and run them, and read and reject proposals. Creating and editing happen in the portal. + +
+ +
+ +Write in `#support` on Discord. + +
diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index 1a59d7b..4aa536e 100644 --- a/src/content/docs/examples.md +++ b/src/content/docs/examples.md @@ -1,7 +1,7 @@ --- title: Examples description: Code snippets to connect to the NaN API with Python, Node.js, curl, and more. -order: 20 +order: 22 group: Guides --- diff --git a/src/content/docs/intro.md b/src/content/docs/intro.md index 14f24db..e6c2176 100644 --- a/src/content/docs/intro.md +++ b/src/content/docs/intro.md @@ -32,5 +32,7 @@ Limits are per API key — a cap on requests per minute and a maximum number of - [Models](/docs/models): the spec sheets and the limits. - [Workspaces](/docs/workspaces): your own cloud machine, over SSH and in the browser, with coding agents ready to use. - [Agent runs](/docs/runs): give an agent a task inside your workspace and let it work on its own. +- [Automations](/docs/automations): save an agent task and start it from a webhook, a schedule or your scripts, with the approvals you choose. +- [Webhooks and schedules](/docs/webhooks): connect GitHub, Stripe or your own systems, or run on a schedule. - [Examples](/docs/examples): snippets in Python, Node.js and curl. - Support: report issues in `#support` on Discord. diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 9fb1916..e53b54f 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -168,6 +168,21 @@ nan runs logs -f The run happens in the workspace, not on your machine, so it keeps going if you close the terminal. The runs commands use your `nan auth login` session, or a platform token from `NAN_TOKEN`, `--token-file` or `nan auth login --api-token` for scripts, CI and servers. Every flag, the authentication order, the exit codes and the API are in [Agent runs](/docs/runs). +## Automations + +From v0.1.26 the CLI also lists and runs your [automations](/docs/automations), and shows the proposals waiting for your approval: + +```bash +nan automations ls +nan automations run "PR review" --input '{"pr": 42}' +nan approvals ls +nan runs proposal +nan runs reject +nan secrets ls +``` + +Creating or editing automations and triggers, writing secrets and approving proposals happen only in the portal, signed in through your browser: the CLI tells you where if you try. A run waiting for approval makes `nan run`, `nan runs logs -f` and `nan automations run` exit with code 10. The details are in [Automations](/docs/automations#from-the-terminal). + ## Updating Run the installer again. It always brings the latest published release and overwrites the one you have: diff --git a/src/content/docs/runs.mdx b/src/content/docs/runs.mdx index b3a3e04..58934fb 100644 --- a/src/content/docs/runs.mdx +++ b/src/content/docs/runs.mdx @@ -22,6 +22,8 @@ You can start a run in three ways, and all three see the same runs: - From your terminal, with `nan run`. - From your own scripts, with the API. +To run the same task again and again, from a webhook, on a schedule or with actions that wait for your approval, save it as an [automation](/docs/automations). + ## Before you start - **A workspace that is running**, with Pi or Hermes installed. You can install either one from the **Software** panel of the workspace. @@ -100,6 +102,7 @@ The folder must be inside `/home/nan`, your home in the workspace. | `failed` | The agent stopped with an error, or the run could not start (for example, **Separate branch** set to **On** in a folder that is not a repository). | | `cancelled` | You cancelled it. | | `timed_out` | It reached its time limit and was stopped. | +| `awaiting_approval` | Only for [automation](/docs/automations) runs: the agent finished and proposed actions that wait for your approval. | The portal shows the same states with a capital letter, for example **Running**, **Succeeded** or **Cancelled**. @@ -129,6 +132,8 @@ Runs use the inference key of the workspace, the same one its agents use when yo Secrets in your workspace's environment file, such as that inference key or a Telegram bot token, are shown as `***` in run logs and results, so a log does not leak them. +The secrets you keep in your settings are not given to these runs: they go only to the runs of the [automations](/docs/automations#secrets) you assign them to. + ## From the terminal: `nan run` The [NaN CLI](/docs/nan-cli) starts and follows runs from your own terminal. You need version **0.1.25** or newer: `nan --version` prints `nan `. @@ -211,6 +216,8 @@ nan runs cancel | `usage` | Tokens used so far. | | `error` | Something went wrong, with a code. | | `finished` | The run ended: its state and the agent's summary. | +| `approval_required` | An [automation](/docs/automations) run proposed actions that wait for your approval. | +| `action` | One step of applying an automation's actions. | ### Exit codes @@ -226,6 +233,7 @@ nan runs cancel | 64 | usage error, including more than one workspace and no `--ws` | | 65 | not signed in, session expired or not allowed | | 69 | nan.builders unavailable, or the stream was lost (the run goes on, and the id is printed) | +| 10 | the run is waiting for your approval (only [automation](/docs/automations#approve-or-reject-a-proposal) runs) | | 75 | the run was accepted and is still going (`--detach`, or Ctrl-C once) | Under `set -e`, allow for the 75 of `--detach`: @@ -253,7 +261,7 @@ nan auth login --api-token < token.txt # or pipe it in Never put a token on the command line itself: it would end up in your shell history and in `ps`. -In the CLI, a platform token only works for `nan run` and `nan runs` (see [API keys and tokens](#api-keys-and-tokens)). `nan me`, `nan metrics usage` and the dashboard still need a session from `nan auth login`. +In the CLI, a platform token only works for `nan run`, `nan runs`, `nan automations` and `nan approvals` (see [API keys and tokens](#api-keys-and-tokens)). `nan me`, `nan metrics usage` and the dashboard still need a session from `nan auth login`. ### Use the CLI in CI or on a server @@ -292,7 +300,7 @@ Two kinds of credentials work with the runs API. Both are in your [settings on t |---|---|---| | Looks like | `sk-...` | `nan_pat_...` | | Inference (models) | Yes | **No, never** | -| Platform API (runs, your workspaces) | Yes | Yes | +| Platform API (runs, automations, approvals, your workspaces) | Yes | Yes | | Who can create them | Plans with inference | Any member with an active subscription | A **token** is the one to give a script that only manages runs. It cannot call the models directly, but it can start runs that spend your plan, so keep it as private as an API key and revoke it if it leaks. Create it under **Settings → Tokens**: @@ -301,6 +309,7 @@ A **token** is the one to give a script that only manages runs. It cannot call t - You can have up to **10** active tokens. - Each one expires after **30, 90 or 365 days**, or never. - You can revoke any of them at any time. +- It carries **scopes**, what it may do: `runs` and `workspaces:read` by default. To list or run automations, add `automations:read` or `automations:run`. See [Tokens for automations](/docs/automations#tokens-for-automations). 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. diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx new file mode 100644 index 0000000..764d482 --- /dev/null +++ b/src/content/docs/webhooks.mdx @@ -0,0 +1,323 @@ +--- +title: Webhooks and schedules +description: Start your automations from GitHub, Stripe or your own systems with signed webhooks, or on a schedule. +order: 21 +group: Guides +--- + +import Steps from '../../components/docs/Steps.astro'; +import Details from '../../components/docs/Details.astro'; +import Callout from '../../components/docs/Callout.astro'; + +# Webhooks and schedules. + +A **trigger** starts an [automation](/docs/automations) without you: a **webhook** when GitHub, Stripe or one of your systems sends an event, or a **schedule** at the times you pick. Each start creates a run in your workspace with the automation's settings, exactly like **Run now**. + +An automation can have up to **5 triggers**, of either kind. You manage them in its **Triggers** tab, at [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule**, and on each trigger **Pause** / **Resume**, **Rotate** (webhooks) and delete. Adding, changing and deleting triggers needs a [browser sign-in](/docs/automations#why-some-actions-need-the-browser). + +## Add a webhook + + + +### Pick the sender + +In the **Triggers** tab, press **+ Webhook** and pick the **Sender**: + +| Sender | Who sends it | Signing secret | +|---|---|---| +| **GitHub** | A repository's or an organization's webhooks. | NaN creates it. You paste it into GitHub. | +| **Stripe** | A webhook endpoint of your Stripe account. | Stripe creates it (`whsec_...`). You paste it here. | +| **NaN (HMAC)** | Your own scripts and systems. | NaN creates it. Your code signs with it. | + +When the automation comes from a template, the sender and the event filter are already filled in. + +### Choose which events start a run + +**Event filter (JSON, optional)**: which events start a run. See [Event filters](#event-filters). For GitHub you can also limit **Who can start it**: see [Only some GitHub users](#only-some-github-users). + +### Copy the URL and the secret + +Press **Add webhook**. The portal shows, **only once**: + +- **Webhook URL**: `https://api.nan.builders/hooks/nan_whk_...`. The URL itself is a credential: anyone who has it can send to it, so treat it like a password. +- **Signing secret** (GitHub and NaN): what the sender signs each event with. + +Copy both, then press **I've saved them**. Afterwards the portal only shows the start of the URL. If you lose them, [rotate](#rotate-the-url-or-the-secret) to get new ones. + +### Set it up on the sender + +Follow the section for your sender: [GitHub](#from-github), [Stripe](#from-stripe) or [your own code](#send-your-own-events). + + + +## From GitHub + +In the repository, open **Settings → Webhooks → Add webhook** (or the same in your organization's settings): + +| GitHub field | Value | +|---|---| +| **Payload URL** | The webhook URL. | +| **Content type** | `application/json`. A form-encoded webhook is refused with `415`. | +| **Secret** | The signing secret. | +| **Which events** | **Let me select individual events**, and tick the ones your automation handles: **Pull requests** for a review, **Issues** for triage, **Workflow runs** for CI failures. | + +GitHub signs each delivery with `X-Hub-Signature-256` and NaN checks it. When you save, GitHub sends a `ping`: it shows as **Ignored by a filter** in **Deliveries** unless your filter takes `ping`. That tells you the URL and the secret work. + +GitHub does not sign a timestamp, so NaN remembers each delivery id (`X-GitHub-Delivery`) and starts at most one run per delivery. **Redeliver** in GitHub sends the same id: if it already started a run, it is answered as a duplicate. + +## From Stripe + + + +### Create the endpoint in Stripe + +In the Stripe Dashboard, **Developers → Webhooks → Add endpoint**. Pick the events you want and, for now, any placeholder URL of yours: you change it in the last step. Stripe shows the endpoint's **Signing secret** (`whsec_...`). + +### Paste the secret in NaN + +Add the webhook in NaN with **Stripe** as the sender and paste that `whsec_...` in **Stripe signing secret**. It is stored encrypted and never shown again. + +### Point Stripe at the URL + +Set the endpoint's URL in Stripe to the webhook URL NaN gives you. + + + +NaN checks `Stripe-Signature`, accepts any of the signatures Stripe sends (it sends several while you roll its secret) and refuses events more than 5 minutes off. Test and live mode both work: the endpoint you create decides which events you get. Each event id (`evt_...`) starts at most one run. + +## Send your own events + +The **NaN (HMAC)** sender is for your own systems: a deploy script, a monitoring alert, another service. Send a `POST` with a JSON object as the body and these headers: + +| Header | Required | Value | +|---|---|---| +| `X-Nan-Timestamp` | Yes | The current time, in Unix seconds. Events more than 5 minutes off are refused. | +| `X-Nan-Signature` | Yes | `v1=` followed by the hex HMAC-SHA256 of `.` with the signing secret. You can send several, separated by commas, while you rotate the secret. | +| `X-Nan-Delivery` | No | A unique id per event, up to 128 of `A-Z a-z 0-9 . _ : -`. The same id never starts two runs. Without it, the id is the hash of the timestamp and the body. | +| `X-Nan-Event` | No | The event's name, for your [event filter](#event-filters). For example `deploy.finished`. | +| `X-Nan-Run-Id` | No | If a run caused this event, its id (it is in `NAN_RUN_ID` inside the run). It feeds the [loop protection](#loops). | + +Sign the body exactly as you send it, byte for byte. In bash: + +```bash +URL="https://api.nan.builders/hooks/nan_whk_..." +SECRET="..." # the signing secret +BODY='{"service":"api","version":"1.4.2"}' +TS=$(date +%s) +SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') + +curl -s "$URL" \ + -H "Content-Type: application/json" \ + -H "X-Nan-Timestamp: $TS" \ + -H "X-Nan-Signature: v1=$SIG" \ + -H "X-Nan-Event: deploy.finished" \ + -d "$BODY" +``` + +In Python: + +```python +import hashlib, hmac, json, time, urllib.request + +url = "https://api.nan.builders/hooks/nan_whk_..." +secret = b"..." +body = json.dumps({"service": "api", "version": "1.4.2"}).encode() +ts = str(int(time.time())) +sig = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest() + +req = urllib.request.Request(url, data=body, method="POST", headers={ + "Content-Type": "application/json", + "X-Nan-Timestamp": ts, + "X-Nan-Signature": f"v1={sig}", + "X-Nan-Event": "deploy.finished", +}) +print(urllib.request.urlopen(req).read().decode()) +``` + +In Node.js: + +```js +import { createHmac } from 'node:crypto'; + +const url = 'https://api.nan.builders/hooks/nan_whk_...'; +const secret = '...'; +const body = JSON.stringify({ service: 'api', version: '1.4.2' }); +const ts = Math.floor(Date.now() / 1000).toString(); +const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex'); + +const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Nan-Timestamp': ts, + 'X-Nan-Signature': `v1=${sig}`, + 'X-Nan-Event': 'deploy.finished', + }, + body, +}); +console.log(res.status, await res.text()); +``` + + +Keep the URL and the signing secret in your systems' secret store, never in a repository or in client-side code. With both, anyone can start your automation. + + +## Event filters + +The **Event filter** is a JSON object. Each list you fill must match; an empty list, or no filter at all, lets everything through: + +| Field | Matches | Example | +|---|---|---| +| `events` | The event name: `X-GitHub-Event` for GitHub, the event `type` for Stripe, `X-Nan-Event` for NaN. An entry ending in `.*` matches a prefix. | `["pull_request"]`, `["invoice.*"]` | +| `actions` | GitHub's `action` field. | `["opened", "synchronize"]` | +| `exclude_drafts` | `true` ignores draft pull requests. | `true` | +| `conclusions` | The `conclusion` of a GitHub `workflow_run`. | `["failure"]` | + +The **Pull request review** template, for example, uses: + +```json +{ + "events": ["pull_request"], + "actions": ["opened", "synchronize", "reopened", "ready_for_review"], + "exclude_drafts": true +} +``` + +Up to 32 entries per list, each of `A-Z a-z 0-9 _ . : -`. An event that does not match is answered `200` and listed as **Ignored by a filter**. + +## Only some GitHub users + +By default, any event with a valid signature starts a run. On a public repository that means anyone who can open a pull request or an issue. To limit it, tick **Only some GitHub users** under **Who can start it**: + +- **GitHub logins**: only events sent by these users. Empty means any. +- **Relationship to the repository**: only authors with that relationship (owner, member, collaborator, contributor, first-time contributor...). None ticked means any. +- **Ignore bots**: skip events sent by bot accounts. + +You can change it later with **Change** on the trigger. + +## What the agent receives + +The event reaches the agent as data, after your instructions and clearly marked as coming from outside. It is never taken as instructions. + +- **GitHub** pull request, issue and workflow run events arrive trimmed to the fields that matter: the repository, the number, the title, the body (up to 8 KiB), the author and their relationship to the repository, the branches and commit, the link, and the changed files or the failed workflow. +- **Any other event** (other GitHub events, Stripe, NaN) arrives as its JSON body, if it is at most 48 KiB and nested at most 8 levels. Otherwise it is listed as **Too large** and no run starts. + +Long strings are cut with a marker. Webhook bodies are capped at 1 MiB. + +## What the sender gets back + +| Status | When | Body | +|---|---|---| +| `202` | A run was created. | `{"status":"run_created","run_id":"..."}` | +| `200` | Accepted without a run: filtered out, a repeated delivery, a loop, a run already active, a membership without runs, or too large. | `{"status":"ignored"}`, `"duplicate"`, `"loop_suppressed"`, `"skipped_active"`, `"not_accepted"` or `"too_large"` | +| `401` | The signature is missing or wrong, or the timestamp is too far off. | Empty | +| `404` | Unknown URL, or a paused trigger or a disabled automation. | Empty | +| `413` | The body is over 1 MiB. | Empty | +| `415` | A GitHub webhook that is not `application/json`. | Empty | +| `429` | Too many deliveries for now, the automation reached its runs per hour, or your queue is full. Retry later (`Retry-After`). | Empty | +| `503` | Temporarily unavailable. Retry later. | Empty | + +A `200` is final: the sender should not retry it. A `429` or a `503` is not recorded, so a retry or a redelivery is processed as new. + +## Deliveries + +The **Deliveries** tab lists the webhooks the automation received in the last **30 days**, newest first, with when it arrived, its delivery id, what happened and the run it started: + +| Outcome | Meaning | +|---|---| +| **Run started** | A run was created. | +| **Ignored by a filter** | The event or actor filter did not match. | +| **Stopped: caused by one of its own runs** | A [loop](#loops) was stopped. | +| **Skipped: a run for the same subject is active** | See [When a run is already active](/docs/automations#when-a-run-is-already-active). | +| **Not accepted: your membership does not include runs** | Your plan has no inference. | +| **Too large** | The event did not fit in what the agent can receive. | + +No payload and no headers are kept. Deliveries with a bad signature are never stored, and throttled ones (rate limit, full queue, temporarily unavailable) are answered with a retry and not listed. + +From the terminal: `nan automations deliveries `. From the API: [`GET /v1/automations/{id}/deliveries`](/docs/api#tag/automations). + +## Rotate the URL or the secret + +**Rotate**, on a webhook, gives you new credentials. Tick what to replace: + +- **New webhook URL**: the current URL stops working at once. +- **New signing secret**: by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. + +The new values are shown once, like when you added the webhook. + +## Loops + +An automation that comments on a pull request can receive a webhook for its own comment, and an automation that pushes can trigger the CI that triggers it again. NaN stops those loops: + +- Everything a run does carries its mark: a `Nan-Run:` line in pushed commits, a hidden marker in comments, reviews and pull requests, and branches named `nan-run/...`. Your own scripts can forward `NAN_RUN_ID` as `X-Nan-Run-Id`. +- An event caused by a run counts one step more than that run. Beyond the automation's **Chain depth** (2 by default, 0 to 5), it is stopped and listed as **Stopped: caused by one of its own runs**. + +The runs per hour of each automation, the 120 runs per hour from webhooks and schedules across your automations, and **Skip the new event** stop a loop that slips past the marks. + +## Schedules + +### Add a schedule + +In the **Triggers** tab, press **+ Schedule**: + +- **Schedule (cron)**: five fields, minute, hour, day of month, month and day of week. For example `0 9 * * 1-5` (weekdays at 9:00) or `30 */2 * * *` (every two hours at half past). The shortcuts `@hourly`, `@daily`, `@weekly`, `@monthly` and `@yearly` work too. +- **Time zone**: the times follow it. It starts as your browser's. + +The portal previews the next runs before you save. Press **Add schedule**. + +### Rules + +- **At least 15 minutes between runs.** A schedule whose next runs come closer than that is refused. +- **Up to 10 schedules** across all your automations. +- **Clock changes**: when clocks go forward, a skipped hour runs at the next valid time; when they go back, a repeated hour runs once. +- **A few seconds later than the minute**: each schedule fires up to a minute after its time, always at the same offset, so schedules on the hour do not all start at once. +- **No catching up**: if a run could not start on time (during maintenance, for example) it still starts if it is at most 15 minutes late. Later than that, that time is skipped, never made up. + +### Last run + +Each schedule shows its next run and how the last one went: + +| Shown as | Meaning | +|---|---| +| **Run started** | A run was created. | +| **Skipped: the previous run was still active** | **Skip the new event** is on and the previous run had not ended. | +| **Skipped: hourly limit reached** | The automation reached its runs per hour, or you reached the 120 per hour from webhooks and schedules. | +| **Skipped: too many runs waiting** | Your run queue is full. | +| **Skipped: automation disabled** | The automation is not enabled. | +| **Skipped: workspace deleted** | Its workspace no longer exists. | +| **Not accepted: your membership does not include runs** | Your plan has no inference. | +| **Missed (more than 15 minutes late)** | It could not start in time and was skipped. | + +A scheduled run receives, as data, the time it was scheduled for, the cron expression and the time zone: `scheduled_for`, `cron` and `timezone`. + +## FAQ + +
+ +Open the delivery in GitHub and look at the response. `401` means the secret in GitHub is not the signing secret of this webhook: rotate it and paste the new one. `415` means the content type is not `application/json`. `404` means the URL is wrong, the trigger is paused or the automation is disabled. + +
+ +
+ +The body tells you why: `ignored` (the filter did not match, often the `ping` or an action you did not include), `skipped_active`, `loop_suppressed` or `duplicate`. The **Deliveries** tab lists the same. + +
+ +
+ +No. A webhook URL belongs to one automation of one member, and it only starts runs of that automation, in its workspace. + +
+ +
+ +Yes: with a platform token that has the `automations:run` scope, call `nan automations run` or the API. See [Run it now](/docs/automations#run-it-now). + +
+ +
+ +Write in `#support` on Discord. + +
diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts new file mode 100644 index 0000000..7ca6d77 --- /dev/null +++ b/src/tests/layouts/automationsDocs.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { createHmac } from 'node:crypto'; +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 Automations and the Webhooks and schedules guides (Agent Automations + * Phase 2), and what the other guides say about them. + * + * The facts pinned here come from cloud-api origin/main (automations/api.go + * limits and outcomes, templates.go, validate.go, webhook/verify.go, + * middleware/pat_scopes.go), cloud-ui origin/main (the labels the portal + * shows: lib/automations.ts, lib/approvals.ts, components/automations/*, + * components/SecretsSection.tsx) and nan-cli 0.1.26 (PR helmcode/nan-cli#43: + * commands, exit code 10, portal-only verbs). + */ +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, '../..'); +const read = (p: string) => readFileSync(resolve(src, p), 'utf-8'); +const strip = (raw: string) => raw.replace(/^---[\s\S]*?\n---\n/, ''); + +const pages = { + en: { dir: 'content/docs/', prefix: '', group: 'Guides', titles: ['Automations', 'Webhooks and schedules'], tokensAnchor: 'tokens-for-automations' }, + es: { dir: 'content/docs-es/', prefix: '/es', group: 'Guías', titles: ['Automatizaciones', 'Webhooks y programaciones'], tokensAnchor: 'tokens-para-automatizaciones' }, +} as const; + +/** Heading ids as the site renders them (github-slugger, as in runsDocs.test.ts). */ +const slug = (h: string) => + h + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .replace(/ /g, '-'); + +function headingIds(body: string): Set { + const ids = new Set(); + let inFence = false; + for (const line of body.split('\n')) { + if (line.startsWith('```')) inFence = !inFence; + const m = !inFence && /^#{1,6}\s+(.*)$/.exec(line); + if (m) ids.add(slug(m[1].replace(/`/g, ''))); + } + return ids; +} + +const INTERNAL = /firecracker|micro-?vms?\b|\bvm\b|\bkvm\b|kubernetes|\bk8s\b|\br2\b|nan-vmd|systemd|litellm|cgroup|\bvault\b|transit|dynamicuser/i; + +/** cloud-ui lib/approvals.ts ACTION_KIND_LABELS. */ +const ACTION_LABELS: Record = { + git_push: 'Push commits', + github_create_pr: 'Open pull requests', + github_pr_comment: 'Comment on pull requests', + github_pr_review: 'Review pull requests', + github_issue_comment: 'Comment on issues', + github_add_labels: 'Add labels', +}; + +/** cloud-ui lib/automations.ts DELIVERY_OUTCOMES (persisted outcomes only). */ +const DELIVERY_LABELS = [ + 'Run started', + 'Ignored by a filter', + 'Stopped: caused by one of its own runs', + 'Skipped: a run for the same subject is active', + 'Not accepted: your membership does not include runs', + 'Too large', +]; + +/** cloud-ui lib/automations.ts SCHEDULE_OUTCOMES (without the generic Error). */ +const SCHEDULE_LABELS = [ + 'Run started', + 'Skipped: the previous run was still active', + 'Not accepted: your membership does not include runs', + 'Missed (more than 15 minutes late)', + 'Skipped: hourly limit reached', + 'Skipped: too many runs waiting', + 'Skipped: automation disabled', + 'Skipped: workspace deleted', +]; + +describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) => { + const auto = read(page.dir + 'automations.mdx'); + const hooks = read(page.dir + 'webhooks.mdx'); + const autoBody = strip(auto); + const hooksBody = strip(hooks); + const autoFlat = autoBody.replace(/\s+/g, ' '); + const hooksFlat = hooksBody.replace(/\s+/g, ' '); + + test('sit in the guides group, right after Agent runs', () => { + const runs = parseFrontmatter(read(page.dir + 'runs.mdx')).frontmatter as { order: number }; + const a = parseFrontmatter(auto).frontmatter as { order: number; group: string; title: string }; + const h = parseFrontmatter(hooks).frontmatter as { order: number; group: string; title: string }; + expect([a.title, h.title]).toEqual(page.titles); + expect(a.group).toBe(page.group); + expect(h.group).toBe(page.group); + expect(a.order).toBe(runs.order + 1); + expect(h.order).toBe(runs.order + 2); + for (const later of ['examples.md', 'apps.md']) { + const o = (parseFrontmatter(read(page.dir + later)).frontmatter as { order: number }).order; + expect(o, later).toBeGreaterThan(h.order); + } + }); + + test('render through the docs text extractor', () => { + expect(() => mdxToText(autoBody)).not.toThrow(); + expect(() => mdxToText(hooksBody)).not.toThrow(); + }); + + test('every link to a docs page and anchor resolves, in the same locale', () => { + const own = { automations: autoBody, webhooks: hooksBody } as Record; + const pageBody = (name: string) => own[name] ?? strip(read(`${page.dir}${name}.mdx`)); + for (const [name, body] of Object.entries(own)) { + const links = [...body.matchAll(/\]\(((?:\/es)?\/docs\/[\w-]+)?(#[^)\s]+)?\)/g)]; + expect(links.length, name).toBeGreaterThan(5); + for (const m of links) { + const [, target, anchor] = m; + if (!target && !anchor) continue; + if (target) { + if (target.endsWith('/api')) continue; // the API reference: checked against its tags below. + expect(target.startsWith(`${page.prefix}/docs/`), `${name}: ${target} in the wrong locale`).toBe(true); + } + if (!anchor) continue; + const targetName = target ? target.split('/').pop()! : name; + expect(headingIds(pageBody(targetName)).has(decodeURIComponent(anchor.slice(1))), `${name}: ${target ?? ''}${anchor}`).toBe(true); + } + } + }); + + test('links into the API reference point at tags that exist', () => { + const tags = new Set((openapi.tags as Array<{ name: string }>).map((t) => `tag/${t.name.toLowerCase()}`)); + for (const body of [autoBody, hooksBody]) { + for (const m of body.matchAll(/\/docs\/api#(tag\/[\w-]+)/g)) expect(tags.has(m[1]), m[1]).toBe(true); + } + }); + + test('templates, fields and the labels the portal shows', () => { + for (const label of ['Pull request review', 'Issue triage', 'CI failure', 'Generic webhook', 'From scratch', 'New automation', 'Create automation', 'Run now', 'Needs approval', 'Review', 'Approve and apply', 'Reject', 'Open run', 'Changes to push']) { + expect(autoBody, label).toContain(`**${label}**`); + } + for (const field of ['Name', 'Instructions', 'Workspace', 'Agent', 'Folder', 'Separate branch', 'Time limit (minutes)', 'GitHub repository (optional)', 'Secrets', 'Approval', 'Runs per hour', 'When a run is active', 'Chain depth', 'Enabled']) { + expect(autoBody, field).toContain(`**${field}**`); + } + for (const preset of ['Approve everything', 'Fully automatic', 'Custom', 'Skip the new event', 'Queue it', 'Agent (read access)', 'Actions only (write access)']) { + expect(autoBody, preset).toContain(`**${preset}**`); + } + for (const [kind, label] of Object.entries(ACTION_LABELS)) { + expect(autoBody, kind).toContain(`\`${kind}\``); + expect(autoBody, label).toContain(`**${label}**`); + } + }); + + test('approval is the member\'s choice, empty = fully automatic, with the TTL options', () => { + expect(autoBody).toContain('**1 hour**'); + expect(autoBody).toContain('**72 hours**'); + expect(autoBody).toContain('**7 days**'); + expect(autoBody).toContain('`awaiting_approval`'); + for (const code of ['approval_rejected', 'approval_expired', 'proposal_invalid', 'action_failed']) expect(autoBody, code).toContain(`\`${code}\``); + expect(autoBody).toContain('`nan runs reject `'); + }); + + test('secrets: write-only, exposures, reserved names and budgets', () => { + for (const fact of ['16 KiB', '48 KiB', '100', '20', '`GITHUB_TOKEN`', '`OPENAI_API_KEY`', '`NAN_`', '`PI_`', '`HERMES_`', '`secrets_unavailable`']) { + expect(autoFlat, fact).toContain(fact); + } + expect(autoBody).toContain('**Add secret**'); + expect(autoBody).toContain('**Replace value**'); + }); + + test('the limits of cloud-api (automations/api.go)', () => { + const limits = autoBody; + for (const row of [/\| (Automations|Automatizaciones) \| 50 /, /\| Triggers \| 5 /, /\| (Schedules|Programaciones) \| 10 /, /30 [^|]*120/, /120 [^|]*(hour|hora)/]) { + expect(limits, String(row)).toMatch(row); + } + expect(autoFlat).toMatch(/5 [^.]*\(8 (on|con) Premium\)/); + }); + + test('the CLI: 0.1.26 commands, exit code 10, portal-only writes', () => { + for (const cmd of ['nan automations ls', 'nan automations show', 'nan automations run', 'nan automations deliveries', 'nan approvals ls', 'nan runs proposal', 'nan runs reject', 'nan secrets ls']) { + expect(autoBody, cmd).toContain(cmd); + } + expect(autoBody).toContain('**0.1.26**'); + expect(autoFlat).toMatch(/\*\*10\*\*/); + expect(autoBody).not.toMatch(/nan (runs approve|secrets set|automations create)/); + }); + + test('tokens: the four scopes and what needs the browser', () => { + for (const scope of ['runs', 'workspaces:read', 'automations:read', 'automations:run']) expect(autoBody, scope).toContain(`| \`${scope}\` |`); + expect(autoBody).toContain('`403 token_scope_denied`'); + expect(autoBody).toContain('https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs'); + }); + + test('webhooks: URL, senders, every NaN header and the GitHub setup', () => { + expect(hooksBody).toContain('`https://api.nan.builders/hooks/nan_whk_...`'); + for (const sender of ['GitHub', 'Stripe', 'NaN (HMAC)']) expect(hooksBody, sender).toContain(`**${sender}**`); + for (const h of ['X-Nan-Timestamp', 'X-Nan-Signature', 'X-Nan-Delivery', 'X-Nan-Event', 'X-Nan-Run-Id', 'X-Hub-Signature-256', 'X-GitHub-Delivery', 'Stripe-Signature']) { + expect(hooksBody, h).toContain(`\`${h}\``); + } + expect(hooksBody).toContain('`application/json`'); + expect(hooksBody).toContain('`415`'); + expect(hooksFlat).toMatch(/5 (minutes|minutos)/); + expect(hooksFlat).toMatch(/24 (hours|horas)/); + expect(hooksFlat).toContain('1 MiB'); + expect(hooksBody).toContain("**I've saved them**"); + }); + + test('webhooks: the answers to the sender match cloud-api (DeliveryOutcome.HTTPStatus)', () => { + for (const status of ['202', '200', '401', '404', '413', '415', '429', '503']) expect(hooksBody, status).toContain(`| \`${status}\` |`); + for (const outcome of ['run_created', 'ignored', 'duplicate', 'loop_suppressed', 'skipped_active', 'not_accepted', 'too_large']) { + expect(hooksBody, outcome).toContain(`"${outcome}"`); + } + }); + + test('webhooks and schedules: the delivery and schedule outcomes the portal shows', () => { + for (const label of DELIVERY_LABELS) expect(hooksBody, label).toContain(`**${label}**`); + for (const label of SCHEDULE_LABELS) expect(hooksBody, label).toContain(`**${label}**`); + expect(hooksFlat).toMatch(/30 (days|días)/); + }); + + test('event filters: the four fields of EventFilter', () => { + for (const field of ['events', 'actions', 'exclude_drafts', 'conclusions']) expect(hooksBody, field).toContain(`| \`${field}\` |`); + expect(hooksBody).toContain('"ready_for_review"'); + }); + + test('schedules: cron, shortcuts, 15 minutes, 10 per member, no catch-up', () => { + expect(hooksBody).toContain('`0 9 * * 1-5`'); + for (const d of ['@hourly', '@daily', '@weekly', '@monthly', '@yearly']) expect(hooksBody, d).toContain(`\`${d}\``); + expect(hooksBody).not.toContain('@every'); + expect(hooksFlat).toMatch(/15 (minutes|minutos)/); + expect(hooksFlat).toMatch(/10 /); + for (const f of ['scheduled_for', 'cron', 'timezone']) expect(hooksBody, f).toContain(`\`${f}\``); + }); + + test('the bash, Python and Node.js signing examples sign `.` with HMAC-SHA256', () => { + expect(hooksBody).toMatch(/printf '%s\.%s' "\$TS" "\$BODY" \| openssl dgst -sha256 -hmac "\$SECRET"/); + expect(hooksBody).toContain('hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()'); + expect(hooksBody).toContain("createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')"); + // The scheme itself: what cloud-api's verifyNaN recomputes. + const sig = createHmac('sha256', 'secret').update('1760000000.{"a":1}').digest('hex'); + expect(sig).toMatch(/^[0-9a-f]{64}$/); + }); + + test('never discloses internals, never uses em-dashes', () => { + for (const body of [autoBody, hooksBody]) { + expect(body).not.toMatch(INTERNAL); + expect(body).not.toContain('—'); + } + }); + + test('never publishes ids or private addresses', () => { + for (const body of [autoBody, hooksBody]) { + expect(body).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}|\b10\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/i); + expect(body).not.toMatch(/nan_whk_[A-Za-z0-9_-]{8,}/); + } + }); + + test('ephemeral executors and BYOK are not documented', () => { + for (const body of [autoBody, hooksBody]) expect(body).not.toMatch(/ephemeral|ef[ií]mer|byok|bring your own key/i); + }); +}); + +describe('automations guides, both locales', () => { + test.each(['automations.mdx', 'webhooks.mdx'])('%s: the Spanish page is a translation with the same structure', (file) => { + const [en, es] = [pages.en, pages.es].map((p) => strip(read(p.dir + file))); + const shape = (b: string) => (b.match(/^#{1,3} /gm) ?? []).join(''); + expect(shape(es)).toEqual(shape(en)); + expect(es).not.toEqual(en); + const count = (b: string, re: RegExp) => (b.match(re) ?? []).length; + for (const re of [/```/g, /
/g, / [...b.matchAll(/```[\s\S]*?```/g)].map((m) => m[0].replace(/#.*$/gm, '').replace(/\/\/.*$/gm, '')); + expect(codeBlocks(es)).toEqual(codeBlocks(en)); + }); +}); + +describe.each(Object.entries(pages))('the other guides point at automations (%s)', (_locale, page) => { + test('runs: the new state, events, exit code and token scopes', () => { + const body = strip(read(page.dir + 'runs.mdx')); + expect(body).toContain('| `awaiting_approval` |'); + expect(body).toContain('| `approval_required` |'); + expect(body).toContain('| `action` |'); + expect(body).toMatch(/^\| 10 \|/m); + expect(body).toContain(`${page.prefix}/docs/automations#${page.tokensAnchor}`); + expect(body).toContain('`nan automations`'); + }); + + test('the CLI guide and the introduction link both guides', () => { + expect(read(page.dir + 'nan-cli.mdx')).toContain('nan automations run'); + const intro = read(page.dir + 'intro.md'); + expect(intro).toContain(`(${page.prefix}/docs/automations)`); + expect(intro).toContain(`(${page.prefix}/docs/webhooks)`); + }); +}); + +describe('member secrets are a portal feature, not part of the public API', () => { + test('no page documents /api/account/secrets as an endpoint', () => { + for (const dir of ['content/docs/', 'content/docs-es/']) { + for (const f of ['automations.mdx', 'webhooks.mdx', 'runs.mdx', 'nan-cli.mdx']) { + expect(read(dir + f), dir + f).not.toContain('/api/account/secrets'); + } + } + }); +}); diff --git a/src/tests/lib/docsSnippets.test.ts b/src/tests/lib/docsSnippets.test.ts index 988a221..2bf3d60 100644 --- a/src/tests/lib/docsSnippets.test.ts +++ b/src/tests/lib/docsSnippets.test.ts @@ -197,8 +197,8 @@ describe('every package a snippet imports is a package the page installs', () => 'os', 'path', 'process', 'readline', 'stream', 'url', 'util', 'zlib', ]); const PY_STDLIB = new Set([ - 'base64', 'dataclasses', 'io', 'json', 'os', 'pathlib', 're', 'sys', 'textwrap', - 'time', 'typing', 'wave', + 'base64', 'dataclasses', 'hashlib', 'hmac', 'io', 'json', 'os', 'pathlib', 're', 'sys', + 'textwrap', 'time', 'typing', 'urllib', 'wave', ]); /** `@scope/pkg/sub` -> `@scope/pkg`, `openai/shims` -> `openai`. */ From 5a22c6f4f376e2f0fa45829cd1c95176fc3837a3 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 22:18:54 +0200 Subject: [PATCH 2/4] docs(api): Automations and Approvals in the API reference Adds the Automations tag (list, templates, retrieve, triggers, deliveries, run) and the Approvals tag (list, proposal diff, reject), the Phase 2 run fields (phase, approval, apply_run_ids, automation_id, parent_run_id and the rest), the awaiting_approval state, the approval_required and action events and the new error codes. Documents platform token scopes, the browser sign-in rule (browser_session_required), token_not_valid_for_inference on inference, and the current session_required body. Portal-only writes and member secrets are not documented as endpoints. cloud-api's own golden responses are copied as fixtures and validated against the published schemas; the example validator moves to a shared test helper. Co-Authored-By: Claude Opus 5.5 --- src/data/openapi.json | 5812 ++++++++++++----- src/lib/openapiAutomations.test.ts | 353 + src/lib/openapiRuns.test.ts | 112 +- src/lib/openapiSpec.test.ts | 9 + src/tests/fixtures/phase2/approvals_list.json | 79 + .../phase2/automation_deliveries.json | 12 + src/tests/fixtures/phase2/automation_get.json | 36 + .../fixtures/phase2/automation_templates.json | 31 + .../fixtures/phase2/automation_triggers.json | 34 + .../fixtures/phase2/automations_list.json | 41 + src/tests/fixtures/phase2/run_get_adhoc.json | 52 + .../fixtures/phase2/run_get_propose.json | 147 + .../fixtures/phase2/run_list_propose.json | 141 + src/tests/fixtures/phase2/trigger_create.json | 33 + src/tests/helpers/openapiValidate.ts | 85 + 15 files changed, 5353 insertions(+), 1624 deletions(-) create mode 100644 src/lib/openapiAutomations.test.ts create mode 100644 src/tests/fixtures/phase2/approvals_list.json create mode 100644 src/tests/fixtures/phase2/automation_deliveries.json create mode 100644 src/tests/fixtures/phase2/automation_get.json create mode 100644 src/tests/fixtures/phase2/automation_templates.json create mode 100644 src/tests/fixtures/phase2/automation_triggers.json create mode 100644 src/tests/fixtures/phase2/automations_list.json create mode 100644 src/tests/fixtures/phase2/run_get_adhoc.json create mode 100644 src/tests/fixtures/phase2/run_get_propose.json create mode 100644 src/tests/fixtures/phase2/run_list_propose.json create mode 100644 src/tests/fixtures/phase2/trigger_create.json create mode 100644 src/tests/helpers/openapiValidate.ts diff --git a/src/data/openapi.json b/src/data/openapi.json index 91e6e96..6c6b20a 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 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.", + "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), [Automations](#tag/automations) and [Approvals](#tag/approvals), 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`. An API key works on both inference and these endpoints. Each endpoint lists the credentials it accepts.\n\nA platform token carries **scopes**, and each endpoint names the one it needs: `runs` (runs and approvals), `workspaces:read`, `automations:read` (read automations, templates, triggers and deliveries) and `automations:run` (run an existing automation). New tokens get `runs` and `workspaces:read`. A token without the scope gets `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This platform token does not have the \\\"automations:run\\\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"token_scope_denied\"\n }\n}\n```\n\nA platform token never works for inference: the model endpoints answer `401`:\n\n```json\n{\n \"error\": {\n \"message\": \"Platform tokens (nan_pat_) cannot be used for inference. Use an inference API key (sk-) instead. Platform tokens only work on the platform API (/v1/runs and GET /api/workspaces).\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"token_not_valid_for_inference\"\n }\n}\n```\n\n### Browser sign-in\n\nSome actions need you signed in through your browser at [cloud.nan.builders](https://cloud.nan.builders/), and no API key, platform token or CLI session can do them: approving a run, creating, editing or deleting automations and their triggers, writing secrets, and creating a token with an `automations:` scope. Through the API they answer `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This action needs a browser session: sign in at cloud.nan.builders in your browser and try again from there. CLI sessions, API keys and platform tokens cannot approve runs, change automations or secrets, or create tokens with automations scopes.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"browser_session_required\"\n }\n}\n```\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. A platform token (`nan_pat_...`) sent to an inference endpoint answers `token_not_valid_for_inference`. | `invalid_api_key` · `token_not_valid_for_inference` |\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. On the platform endpoints: a platform token without the scope the endpoint needs, or an action that needs a [browser sign-in](#description/browser-sign-in). | `tier_restricted` · `token_scope_denied` · `browser_session_required` |\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" @@ -60,7 +60,15 @@ }, { "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)." + "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 with the `runs` scope. Guide: [Agent runs](https://nan.builders/docs/runs)." + }, + { + "name": "Automations", + "description": "Read and run the automations you set up in the portal: saved agent tasks that webhooks, schedules or your own calls start. Reading needs a platform token with `automations:read`, running one `automations:run` (or your API key). Creating, editing and deleting automations and their triggers is done in the portal, signed in through your browser: those routes answer `403 browser_session_required` to API keys and platform tokens. Webhooks themselves arrive at `https://api.nan.builders/hooks/`, signed by the sender. Guides: [Automations](https://nan.builders/docs/automations), [Webhooks and schedules](https://nan.builders/docs/webhooks)." + }, + { + "name": "Approvals", + "description": "The actions your automation runs propose, and the ones waiting for your approval. List them, read a proposal and its diff, and reject. Approving is done only in the portal, signed in through your browser (`POST /v1/runs/{id}/approve` answers `403 browser_session_required` to API keys and platform tokens), so nothing a script or an agent holds can approve. Guide: [Automations](https://nan.builders/docs/automations#approve-or-reject-a-proposal)." }, { "name": "Workspaces", @@ -1602,7 +1610,7 @@ "platformToken": [] } ], - "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_...`).", + "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_...`) with the `runs` scope.", "parameters": [ { "name": "Idempotency-Key", @@ -1844,7 +1852,7 @@ "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_...`).", + "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_...`) with the `runs` scope.", "parameters": [ { "name": "workspace", @@ -1854,6 +1862,15 @@ }, "description": "Only the runs of this workspace (name or id). A workspace that is not yours gives an empty page." }, + { + "name": "automation", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Only the runs of this automation (its id). An automation that is not yours gives an empty page; a malformed id answers `400`." + }, { "name": "state", "in": "query", @@ -1863,6 +1880,7 @@ "queued", "starting", "running", + "awaiting_approval", "succeeded", "failed", "cancelled", @@ -2002,7 +2020,7 @@ "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_...`).", + "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_...`) with the `runs` scope.", "parameters": [ { "name": "id", @@ -2128,7 +2146,7 @@ "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_...`).", + "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, or waiting for your approval (`awaiting_approval`), 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_...`) with the `runs` scope.", "parameters": [ { "name": "id", @@ -2327,7 +2345,7 @@ "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_...`).", + "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_...`) with the `runs` scope.", "parameters": [ { "name": "id", @@ -2477,19 +2495,13 @@ ] } }, - "/api/workspaces": { - "servers": [ - { - "url": "https://api.nan.builders", - "description": "Production. These routes live outside `/v1`." - } - ], + "/automations": { "get": { - "operationId": "listWorkspaces", + "operationId": "listAutomations", "tags": [ - "Workspaces" + "Automations" ], - "summary": "List workspaces", + "summary": "List automations", "security": [ { "apiKey": [] @@ -2498,82 +2510,100 @@ "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.", + "description": "Your automations, with their settings. Secrets appear by name, never by value.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", "responses": { "200": { - "description": "Your workspaces (a JSON array, empty if you have none).", + "description": "Your automations.", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } + "$ref": "#/components/schemas/AutomationList" }, - "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 + "example": { + "object": "list", + "data": [ + { + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "object": "automation", + "name": "PR review api", + "description": "Reviews every pull request of acme/api.", + "template": "pr_review", + "workspace": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "agent": "pi", + "model": null, + "instructions": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "approval_ttl_seconds": 259200, + "inference": { + "source": "nan" + }, + "github_repo": "acme/api", + "secrets": [ + { + "secret": "GITHUB_READ_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "agent" + }, + { + "secret": "GITHUB_WRITE_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "actions" + } + ], + "max_runs_per_hour": 30, + "concurrency_policy": "skip_if_active", + "max_trigger_depth": 2, + "enabled": true, + "disabled_reason": null, + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-11T09:00:00Z" } - } - ] + ] + } } } }, "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`).", + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformError" + "$ref": "#/components/schemas/Error" }, "examples": { - "unauthorized": { + "token_scope_denied": { + "summary": "A platform token without the `automations:read` scope.", "value": { - "error": "unauthorized" - } - }, - "invalid_session": { - "value": { - "error": "invalid session" + "error": { + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } } }, - "session_required": { + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", "value": { - "error": "session_required", - "message": "This endpoint requires a signed-in session. API keys only work for inference (https://api.nan.builders/v1)." + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } } } } @@ -2585,24 +2615,18 @@ { "lang": "curl", "label": "cURL", - "source": "curl https://api.nan.builders/api/workspaces \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + "source": "curl https://api.nan.builders/v1/automations \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" } ] } }, - "/api/workspaces/{id}": { - "servers": [ - { - "url": "https://api.nan.builders", - "description": "Production. These routes live outside `/v1`." - } - ], + "/automations/templates": { "get": { - "operationId": "getWorkspace", + "operationId": "listAutomationTemplates", "tags": [ - "Workspaces" + "Automations" ], - "summary": "Retrieve a workspace", + "summary": "List templates", "security": [ { "apiKey": [] @@ -2611,125 +2635,111 @@ "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." - } - ], + "description": "The templates the portal offers when you create an automation: `pr_review`, `issue_triage`, `ci_failure` and `generic_webhook`, with their defaults.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", "responses": { "200": { - "description": "The workspace.", + "description": "The templates.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkspaceDetail" + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationTemplate" + } + } + } }, "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" + "object": "list", + "data": [ + { + "id": "pr_review", + "name": "Pull request review", + "description": "Reviews every new or updated pull request and proposes a review.", + "instructions": "Review the pull request described in the input for correctness, security and maintainability. ...", + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "trigger_kind": "webhook", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + }, + "secret_slots": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent", + "optional": true, + "help": "Read-only token the agent uses to read the repository, pull requests and CI logs. Use a fine-grained token limited to this repository." + }, + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "optional": false, + "help": "Write token used only by NaN's action executor (never given to the agent) to push, comment, review, label and open pull requests." + } + ], + "requires_github_repo": true, + "suggested_concurrency": "skip_if_active" + } ] } } } }, "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)." - } - } - } - } - } + "$ref": "#/components/responses/RunsUnauthorized" }, - "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).", + "403": { + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PlatformError" - }, - { - "$ref": "#/components/schemas/Error" - } - ] + "$ref": "#/components/schemas/Error" }, "examples": { - "workspace_not_found": { + "token_scope_denied": { + "summary": "A platform token without the `automations:read` scope.", "value": { - "error": "workspace not found" + "error": { + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } } }, - "malformed_id": { + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", "value": { "error": { - "message": "Not found.", + "message": "workspace keys cannot manage runs", "type": "invalid_request_error", - "code": "404" + "param": null, + "code": "workspace_key_not_allowed" } } } @@ -2742,1303 +2752,3933 @@ { "lang": "curl", "label": "cURL", - "source": "curl https://api.nan.builders/api/workspaces/$WORKSPACE_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + "source": "curl https://api.nan.builders/v1/automations/templates \\\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." + }, + "/automations/{id}": { + "get": { + "operationId": "getAutomation", + "tags": [ + "Automations" + ], + "summary": "Retrieve an automation", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "One automation.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The automation id. A malformed id answers `404`." + } + ], + "responses": { + "200": { + "description": "The automation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Automation" + }, + "example": { + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "object": "automation", + "name": "PR review api", + "description": "Reviews every pull request of acme/api.", + "template": "pr_review", + "workspace": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "agent": "pi", + "model": null, + "instructions": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "approval_ttl_seconds": 259200, + "inference": { + "source": "nan" + }, + "github_repo": "acme/api", + "secrets": [ + { + "secret": "GITHUB_READ_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "agent" + }, + { + "secret": "GITHUB_WRITE_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "actions" + } + ], + "max_runs_per_hour": 30, + "concurrency_policy": "skip_if_active", + "max_trigger_depth": 2, + "enabled": true, + "disabled_reason": null, + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-11T09:00:00Z" + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `automations:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } + } + } + } + } + } + }, + "404": { + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] } }, - "schemas": { - "Model": { + "/automations/{id}/triggers": { + "get": { + "operationId": "listAutomationTriggers", + "tags": [ + "Automations" + ], + "summary": "List triggers", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "The webhooks and schedules of an automation. A webhook shows only the start of its URL: the full URL and the signing secret are shown once, in the portal, when they are created or rotated.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The automation id. A malformed id answers `404`." + } + ], + "responses": { + "200": { + "description": "The triggers.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerList" + }, + "example": { + "object": "list", + "data": [ + { + "id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", + "object": "trigger", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "kind": "webhook", + "enabled": true, + "token_prefix": "nan_whk_Q3vX", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + }, + "actor_filter": { + "logins": [], + "author_associations": [ + "OWNER", + "MEMBER", + "COLLABORATOR" + ], + "exclude_bots": true + }, + "prev_secret_until": null, + "cron": null, + "timezone": null, + "next_fire_at": null, + "last_fired_at": null, + "last_outcome": null, + "created_at": "2026-10-11T09:01:00Z", + "updated_at": "2026-10-11T09:01:00Z" + }, + { + "id": "4c6e8a0b-2d4f-4a6c-9e0b-1d3f5a7c9e2b", + "object": "trigger", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "kind": "schedule", + "enabled": true, + "token_prefix": null, + "signature_scheme": null, + "event_filter": null, + "actor_filter": null, + "prev_secret_until": null, + "cron": "0 9 * * 1-5", + "timezone": "Europe/Madrid", + "next_fire_at": "2026-10-12T07:00:21Z", + "last_fired_at": "2026-10-11T07:00:21Z", + "last_outcome": "run_created", + "created_at": "2026-10-11T09:02:00Z", + "updated_at": "2026-10-11T09:02:00Z" + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `automations:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } + } + } + } + } + } + }, + "404": { + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/triggers \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/automations/{id}/deliveries": { + "get": { + "operationId": "listAutomationDeliveries", + "tags": [ + "Automations" + ], + "summary": "List webhook deliveries", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "The webhook deliveries of an automation in the last 30 days, newest first, and what each one did. Only final outcomes are kept: deliveries refused for their signature are never stored, and throttled ones (`429`, `503`) are not listed because the sender retries them.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The automation id. A malformed id answers `404`." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "description": "Deliveries to return, 1 to 100." + } + ], + "responses": { + "200": { + "description": "The deliveries.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookDeliveryList" + }, + "example": { + "object": "list", + "data": [ + { + "trigger_id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", + "delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958", + "received_at": "2026-10-11T09:00:00Z", + "outcome": "run_created", + "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c" + } + ] + } + } + } + }, + "400": { + "description": "`limit` out of range.", + "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": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `automations:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } + } + } + } + } + } + }, + "404": { + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl \"https://api.nan.builders/v1/automations/$AUTOMATION_ID/deliveries?limit=20\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/automations/{id}/runs": { + "post": { + "operationId": "runAutomation", + "tags": [ + "Automations" + ], + "summary": "Run an automation", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "Starts a run of the automation now, with its saved instructions, secrets and approval settings: the same run a webhook or a schedule would start. Its agent proposes actions, which apply automatically or wait for your approval as the automation says. Follow it like any run (`GET /v1/runs/{id}/events`). Runs you start this way are never skipped by `skip_if_active`, but they count towards the automation's `max_runs_per_hour` and the limits of every run.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:run` scope. Runs need a plan with inference.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The automation id. A malformed id answers `404`." + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "description": "Optional. A retry with the same key and the same input returns the run already created (`200`) instead of a second one. Keys starting with `sch:` or `whk:` are reserved (`400`)." + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunAutomationRequest" + }, + "example": { + "input": { + "pr": 42, + "note": "only the auth changes" + } + } + } + } + }, + "responses": { + "201": { + "description": "The run was created.", + "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": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "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-11T09:00:00Z", + "started_at": null, + "finished_at": null, + "phase": "propose", + "inference": { + "source": "nan" + }, + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "parent_run_id": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "github_repo": "acme/api", + "trigger_depth": 0, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "agent_finished_at": null + } + } + } + }, + "200": { + "description": "A retry with the same `Idempotency-Key`: the run created the first time.", + "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": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "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-11T09:00:00Z", + "started_at": "2026-10-11T09:00:02Z", + "finished_at": null, + "phase": "propose", + "inference": { + "source": "nan" + }, + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "parent_run_id": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "github_repo": "acme/api", + "trigger_depth": 0, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "agent_finished_at": null + } + } + } + }, + "400": { + "description": "Invalid body: `input` not a JSON object, too large (48 KiB) or nested too deep (8 levels); unknown fields; a bad `Idempotency-Key`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "must be a JSON object of at most 48 KiB nested at most 8 levels deep", + "type": "invalid_request_error", + "param": "input", + "code": "invalid_request" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations:run` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations. A membership without runs: `tier_restricted`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `automations:run` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations:run\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } + } + }, + "tier_restricted": { + "summary": "A membership without inference.", + "value": { + "error": { + "message": "your membership does not include agent runs", + "type": "permission_error", + "param": null, + "code": "tier_restricted" + } + } + } + } + } + } + }, + "404": { + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The run cannot start: the automation is disabled (`automation_disabled`), its workspace is not running (`workspace_not_running`), the agent is not installed (`agent_not_installed`), the workspace has no inference key (`no_inference_key`), or the `Idempotency-Key` was used with a different input (`idempotency_conflict`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "automation_disabled": { + "summary": "Disabled automation.", + "value": { + "error": { + "message": "the automation is disabled", + "type": "invalid_request_error", + "param": null, + "code": "automation_disabled" + } + } + }, + "workspace_not_running": { + "summary": "Stopped workspace.", + "value": { + "error": { + "message": "the workspace is not running", + "type": "invalid_request_error", + "param": null, + "code": "workspace_not_running" + } + } + } + } + } + } + }, + "429": { + "description": "The automation reached its runs per hour (`automation_rate_limited`), or the limits of every run: the queue (`run_queue_full`) or 60 new runs per hour (`rate_limited`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "automation_rate_limited": { + "summary": "Runs per hour of the automation.", + "value": { + "error": { + "message": "the automation reached its run budget for the last hour", + "type": "invalid_request_error", + "param": null, + "code": "automation_rate_limited" + } + } + }, + "run_queue_full": { + "summary": "Queue full.", + "value": { + "error": { + "message": "too many queued runs", + "type": "invalid_request_error", + "param": null, + "code": "run_queue_full" + } + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"input\": {\"pr\": 42}}'" + } + ] + } + }, + "/approvals": { + "get": { + "operationId": "listApprovals", + "tags": [ + "Approvals" + ], + "summary": "List approvals", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "The proposals of your automation runs, newest first. `?state=pending` lists the ones waiting for you, as the portal's **Needs approval** does.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "parameters": [ + { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "auto", + "pending", + "approved", + "rejected", + "expired", + "cancelled" + ] + }, + "description": "Only approvals in this state. All of them when omitted." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "description": "Approvals per page, 1 to 100." + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque: the `next_cursor` of the previous page." + } + ], + "responses": { + "200": { + "description": "A page of approvals.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApprovalList" + }, + "example": { + "object": "list", + "data": [ + { + "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", + "state": "pending", + "approval_from": 0, + "expires_at": "2026-10-14T09:03:41Z", + "proposal": { + "v": 1, + "actions": [ + { + "kind": "github_pr_review", + "repo": "acme/api", + "number": 42, + "event": "COMMENT", + "body": "The new branch in `login.go` skips the rate limit. Move the check above it.\n\n" + } + ] + }, + "proposal_sha256": "3f1d0c9a8b7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c", + "diffstat": null, + "has_diff": false, + "diff_masked": false, + "decided_at": null, + "prefix_apply_run_id": null, + "apply_run_id": null, + "run_state": "awaiting_approval", + "workspace_id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69" + } + ], + "next_cursor": null + } + } + } + }, + "400": { + "description": "An unknown `state` or `limit` out of range.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "unknown approval state", + "type": "invalid_request_error", + "param": "state", + "code": "invalid_request" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `runs` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "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/approvals?state=pending\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/runs/{id}/proposal.diff": { + "get": { + "operationId": "getRunProposalDiff", + "tags": [ + "Approvals" + ], + "summary": "Download a proposal's diff", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "The full diff of a proposal, as plain text (`Content-Disposition: attachment`). It exists only when your approval covers a push (`has_diff: true`). Secret values in it appear as `[masked]`.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "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 diff.", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "diff --git a/internal/auth/login.go b/internal/auth/login.go\n..." + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `runs` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "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, or no diff kept for it.", + "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 -o proposal.diff https://api.nan.builders/v1/runs/$RUN_ID/proposal.diff \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/runs/{id}/reject": { + "post": { + "operationId": "rejectRun", + "tags": [ + "Approvals" + ], + "summary": "Reject a proposal", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "Rejects the proposal of a run in `awaiting_approval`: the run ends `cancelled` with `error_code: approval_rejected`, and none of the actions that waited for approval is applied. Actions that applied automatically before it stay applied.\n\nApproving is not available here: it needs you signed in through the browser, in the portal.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The run id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Optional, for your own record." + } + } + }, + "example": { + "reason": "not now" + } + } + } + }, + "responses": { + "200": { + "description": "The run, now `cancelled`.", + "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": "webhook", + "state": "cancelled", + "queue_position": null, + "prompt_preview": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "cancel_requested": false, + "exit_code": null, + "error_code": "approval_rejected", + "result": { + "status": "succeeded", + "summary": "Proposed one review on acme/api#42.", + "branch": null, + "commit": null, + "changed_files": 0, + "artifacts": [], + "actions_applied": [] + }, + "usage": { + "tokens_in": 48210, + "tokens_out": 2304, + "source": "litellm" + }, + "last_seq": 57, + "created_at": "2026-10-11T09:00:00Z", + "started_at": "2026-10-11T09:00:02Z", + "finished_at": "2026-10-11T10:12:00Z", + "phase": "propose", + "inference": { + "source": "nan" + }, + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "parent_run_id": null, + "apply_run_ids": [], + "approval": { + "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", + "state": "rejected", + "approval_from": 0, + "expires_at": null, + "proposal": { + "v": 1, + "actions": [ + { + "kind": "github_pr_review", + "repo": "acme/api", + "number": 42, + "event": "COMMENT", + "body": "The new branch in `login.go` skips the rate limit. Move the check above it.\n\n" + } + ] + }, + "proposal_sha256": "3f1d0c9a8b7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c", + "diffstat": null, + "has_diff": false, + "diff_masked": false, + "decided_at": "2026-10-11T10:12:00Z", + "prefix_apply_run_id": null, + "apply_run_id": null + }, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "github_repo": "acme/api", + "trigger_depth": 1, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "agent_finished_at": "2026-10-11T09:03:41Z" + } + } + } + }, + "400": { + "description": "`reason` is not text, or unknown fields.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "unknown field", + "type": "invalid_request_error", + "param": "foo", + "code": "invalid_request" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `runs` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "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 is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "run not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The run is not waiting for approval.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "the run is not awaiting approval", + "type": "invalid_request_error", + "param": null, + "code": "not_awaiting_approval" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/reject \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"reason\": \"not now\"}'" + } + ] + } + }, + "/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 (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota." + } + } + } + } + } + } + }, + "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 (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota." + } + } + } + } + } + }, + "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, Automations and Approvals endpoints and on the two Workspaces endpoints (it cannot approve runs or change automations). 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: Runs (`/v1/runs...`), Automations (`/v1/automations...`), Approvals and the two Workspaces endpoints, and only where it carries the scope the endpoint names: `runs`, `workspaces:read`, `automations:read` or `automations:run`. A missing scope answers `403 token_scope_denied`. New tokens get `runs` and `workspaces:read` by default; a token with an `automations:` scope can only be created from a browser sign-in. It never works for inference: inference endpoints refuse it with `401 token_not_valid_for_inference`. It can never approve a run or change automations or secrets. 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." + }, + "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" + ] + }, + "json_schema": { + "type": "object", + "required": [ + "name", + "schema" + ], + "properties": { + "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." + }, + "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": "..." + } + } + }, + "Embedding": { + "type": "object", + "description": "A single embedding vector for one input.", + "properties": { + "object": { + "type": "string", + "enum": [ + "embedding" + ], + "description": "The object type, always `embedding`." + }, + "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." + } + } + }, + "EmbeddingList": { + "type": "object", + "description": "A list of embeddings, one per input.", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ], + "description": "Always `list`." + }, + "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": { + "prompt_tokens": { + "type": "integer", + "description": "Tokens in the input." + }, + "total_tokens": { + "type": "integer", + "description": "Total tokens used." + } + } + } + } + }, + "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", + "properties": { + "text": { + "type": "string", + "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", + "description": "Request metadata, including token accounting.", + "properties": { + "billed_units": { + "type": "object", + "properties": { + "total_tokens": { + "type": "integer" + } + } + }, + "tokens": { + "type": "object", + "properties": { + "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": "An available model, in the OpenAI-compatible model shape.", + "description": "A response produced by the Responses API.", "properties": { "id": { "type": "string", - "description": "The model's unique slug. Pass this value as the `model` field in your requests.", - "example": "deepseek-v4-flash" + "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": [ - "model" + "response" ], - "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 + "description": "The object type, always `response`." }, - "owned_by": { + "status": { "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": [ - { + "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", - "required": [ - "type", - "text" - ], + "description": "Token usage for the request.", "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] + "input_tokens": { + "type": "integer" }, - "text": { - "type": "string" + "output_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" } } + } + } + }, + "ImageResponse": { + "type": "object", + "description": "The generated image(s).", + "properties": { + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the images were created." }, - { - "type": "object", - "required": [ - "type", - "image_url" + "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`." + } + } + } + } + } + }, + "Completion": { + "type": "object", + "description": "A completion for the provided prompt (legacy).", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "enum": [ + "text_completion" ], - "properties": { - "type": { - "type": "string", - "enum": [ - "image_url" - ] - }, - "image_url": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Public URL or `data:` URI of the image." - } + "description": "Always `text_completion`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the completion was created." + }, + "model": { + "type": "string" + }, + "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" + ] } } } + }, + "usage": { + "$ref": "#/components/schemas/Usage" + } + } + }, + "UsageReport": { + "type": "object", + "description": "Your token usage for the requested window.", + "properties": { + "object": { + "type": "string", + "enum": [ + "usage.report" + ], + "description": "The object type, always `usage.report`." + }, + "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 id, as in the Model catalog." + }, + "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`." } - ] + } }, - "Message": { + "UsageTotals": { "type": "object", - "required": [ - "role" - ], - "description": "A single message in a conversation. The `role` determines which other fields apply.", + "description": "Totals over the full requested window, independent of pagination.", "properties": { - "role": { - "type": "string", - "enum": [ - "system", - "user", - "assistant", - "tool" - ], - "description": "The role of the message author: `system`, `user`, `assistant`, or `tool`." + "prompt_tokens": { + "type": "integer", + "description": "Total input tokens in the window." }, - "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" - } - } - ] + "completion_tokens": { + "type": "integer", + "description": "Total output tokens in the window." }, - "name": { - "type": "string", - "description": "An optional name for the author of this message." + "total_tokens": { + "type": "integer", + "description": "`prompt_tokens` plus `completion_tokens`." }, - "tool_calls": { + "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": { - "$ref": "#/components/schemas/ToolCall" + "$ref": "#/components/schemas/UsageModelTotals" }, - "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": { + "description": "The same totals, broken down per model." + } + } + }, + "UsageModelTotals": { + "type": "object", + "description": "Window totals for one model.", + "properties": { + "model": { "type": "string", - "description": "The id of the tool call this message answers. Required when `role` is `tool`." + "description": "The model id, as in the Model catalog." }, - "reasoning_content": { - "type": "string", - "description": "The model's reasoning trace. Present only for reasoning models; safe to display or ignore." + "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`." } } }, - "Tool": { + "UsageAllTime": { "type": "object", - "required": [ - "type", - "function" - ], - "description": "A function the model may call. Provide these in the request's `tools` array.", + "description": "Your lifetime usage across all models.", "properties": { - "type": { - "type": "string", - "enum": [ - "function" - ], - "description": "The type of the tool. Currently only `function` is supported." + "prompt_tokens": { + "type": "integer", + "description": "Total input tokens ever used." }, - "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." - }, - "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\": {}}`." - } - } + "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." } } }, - "ToolChoice": { - "description": "Controls tool use: `\"auto\"` (default), `\"none\"`, `\"required\"`, or force one with `{ \"type\": \"function\", \"function\": { \"name\": \"...\" } }`.", - "oneOf": [ - { + "PlatformError": { + "type": "object", + "description": "Error body of the Workspaces endpoints (outside `/v1`): a short `error` string, plus `message` on `session_required`.", + "properties": { + "error": { "type": "string", - "enum": [ - "auto", - "none", - "required" - ] + "description": "Short error: `unauthorized`, `invalid session`, `session_required` or `workspace not found`." }, - { - "type": "object" + "message": { + "type": "string", + "description": "Present on `session_required`: what the credential can be used for." } + }, + "required": [ + "error" ] }, - "ToolCall": { + "CreateRunRequest": { "type": "object", - "description": "A tool the model chose to call. Execute it, then send the result back in a `tool` message referencing `id`.", + "description": "Body of `POST /v1/runs`. Unknown fields are refused with `400`.", + "required": [ + "workspace", + "agent", + "prompt" + ], + "additionalProperties": false, "properties": { - "id": { + "workspace": { "type": "string", - "description": "A unique id for this tool call. Pass it as `tool_call_id` in your follow-up `tool` message." + "description": "Name or id of one of your workspaces.", + "example": "develop" }, - "type": { + "agent": { "type": "string", "enum": [ - "function" + "pi", + "hermes" ], - "description": "The type of the tool call. Always `function`." + "description": "The agent. It must be installed in the workspace, otherwise `409 agent_not_installed`." }, - "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" + "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", + "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." + }, + "model": { + "type": [ + "string", + "null" ], - "properties": { - "type": { - "type": "string", - "enum": [ - "json_object" - ] - } - } + "pattern": "^[A-Za-z0-9._:/-]{1,128}$", + "default": null, + "description": "Model for the agent. `null` uses the agent's own default." }, - { - "type": "object", - "required": [ - "type", - "json_schema" + "trigger": { + "type": "string", + "enum": [ + "api", + "cli", + "ui" ], - "properties": { - "type": { - "type": "string", - "enum": [ - "json_schema" - ] - }, - "json_schema": { - "type": "object", - "required": [ - "name", - "schema" - ], - "properties": { - "name": { - "type": "string" - }, - "strict": { - "type": "boolean" - }, - "schema": { - "type": "object" - } - } - } - } + "default": "api", + "description": "Who started the run. Informational only." } - ] + } }, - "Usage": { + "RunArtifact": { "type": "object", - "description": "Token accounting for the request.", + "description": "A file the agent left in its run's `artifacts/` folder.", "properties": { - "prompt_tokens": { - "type": "integer", - "description": "The number of tokens in the prompt (input)." + "path": { + "type": "string", + "description": "Path relative to the run folder, under `artifacts/`.", + "example": "artifacts/report.md" }, - "completion_tokens": { - "type": "integer", - "description": "The number of tokens in the generated completion (output)." + "bytes": { + "type": "integer" }, - "total_tokens": { - "type": "integer", - "description": "The total tokens used: `prompt_tokens` plus `completion_tokens`. This is what counts against your quota." + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" } } }, - "ChatCompletion": { + "RunResult": { "type": "object", - "description": "The model's response to a chat request. Represents a single, non-streamed completion.", + "description": "How the run ended, written once it is over. Text from the agent is data: render it as plain text.", "properties": { - "id": { + "status": { "type": "string", - "description": "A unique identifier for the completion." + "enum": [ + "succeeded", + "failed", + "cancelled", + "timed_out" + ] }, - "object": { + "summary": { "type": "string", - "enum": [ - "chat.completion" + "description": "The agent's final answer, up to 4 KiB." + }, + "branch": { + "type": [ + "string", + "null" ], - "description": "The object type, always `chat.completion`." + "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" }, - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the completion was created." + "commit": { + "type": [ + "string", + "null" + ], + "description": "The commit on `branch`." }, - "model": { - "type": "string", - "description": "The model that produced the completion." + "changed_files": { + "type": "integer" }, - "choices": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunArtifact" + } + }, + "actions_applied": { "type": "array", + "description": "On an `apply` run: each action applied, in order. Empty otherwise.", "items": { "type": "object", "properties": { - "index": { - "type": "integer", - "description": "The index of this choice in the `choices` array." - }, - "finish_reason": { + "kind": { "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)." + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] }, - "message": { - "$ref": "#/components/schemas/Message" + "ok": { + "type": "boolean" + }, + "ref": { + "type": "string", + "description": "What the action produced: a commit, or the link of the pull request, comment or review." } } - }, - "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": { + "Run": { "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": "A run. Every response returns this object; `prompt` (the full task), `instructions` and `input` are only in `GET /v1/runs/{id}`.", "properties": { "id": { - "type": "string" + "type": "string", + "format": "uuid" }, "object": { "type": "string", "enum": [ - "chat.completion.chunk" + "run" + ] + }, + "workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "`null` once the workspace is deleted." + } + } + }, + "agent": { + "type": "string", + "enum": [ + "pi", + "hermes" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "trigger": { + "type": "string", + "enum": [ + "api", + "cli", + "ui", + "webhook", + "schedule", + "approval", + "automation" + ], + "description": "What created the run: `api`, `cli` or `ui` (you, from the API, the CLI or the portal; a manual run of an automation is `api` or `ui`), `webhook` or `schedule` (an automation trigger), `approval` (the step that applies the actions you approved) or `automation` (the step that applies actions that need no approval)." + }, + "state": { + "type": "string", + "enum": [ + "queued", + "starting", + "running", + "awaiting_approval", + "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. An automation run whose proposal needs your approval stops in `awaiting_approval` until you approve it (`succeeded`), reject it or it expires (`cancelled`)." + }, + "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", + "secrets_unavailable", + "secret_missing", + "proposal_invalid", + "approval_rejected", + "approval_expired", + "action_failed", + "automation_deleted", + null ], - "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." + "description": "Why the run did not succeed, when there is a reason. For automation runs: `secrets_unavailable` or `secret_missing` (its secrets could not be delivered, so it did not start), `proposal_invalid` (the proposed actions were refused), `approval_rejected`, `approval_expired`, `action_failed` (an action could not be applied) and `automation_deleted` (deleted while the run was queued)." }, - "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." - } + "result": { + "oneOf": [ + { + "$ref": "#/components/schemas/RunResult" + }, + { + "type": "null" } - } - } - } - }, - "Error": { - "type": "object", - "description": "OpenAI-standard error envelope: a non-2xx HTTP status with a JSON body describing the problem.", - "properties": { - "error": { + ] + }, + "usage": { "type": "object", + "description": "Tokens the run used. Each field is `null` until it is known.", "properties": { - "message": { - "type": "string" - }, - "type": { + "tokens_in": { "type": [ - "string", + "integer", "null" ] }, - "param": { + "tokens_out": { "type": [ - "string", + "integer", "null" ] }, - "code": { + "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)." } } - } - }, - "example": { - "error": { - "message": "...", - "type": null, - "param": null, - "code": "..." - } - } - }, - "Embedding": { - "type": "object", - "description": "A single embedding vector for one input.", - "properties": { - "object": { - "type": "string", - "enum": [ - "embedding" - ], - "description": "The object type, always `embedding`." }, - "index": { + "last_seq": { "type": "integer", - "description": "The position of this embedding in the request `input` array." + "description": "`seq` of the last stored event." }, - "embedding": { - "type": "array", - "items": { - "type": "number" - }, - "description": "The embedding vector: a list of 4096 floats. Cosine similarity between vectors measures semantic closeness." - } - } - }, - "EmbeddingList": { - "type": "object", - "description": "A list of embeddings, one per input.", - "properties": { - "object": { + "created_at": { "type": "string", - "enum": [ - "list" + "format": "date-time" + }, + "started_at": { + "type": [ + "string", + "null" ], - "description": "Always `list`." + "format": "date-time" }, - "model": { - "type": "string", - "description": "The model used to create the embeddings." + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Embedding" - }, - "description": "The embeddings, in the same order as the request `input`." + "phase": { + "type": "string", + "enum": [ + "run", + "propose", + "apply" + ], + "description": "`run`: a run you start with `POST /v1/runs`. `propose`: an automation run, whose agent proposes actions instead of acting. `apply`: the step that applies a proposal's actions, with no agent." }, - "usage": { + "inference": { "type": "object", - "description": "Token usage for the request.", + "description": "Where the run's inference comes from. Always NaN.", "properties": { - "prompt_tokens": { - "type": "integer", - "description": "Tokens in the input." - }, - "total_tokens": { - "type": "integer", - "description": "Total tokens used." + "source": { + "type": "string", + "enum": [ + "nan" + ] } } - } - } - }, - "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." + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The automation that created the run. `null` for runs you start with `POST /v1/runs`, and once the automation is deleted." }, - "document": { - "type": "object", - "properties": { - "text": { - "type": "string", - "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." + "parent_run_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "On an `apply` run: the automation run whose proposal it applies." }, - "results": { + "apply_run_ids": { "type": "array", "items": { - "$ref": "#/components/schemas/RerankResult" + "type": "string", + "format": "uuid" }, - "description": "The documents, sorted by descending `relevance_score`. Each carries its original `index`." + "description": "On an automation run: the `apply` runs that applied its actions, in order." }, - "meta": { - "type": "object", - "description": "Request metadata, including token accounting.", - "properties": { - "billed_units": { - "type": "object", - "properties": { - "total_tokens": { - "type": "integer" - } - } + "approval": { + "oneOf": [ + { + "$ref": "#/components/schemas/Approval" }, - "tokens": { - "type": "object", - "properties": { - "input_tokens": { - "type": "integer" - } - } + { + "type": "null" } - } - } - } - }, - "Transcription": { - "type": "object", - "description": "A transcription of the supplied audio.", - "properties": { - "text": { - "type": "string", - "description": "The full transcribed text." + ], + "description": "On an automation run that proposed actions: the proposal and where its approval stands. `null` otherwise." }, - "language": { + "instructions": { "type": "string", - "description": "The detected (or supplied) language, as an ISO-639-1 code." + "description": "The automation's instructions the run used. Only in `GET /v1/runs/{id}` of an automation run." }, - "task": { - "type": "string", - "description": "The task performed, always `transcribe`." + "input": { + "oneOf": [ + { + "$ref": "#/components/schemas/RunInput" + }, + { + "type": "null" + } + ], + "description": "The event or input that started an automation run. Only in `GET /v1/runs/{id}`." }, - "duration": { - "type": "number", - "description": "The audio duration in seconds. Present with `verbose_json`." + "approval_kinds": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + }, + "description": "On an automation run: the action kinds that needed approval when it started. `null` otherwise." }, - "segments": { + "github_repo": { + "type": [ + "string", + "null" + ], + "description": "On an automation run: the repository its actions act on." + }, + "trigger_depth": { + "type": "integer", + "description": "0 for a run you start. 1 for a webhook or schedule run, one more for each run caused by another run (loop protection)." + }, + "secrets": { "type": "array", - "description": "Time-aligned segments. Present with `verbose_json`.", + "description": "The secrets the run receives: variable names and who gets them, never values.", "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": { + "env": { "type": "string" }, - "avg_logprob": { - "type": "number" - }, - "no_speech_prob": { - "type": "number" + "exposure": { + "type": "string", + "enum": [ + "agent", + "actions" + ] } } } }, - "words": { + "agent_finished_at": { "type": [ - "array", + "string", "null" ], - "description": "Per-word timestamps `[{ word, start, end, probability }]`. Populated only when `timestamp_granularities[]=word`." + "format": "date-time", + "description": "When the agent of an automation run finished, before any approval." } } }, - "ResponseObject": { + "RunList": { "type": "object", - "description": "A response produced by the Responses API.", "properties": { - "id": { + "object": { "type": "string", - "description": "A unique identifier for the response." - }, - "created_at": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the response was created." + "enum": [ + "list" + ] }, - "model": { - "type": "string", - "description": "The model that produced the response." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Run" + }, + "description": "Newest first. `prompt` is not included: use `prompt_preview`." }, - "object": { + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass it as `cursor` for the next page. `null` on the last page." + } + } + }, + "RunEventDataStarted": { + "type": "object", + "description": "`data` of a `started` event. The run started.", + "properties": { + "agent": { "type": "string", "enum": [ - "response" - ], - "description": "The object type, always `response`." + "pi", + "hermes" + ] }, - "status": { + "agent_version": { + "type": "string" + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "cwd": { "type": "string", - "description": "The response status, e.g. `completed`." + "description": "Where the agent works (inside the worktree when there is one)." }, - "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" - } + "worktree": { + "type": [ + "string", + "null" + ], + "description": "The separate worktree, or `null` when the agent works in the folder." }, - "usage": { - "type": "object", - "description": "Token usage for the request.", - "properties": { - "input_tokens": { - "type": "integer" - }, - "output_tokens": { - "type": "integer" - }, - "total_tokens": { - "type": "integer" - } - } + "branch": { + "type": [ + "string", + "null" + ], + "description": "`nan-run/...` when there is a worktree, otherwise `null`." + }, + "unit": { + "type": "string" } } }, - "ImageResponse": { + "RunEventDataLog": { "type": "object", - "description": "The generated image(s).", + "description": "`data` of a `log` event. A line of output that is not a message.", "properties": { - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the images were created." + "stream": { + "type": "string", + "enum": [ + "agent_stdout", + "agent_stderr", + "nan-run" + ] }, - "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`." - } - } - } + "text": { + "type": "string", + "description": "Up to 8 KiB." } } }, - "Completion": { + "RunEventDataMessage": { "type": "object", - "description": "A completion for the provided prompt (legacy).", + "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": { - "id": { - "type": "string" - }, - "object": { + "role": { "type": "string", "enum": [ - "text_completion" - ], - "description": "Always `text_completion`." - }, - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the completion was created." + "assistant" + ] }, - "model": { - "type": "string" + "text": { + "type": "string", + "description": "This chunk only, up to 16 KiB." }, - "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" - ] - } - } - } + "final": { + "type": "boolean" + } + } + }, + "RunEventDataToolCall": { + "type": "object", + "description": "`data` of a `tool_call` event. The agent called a tool.", + "properties": { + "id": { + "type": "string" }, - "usage": { - "$ref": "#/components/schemas/Usage" + "name": { + "type": "string" + }, + "args_preview": { + "type": "string", + "description": "Arguments, up to 2 KiB." } } }, - "UsageReport": { + "RunEventDataToolResult": { "type": "object", - "description": "Your token usage for the requested window.", + "description": "`data` of a `tool_result` event. What a tool answered. `id` matches its `tool_call`.", "properties": { - "object": { - "type": "string", - "enum": [ - "usage.report" - ], - "description": "The object type, always `usage.report`." - }, - "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." + "id": { + "type": "string" }, - "all_time": { - "$ref": "#/components/schemas/UsageAllTime", - "description": "Your lifetime usage, always present." + "name": { + "type": "string" }, - "has_more": { - "type": "boolean", - "description": "Whether more pages follow. When `true`, pass `next_cursor` back as the `cursor` parameter." + "is_error": { + "type": "boolean" }, - "next_cursor": { + "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" - ], - "description": "The cursor for the next page, or `null` on the last page." + ] + }, + "prompt_tokens": { + "type": "integer" + }, + "completion_tokens": { + "type": "integer" } } }, - "UsageRow": { + "RunEventDataError": { "type": "object", - "description": "Usage for one model on one day.", + "description": "`data` of an `error` event. Something went wrong.", "properties": { - "date": { + "code": { "type": "string", - "description": "The UTC day this row covers, `YYYY-MM-DD`." + "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" + ] }, - "model": { + "message": { "type": "string", - "description": "The model id, as in the Model catalog." - }, - "prompt_tokens": { - "type": "integer", - "description": "Input tokens used that day on that model." + "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" + ] }, - "completion_tokens": { - "type": "integer", - "description": "Output tokens generated that day on that model." + "exit_code": { + "type": "integer" }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." + "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", + "secrets_unavailable", + "secret_missing", + "proposal_invalid", + "approval_rejected", + "approval_expired", + "action_failed", + "automation_deleted", + null + ] }, - "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`." + "summary": { + "type": "string" } } }, - "UsageTotals": { + "RunEvent": { "type": "object", - "description": "Totals over the full requested window, independent of pagination.", + "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": { - "prompt_tokens": { - "type": "integer", - "description": "Total input tokens in the window." - }, - "completion_tokens": { + "seq": { "type": "integer", - "description": "Total output tokens in the window." + "description": "1, 2, 3... in order, per run. Use it to resume (`after`, `Last-Event-ID`)." }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." + "ts": { + "type": "string", + "format": "date-time" }, - "api_requests": { - "type": "integer", - "description": "Total requests in the window. Request counts are only available from 2026-09-02 onward; older days report `0`." + "type": { + "type": "string", + "enum": [ + "started", + "log", + "message", + "tool_call", + "tool_result", + "usage", + "artifact", + "error", + "truncated", + "finished", + "approval_required", + "action" + ], + "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.\n- `approval_required`: An automation run proposed actions that wait for your approval.\n- `action`: One step of an `apply` run." }, - "by_model": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UsageModelTotals" - }, - "description": "The same totals, broken down per model." + "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" + }, + { + "$ref": "#/components/schemas/RunEventDataApprovalRequired" + }, + { + "$ref": "#/components/schemas/RunEventDataAction" + } + ], + "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 ./...\"}" } } }, - "UsageModelTotals": { + "RunEventPage": { "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." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunEvent" + } }, - "total_tokens": { + "next_after": { "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." + "description": "The last `seq` returned (or your `after` when the page is empty). Pass it as `after` for the next page." }, - "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`." + "done": { + "type": "boolean", + "description": "`true` when the run is over and every event has been returned." } } }, - "UsageAllTime": { + "WorkspaceSlot": { "type": "object", - "description": "Your lifetime usage across all models.", + "description": "The purchased slot paying for the workspace.", "properties": { - "prompt_tokens": { - "type": "integer", - "description": "Total input tokens ever used." + "id": { + "type": "integer" }, - "completion_tokens": { - "type": "integer", - "description": "Total output tokens ever generated." + "tier": { + "type": "string" }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." + "status": { + "type": "string" }, - "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`." + "cancel_at_period_end": { + "type": "boolean" }, - "cached_at": { - "type": "string", - "description": "When the all-time summary was last computed, as an ISO 8601 UTC timestamp." + "current_period_end": { + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, - "PlatformError": { + "Workspace": { "type": "object", - "description": "Error body of the Workspaces endpoints (outside `/v1`): a short `error` string, plus `message` on `session_required`.", + "description": "One of your workspaces. Never carries keys, environment values or agent configuration.", "properties": { - "error": { + "workspace_uuid": { "type": "string", - "description": "Short error: `unauthorized`, `invalid session`, `session_required` or `workspace not found`." + "format": "uuid", + "description": "The workspace id. Accepted by `workspace` in `POST /v1/runs`." }, - "message": { + "name": { "type": "string", - "description": "Present on `session_required`: what the credential can be used for." - } - }, - "required": [ - "error" - ] - }, - "CreateRunRequest": { - "type": "object", - "description": "Body of `POST /v1/runs`. Unknown fields are refused with `400`.", - "required": [ - "workspace", - "agent", - "prompt" - ], - "additionalProperties": false, - "properties": { - "workspace": { + "description": "Unique among your workspaces. Accepted by `workspace` in `POST /v1/runs`." + }, + "agent_selection": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agents and tools chosen for the workspace." + }, + "status": { "type": "string", - "description": "Name or id of one of your workspaces.", - "example": "develop" + "enum": [ + "provisioning", + "running", + "stopped", + "crashed", + "unknown" + ], + "description": "Runs can only be created while it is `running`." }, - "agent": { + "ssh_command": { + "type": "string", + "description": "The command to connect over SSH." + }, + "vcpus": { + "type": "integer" + }, + "memory_mib": { + "type": "integer" + }, + "disk_gib": { + "type": "integer" + }, + "base_image_ver": { + "type": "string" + }, + "tier": { "type": "string", "enum": [ - "pi", - "hermes" + "free", + "micro", + "nano", + "basic", + "medium", + "large" ], - "description": "The agent. It must be installed in the workspace, otherwise `409 agent_not_installed`." + "description": "Size. `free` is the Micro-sized workspace included with Premium." }, - "prompt": { + "created_at": { "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" + "format": "date-time" }, - "cwd": { + "updated_at": { "type": "string", - "default": "/home/nan", - "maxLength": 512, - "description": "Working directory: an absolute path inside `/home/nan`, without `.` or `..` segments. Called Folder in the portal." + "format": "date-time" }, - "git_isolation": { + "state_reason": { "type": [ - "boolean", + "string", "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", - "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." + "enum": [ + "dunning", + "canceled", + "disputed", + "churn", + "banned", + "pending_capacity", + "rescue", + null + ], + "description": "Why the workspace is not running normally, or `null`." }, - "model": { + "grace_until": { "type": [ "string", "null" ], - "pattern": "^[A-Za-z0-9._:/-]{1,128}$", - "default": null, - "description": "Model for the agent. `null` uses the agent's own default." + "format": "date-time", + "description": "When an open grace period ends (the data is deleted then), or `null`." }, - "trigger": { - "type": "string", - "enum": [ - "api", - "cli", - "ui" + "slot": { + "oneOf": [ + { + "$ref": "#/components/schemas/WorkspaceSlot" + }, + { + "type": "null" + } ], - "default": "api", - "description": "Who started the run. Informational only." + "description": "`null` for the free workspace." + }, + "inference_attached": { + "type": "boolean", + "description": "Whether the workspace has an inference key (never the key itself). Runs need it." + }, + "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" + ] + } + } + }, + "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." + } + } } } }, - "RunArtifact": { + "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." + } + } + } + ] + }, + "RunInput": { "type": "object", - "description": "A file the agent left in its run's `artifacts/` folder.", + "description": "The untrusted input of an automation run. The agent receives it as data next to the automation's instructions, never as instructions. Render it as plain text.", "properties": { - "path": { + "untrusted": { + "type": "boolean", + "enum": [ + true + ] + }, + "source": { "type": "string", - "description": "Path relative to the run folder, under `artifacts/`.", - "example": "artifacts/report.md" + "enum": [ + "api", + "webhook:github", + "webhook:stripe", + "webhook:nan", + "schedule" + ] }, - "bytes": { - "type": "integer" + "event": { + "type": "string", + "description": "The event name, for a webhook." }, - "sha256": { + "delivery": { "type": "string", - "pattern": "^[0-9a-f]{64}$" + "description": "The delivery id, for a webhook." + }, + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The input itself: a JSON object." } } }, - "RunResult": { + "ProposalAction": { "type": "object", - "description": "How the run ended, written once it is over. Text from the agent is data: render it as plain text.", + "description": "One proposed action. Which fields are present depends on `kind`. Every text was masked before it was stored: secret values appear as `[masked]`. Render every text as plain text.", + "required": [ + "kind" + ], "properties": { - "status": { + "kind": { "type": "string", "enum": [ - "succeeded", - "failed", - "cancelled", - "timed_out" + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" ] }, - "summary": { + "repo": { "type": "string", - "description": "The agent's final answer, up to 4 KiB." + "description": "`owner/name`: always the automation's `github_repo`." + }, + "base": { + "type": "string", + "description": "`git_push`, `github_create_pr`: the base branch." }, "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" + "type": "string", + "description": "`git_push`: the branch pushed to." }, "commit": { - "type": [ - "string", - "null" + "type": "string", + "description": "`git_push`: the agent's commit." + }, + "commit_message": { + "type": "string", + "description": "`git_push`: the message of the pushed commit." + }, + "force": { + "type": "boolean", + "description": "`git_push`: a force push." + }, + "head": { + "type": "string", + "description": "`github_create_pr`: the branch to merge." + }, + "title": { + "type": "string", + "description": "`github_create_pr`: the title." + }, + "draft": { + "type": "boolean", + "description": "`github_create_pr`: open it as a draft." + }, + "number": { + "type": "integer", + "description": "The pull request or issue number." + }, + "event": { + "type": "string", + "enum": [ + "COMMENT", + "APPROVE", + "REQUEST_CHANGES" ], - "description": "The commit on `branch`." + "description": "`github_pr_review`: the review." }, - "changed_files": { - "type": "integer" + "body": { + "type": "string", + "description": "The text of a pull request, comment or review." }, - "artifacts": { + "labels": { "type": "array", "items": { - "$ref": "#/components/schemas/RunArtifact" - } + "type": "string" + }, + "description": "`github_add_labels`: up to 10." } } }, - "Run": { + "Diffstat": { "type": "object", - "description": "A run. Every response returns this object; `prompt` (the full task) is only in `GET /v1/runs/{id}`.", + "description": "The files a push changes.", "properties": { - "id": { - "type": "string", - "format": "uuid" + "files_changed": { + "type": "integer" }, - "object": { - "type": "string", - "enum": [ - "run" - ] + "insertions": { + "type": "integer" }, - "workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": [ - "string", - "null" - ], - "description": "`null` once the workspace is deleted." + "deletions": { + "type": "integer" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer" + }, + "deleted": { + "type": "integer" + }, + "binary": { + "type": "boolean" + } } } }, - "agent": { - "type": "string", + "truncated": { + "type": "boolean", + "description": "Only the first files are listed." + } + } + }, + "Proposal": { + "type": "object", + "description": "What an automation run proposed: up to 10 actions. Immutable once stored.", + "properties": { + "v": { + "type": "integer", "enum": [ - "pi", - "hermes" + 1 ] }, - "model": { - "type": [ - "string", - "null" - ] + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProposalAction" + } }, - "trigger": { + "base": { "type": "string", - "enum": [ - "api", - "cli", - "ui" - ] + "description": "With a push: the base branch." }, - "state": { + "merge_base": { "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." + "description": "With a push: the commit the pushed commit is built on." }, - "queue_position": { - "type": [ - "integer", - "null" - ], - "description": "1-based position while `queued`, otherwise `null`." + "bundle_sha256": { + "type": "string" }, - "prompt_preview": { + "diff_sha256": { "type": "string", - "description": "The first 200 characters of the task." + "description": "With a push: the hash of the diff. What is pushed must match it." }, - "prompt": { + "diffstat": { + "$ref": "#/components/schemas/Diffstat" + } + } + }, + "Approval": { + "type": "object", + "description": "The proposal of an automation run and where its approval stands. Actions before `approval_from` apply automatically; from `approval_from` on they wait for your approval.", + "properties": { + "run_id": { "type": "string", - "description": "The full task. Only in `GET /v1/runs/{id}`." - }, - "cwd": { - "type": "string" - }, - "git_isolation": { - "type": [ - "boolean", - "null" - ] - }, - "timeout_seconds": { - "type": "integer" + "format": "uuid" }, - "cancel_requested": { - "type": "boolean" + "state": { + "type": "string", + "enum": [ + "auto", + "pending", + "approved", + "rejected", + "expired", + "cancelled" + ], + "description": "`auto`: no action needed approval, they applied automatically. `pending`: waiting for you. `approved`, `rejected`, `expired`: what happened to it. `cancelled`: dropped without a decision (the automatic actions before it failed, the run was cancelled, or your membership no longer includes runs)." }, - "exit_code": { + "approval_from": { "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" - } - ] + "description": "Index of the first action that needs approval. `null` when none does." }, - "usage": { - "type": "object", - "description": "Tokens the run used. Each field is `null` until it is known.", - "properties": { - "tokens_in": { - "type": [ - "integer", - "null" - ] - }, - "tokens_out": { - "type": [ - "integer", - "null" - ] + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When a pending approval expires." + }, + "proposal": { + "$ref": "#/components/schemas/Proposal" + }, + "proposal_sha256": { + "type": "string", + "description": "The hash of the stored proposal. Approving in the portal binds to it." + }, + "diffstat": { + "oneOf": [ + { + "$ref": "#/components/schemas/Diffstat" }, - "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)." + { + "type": "null" } - } + ] }, - "last_seq": { - "type": "integer", - "description": "`seq` of the last stored event." + "has_diff": { + "type": "boolean", + "description": "A full diff is kept (`GET /v1/runs/{id}/proposal.diff`). Only when your approval covers a push." }, - "created_at": { - "type": "string", - "format": "date-time" + "diff_masked": { + "type": "boolean", + "description": "The kept diff contains the value of one of your secrets, shown as `[masked]`. Approving pushes the real value." }, - "started_at": { + "decided_at": { "type": [ "string", "null" ], "format": "date-time" }, - "finished_at": { + "prefix_apply_run_id": { "type": [ "string", "null" ], - "format": "date-time" + "format": "uuid", + "description": "The `apply` run of the actions before `approval_from`." + }, + "apply_run_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The `apply` run of the approved actions." } } }, - "RunList": { + "ApprovalListItem": { + "allOf": [ + { + "$ref": "#/components/schemas/Approval" + }, + { + "type": "object", + "properties": { + "run_state": { + "type": "string", + "description": "The state of the run." + }, + "workspace_id": { + "type": "string", + "format": "uuid" + }, + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + } + ] + }, + "ApprovalList": { "type": "object", "properties": { "object": { @@ -4050,9 +6690,9 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Run" + "$ref": "#/components/schemas/ApprovalListItem" }, - "description": "Newest first. `prompt` is not included: use `prompt_preview`." + "description": "Newest first." }, "next_cursor": { "type": [ @@ -4063,533 +6703,623 @@ } } }, - "RunEventDataStarted": { + "Automation": { "type": "object", - "description": "`data` of a `started` event. The run started.", + "description": "A saved agent task. Created and edited in the portal. Never carries a secret value.", "properties": { - "agent": { + "id": { "type": "string", - "enum": [ - "pi", - "hermes" - ] - }, - "agent_version": { - "type": "string" + "format": "uuid" }, - "model": { - "type": [ - "string", - "null" + "object": { + "type": "string", + "enum": [ + "automation" ] }, - "cwd": { + "name": { "type": "string", - "description": "Where the agent works (inside the worktree when there is one)." - }, - "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`." + "description": "Up to 64 characters, unique among yours." }, - "unit": { + "description": { "type": "string" - } - } - }, - "RunEventDataLog": { - "type": "object", - "description": "`data` of a `log` event. A line of output that is not a message.", - "properties": { - "stream": { + }, + "template": { "type": "string", "enum": [ - "agent_stdout", - "agent_stderr", - "nan-run" + "custom", + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" ] }, - "text": { + "workspace": { "type": "string", - "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": { + "format": "uuid", + "description": "The workspace id." + }, + "agent": { "type": "string", "enum": [ - "assistant" + "pi", + "hermes" ] }, - "text": { + "model": { + "type": [ + "string", + "null" + ] + }, + "instructions": { "type": "string", - "description": "This chunk only, up to 16 KiB." + "description": "Up to 16 KiB. Trusted: the agent follows them." }, - "final": { - "type": "boolean" - } - } - }, - "RunEventDataToolCall": { - "type": "object", - "description": "`data` of a `tool_call` event. The agent called a tool.", - "properties": { - "id": { + "cwd": { "type": "string" }, - "name": { - "type": "string" + "git_isolation": { + "type": [ + "boolean", + "null" + ], + "description": "Separate branch: `true` on, `false` off, `null` auto." }, - "args_preview": { - "type": "string", - "description": "Arguments, up to 2 KiB." - } - } - }, - "RunEventDataToolResult": { - "type": "object", - "description": "`data` of a `tool_result` event. What a tool answered. `id` matches its `tool_call`.", - "properties": { - "id": { - "type": "string" + "timeout_seconds": { + "type": "integer", + "minimum": 60, + "maximum": 7200 }, - "name": { - "type": "string" + "approval_kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + }, + "description": "The action kinds that wait for your approval. Empty: every action applies automatically." }, - "is_error": { - "type": "boolean" + "approval_ttl_seconds": { + "type": "integer", + "minimum": 3600, + "maximum": 604800, + "description": "How long a proposal waits for you. 259200 (72 hours) by default." }, - "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": { + "inference": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "nan" + ] + } + } + }, + "github_repo": { "type": [ "string", "null" - ] + ], + "description": "`owner/name`: the repository the actions act on." }, - "prompt_tokens": { - "type": "integer" + "secrets": { + "type": "array", + "description": "The secrets its runs receive: names, variable names and who gets them. Never values.", + "items": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of your secret." + }, + "env": { + "type": "string", + "description": "The variable name the run sees." + }, + "exposure": { + "type": "string", + "enum": [ + "agent", + "actions" + ], + "description": "`agent`: the agent receives it. `actions`: only the step that applies actions." + } + } + } }, - "completion_tokens": { - "type": "integer" - } - } - }, - "RunEventDataError": { - "type": "object", - "description": "`data` of an `error` event. Something went wrong.", - "properties": { - "code": { + "max_runs_per_hour": { + "type": "integer", + "minimum": 1, + "maximum": 120 + }, + "concurrency_policy": { "type": "string", "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" + "queue", + "skip_if_active" + ], + "description": "`skip_if_active`: a new event for the same pull request, issue, workflow or schedule is skipped while a run for it is active, including one awaiting approval." + }, + "max_trigger_depth": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "enabled": { + "type": "boolean" + }, + "disabled_reason": { + "type": [ + "string", + "null" + ], + "enum": [ + "member", + "workspace_deleted", + "secret_deleted", + null ] }, - "message": { + "created_at": { "type": "string", - "description": "Up to 2 KiB." + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" } } }, - "RunEventDataTruncated": { + "AutomationList": { "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": { + "object": { "type": "string", "enum": [ - "event_cap", - "size_cap" + "list" ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Automation" + } } } }, - "RunEventDataFinished": { + "AutomationTemplate": { "type": "object", - "description": "`data` of a `finished` event. The run ended. Always the last event.", + "description": "A template: the defaults the portal fills in when you create an automation from it.", "properties": { - "status": { + "id": { "type": "string", "enum": [ - "succeeded", - "failed", - "cancelled", - "timed_out" + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" ] }, - "exit_code": { - "type": "integer" + "name": { + "type": "string" }, - "error_code": { + "description": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "approval_kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + } + }, + "trigger_kind": { + "type": "string", + "enum": [ + "webhook", + "schedule" + ] + }, + "signature_scheme": { "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", + "github", + "stripe", + "nan", null ] }, - "summary": { - "type": "string" + "event_filter": { + "oneOf": [ + { + "$ref": "#/components/schemas/EventFilter" + }, + { + "type": "null" + } + ] + }, + "secret_slots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "env": { + "type": "string" + }, + "exposure": { + "type": "string", + "enum": [ + "agent", + "actions" + ] + }, + "optional": { + "type": "boolean" + }, + "help": { + "type": "string" + } + } + } + }, + "requires_github_repo": { + "type": "boolean" + }, + "suggested_concurrency": { + "type": "string", + "enum": [ + "queue", + "skip_if_active" + ] } } }, - "RunEvent": { + "EventFilter": { "type": "object", - "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 `***`.", + "description": "Which webhook deliveries start a run. Every list you fill must match; empty lists match everything.", "properties": { - "seq": { - "type": "integer", - "description": "1, 2, 3... in order, per run. Use it to resume (`after`, `Last-Event-ID`)." + "events": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Event names (GitHub `X-GitHub-Event`, Stripe event type, NaN `X-Nan-Event`). An entry ending in `.*` matches a prefix." }, - "ts": { + "actions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "GitHub `action`." + }, + "exclude_drafts": { + "type": "boolean", + "description": "Ignore draft pull requests." + }, + "conclusions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "GitHub `workflow_run` conclusion." + } + } + }, + "Trigger": { + "type": "object", + "description": "What starts an automation: a webhook or a schedule. The webhook URL and its signing secret are never returned after they are created or rotated.", + "properties": { + "id": { "type": "string", - "format": "date-time" + "format": "uuid" }, - "type": { + "object": { "type": "string", "enum": [ - "started", - "log", - "message", - "tool_call", - "tool_result", - "usage", - "artifact", - "error", - "truncated", - "finished" + "trigger" + ] + }, + "automation_id": { + "type": "string", + "format": "uuid" + }, + "kind": { + "type": "string", + "enum": [ + "webhook", + "schedule" + ] + }, + "enabled": { + "type": "boolean", + "description": "`false`: paused." + }, + "token_prefix": { + "type": [ + "string", + "null" ], - "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." + "description": "Webhook: the start of the URL token, to recognise it." }, - "data": { + "signature_scheme": { + "type": [ + "string", + "null" + ], + "enum": [ + "github", + "stripe", + "nan", + null + ] + }, + "event_filter": { "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/EventFilter" }, { - "$ref": "#/components/schemas/RunEventDataError" + "type": "null" + } + ] + }, + "actor_filter": { + "type": [ + "object", + "null" + ], + "description": "GitHub only. `null`: every sender.", + "properties": { + "logins": { + "type": "array", + "items": { + "type": "string" + } }, - { - "$ref": "#/components/schemas/RunEventDataTruncated" + "author_associations": { + "type": "array", + "items": { + "type": "string" + } }, - { - "$ref": "#/components/schemas/RunEventDataFinished" + "exclude_bots": { + "type": "boolean" } + } + }, + "prev_secret_until": { + "type": [ + "string", + "null" ], - "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 ./...\"}" + "format": "date-time", + "description": "After a rotation with grace: until when the previous signing secret still works." + }, + "cron": { + "type": [ + "string", + "null" + ] + }, + "timezone": { + "type": [ + "string", + "null" + ] + }, + "next_fire_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_fired_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_outcome": { + "type": [ + "string", + "null" + ], + "enum": [ + "run_created", + "skipped_active", + "not_accepted", + "missed", + "rate_limited", + "queue_full", + "automation_disabled", + "workspace_deleted", + "error", + null + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" } } }, - "RunEventPage": { + "TriggerList": { "type": "object", "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, "data": { "type": "array", "items": { - "$ref": "#/components/schemas/RunEvent" + "$ref": "#/components/schemas/Trigger" } - }, - "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." - }, - "done": { - "type": "boolean", - "description": "`true` when the run is over and every event has been returned." } } }, - "WorkspaceSlot": { + "WebhookDelivery": { "type": "object", - "description": "The purchased slot paying for the workspace.", + "description": "One webhook delivery. No payload and no headers are kept.", "properties": { - "id": { - "type": "integer" + "trigger_id": { + "type": "string", + "format": "uuid" }, - "tier": { - "type": "string" + "delivery_id": { + "type": "string", + "description": "The sender's delivery id (GitHub `X-GitHub-Delivery`, Stripe event id, NaN `X-Nan-Delivery`)." }, - "status": { - "type": "string" + "received_at": { + "type": "string", + "format": "date-time" }, - "cancel_at_period_end": { - "type": "boolean" + "outcome": { + "type": "string", + "enum": [ + "run_created", + "ignored", + "loop_suppressed", + "skipped_active", + "not_accepted", + "too_large" + ] }, - "current_period_end": { + "run_id": { "type": [ "string", "null" ], - "format": "date-time" + "format": "uuid" } } }, - "Workspace": { + "WebhookDeliveryList": { "type": "object", - "description": "One of your workspaces. Never carries keys, environment values or agent configuration.", "properties": { - "workspace_uuid": { - "type": "string", - "format": "uuid", - "description": "The workspace id. Accepted by `workspace` in `POST /v1/runs`." - }, - "name": { + "object": { "type": "string", - "description": "Unique among your workspaces. Accepted by `workspace` in `POST /v1/runs`." + "enum": [ + "list" + ] }, - "agent_selection": { + "data": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/WebhookDelivery" }, - "description": "Agents and tools chosen for the workspace." - }, - "status": { - "type": "string", - "enum": [ - "provisioning", - "running", - "stopped", - "crashed", - "unknown" - ], - "description": "Runs can only be created while it is `running`." - }, - "ssh_command": { - "type": "string", - "description": "The command to connect over SSH." - }, - "vcpus": { - "type": "integer" - }, - "memory_mib": { - "type": "integer" + "description": "Newest first." + } + } + }, + "RunAutomationRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "input": { + "type": "object", + "additionalProperties": true, + "description": "Optional. A JSON object of up to 48 KiB, nested at most 8 levels. The agent receives it as untrusted data next to the instructions, never as instructions. Do not put secrets in it." + } + } + }, + "RunEventDataApprovalRequired": { + "type": "object", + "description": "`data` of an `approval_required` event. The proposal needs your approval.", + "required": [ + "proposal_sha256", + "kinds" + ], + "additionalProperties": false, + "properties": { + "proposal_sha256": { + "type": "string" }, - "disk_gib": { + "kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + }, + "description": "The kinds of the actions that wait for approval." + } + } + }, + "RunEventDataAction": { + "type": "object", + "description": "`data` of an `action` event: one step of an `apply` run.", + "required": [ + "index", + "kind", + "status", + "message" + ], + "additionalProperties": false, + "properties": { + "index": { "type": "integer" }, - "base_image_ver": { - "type": "string" - }, - "tier": { + "kind": { "type": "string", "enum": [ - "free", - "micro", - "nano", - "basic", - "medium", - "large" - ], - "description": "Size. `free` is the Micro-sized workspace included with Premium." - }, - "created_at": { - "type": "string", - "format": "date-time" + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] }, - "updated_at": { + "status": { "type": "string", - "format": "date-time" - }, - "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`." - }, - "grace_until": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When an open grace period ends (the data is deleted then), or `null`." - }, - "slot": { - "oneOf": [ - { - "$ref": "#/components/schemas/WorkspaceSlot" - }, - { - "type": "null" - } - ], - "description": "`null` for the free workspace." - }, - "inference_attached": { - "type": "boolean", - "description": "Whether the workspace has an inference key (never the key itself). Runs need it." - }, - "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" - ] - } - } + "started", + "ok", + "failed" + ] }, - "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." - } - } + "message": { + "type": "string", + "description": "Plain text, masked." } } - }, - "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": { diff --git a/src/lib/openapiAutomations.test.ts b/src/lib/openapiAutomations.test.ts new file mode 100644 index 0000000..66f59dd --- /dev/null +++ b/src/lib/openapiAutomations.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import spec from '../data/openapi.json'; +import { resolveSpec } from './apiDoc'; +import { DEFAULT_RATE_LIMITS } from './rateLimits'; +import { openapiToText } from './openapiToText'; +import { validate, type Schema } from '../tests/helpers/openapiValidate'; + +/** + * The Automations and Approvals surface of the API reference (Agent + * Automations Phase 2). + * + * Shapes come from cloud-api origin/main (8a42a20): handlers/automations.go, + * automation_triggers.go, approvals.go and runs.go (routes, bodies, statuses), + * automations/api.go and runs/api.go (limits, outcomes, error codes), + * middleware/pat_scopes.go (the route -> scope table) and middleware/auth.go + * (the token_scope_denied and browser_session_required bodies). The edge + * Worker (devops cloudflare/api-edge-normaliser) answers inference with a + * platform token with `401 token_not_valid_for_inference`. + * + * The fixtures in src/tests/fixtures/phase2 are cloud-api's own golden + * responses (internal/handlers/testdata/phase2), copied as they are: every one + * must validate against the schema this reference publishes. + */ + +const s = spec as any; +const op = (path: string, method: string) => s.paths[path][method]; +const here = dirname(fileURLToPath(import.meta.url)); +const fixtures = resolve(here, '../tests/fixtures/phase2'); +const golden = (name: string) => JSON.parse(readFileSync(resolve(fixtures, `${name}.json`), 'utf-8')); + +/** Every documented Phase 2 operation, its tag and the platform-token scope it needs (pat_scopes.go). */ +const OPS: Array<[string, string, string, string, string]> = [ + ['/automations', 'get', 'listAutomations', 'Automations', 'automations:read'], + ['/automations/templates', 'get', 'listAutomationTemplates', 'Automations', 'automations:read'], + ['/automations/{id}', 'get', 'getAutomation', 'Automations', 'automations:read'], + ['/automations/{id}/triggers', 'get', 'listAutomationTriggers', 'Automations', 'automations:read'], + ['/automations/{id}/deliveries', 'get', 'listAutomationDeliveries', 'Automations', 'automations:read'], + ['/automations/{id}/runs', 'post', 'runAutomation', 'Automations', 'automations:run'], + ['/approvals', 'get', 'listApprovals', 'Approvals', 'runs'], + ['/runs/{id}/proposal.diff', 'get', 'getRunProposalDiff', 'Approvals', 'runs'], + ['/runs/{id}/reject', 'post', 'rejectRun', 'Approvals', 'runs'], +]; + +const ACTION_KINDS = ['git_push', 'github_create_pr', 'github_pr_comment', 'github_pr_review', 'github_issue_comment', 'github_add_labels']; + +describe('openapi.json: Automations and Approvals tags', () => { + it('sit right after Runs, before Workspaces, and link their guides', () => { + const names = s.tags.map((t: any) => t.name); + const at = names.indexOf('Runs'); + expect(names.slice(at, at + 4)).toEqual(['Runs', 'Automations', 'Approvals', 'Workspaces']); + const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description])); + expect(tags.Automations).toContain('https://nan.builders/docs/automations'); + expect(tags.Automations).toContain('https://nan.builders/docs/webhooks'); + expect(tags.Approvals).toContain('https://nan.builders/docs/automations#approve-or-reject-a-proposal'); + }); + + it('say the writes are portal-only (browser_session_required)', () => { + const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description])); + expect(tags.Automations).toMatch(/Creating, editing and deleting automations and their triggers is done in the portal/); + expect(tags.Automations).toContain('`403 browser_session_required`'); + expect(tags.Approvals).toContain('`POST /v1/runs/{id}/approve`'); + expect(tags.Approvals).toContain('`403 browser_session_required`'); + }); + + it.each(OPS)('%s %s (%s) is tagged %s and names the %s scope', (path, method, id, tag, scope) => { + const o = op(path, method); + expect(o.operationId).toBe(id); + expect(o.tags).toEqual([tag]); + expect(o.security).toEqual([{ apiKey: [] }, { platformToken: [] }]); + expect(o.description).toContain( + `a member API key (\`sk-...\`) or a platform token (\`nan_pat_...\`) with the \`${scope}\` scope`, + ); + }); + + it.each(OPS)('%s %s: 401 is the shared credentials answer, 403 the scope or workspace key', (path, method, _id, _tag, scope) => { + const r = op(path, method).responses; + expect(r['401']).toEqual({ $ref: '#/components/responses/RunsUnauthorized' }); + const ex = r['403'].content['application/json'].examples; + expect(ex.token_scope_denied.value.error).toEqual({ + message: `This platform token does not have the "${scope}" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.`, + type: 'permission_error', + param: null, + code: 'token_scope_denied', + }); + expect(ex.workspace_key_not_allowed.value.error.code).toBe('workspace_key_not_allowed'); + }); + + it('documents no write that only a browser session can make, and no secrets route', () => { + // POST/PATCH/DELETE /v1/automations*, trigger writes and approve are portal-only. + expect(s.paths['/automations'].post).toBeUndefined(); + expect(s.paths['/automations/{id}'].patch).toBeUndefined(); + expect(s.paths['/automations/{id}'].delete).toBeUndefined(); + expect(s.paths['/automations/{id}/triggers'].post).toBeUndefined(); + expect(s.paths['/runs/{id}/approve']).toBeUndefined(); + expect(Object.keys(s.paths).filter((p) => /trigger.*\/|rotate/.test(p))).toEqual([]); + expect(JSON.stringify(s.paths)).not.toContain('/api/account'); + expect(JSON.stringify(s)).not.toMatch(/api\/account\/secrets/); + }); +}); + +describe('openapi.json: the Automations contract (cloud-api)', () => { + const sc = s.components.schemas; + + it('the Automation object has the fields the handler renders, in its order', () => { + expect(Object.keys(sc.Automation.properties)).toEqual([ + 'id', 'object', 'name', 'description', 'template', 'workspace', 'agent', 'model', 'instructions', 'cwd', + 'git_isolation', 'timeout_seconds', 'approval_kinds', 'approval_ttl_seconds', 'inference', 'github_repo', + 'secrets', 'max_runs_per_hour', 'concurrency_policy', 'max_trigger_depth', 'enabled', 'disabled_reason', + 'created_at', 'updated_at', + ]); + expect(sc.Automation.properties.template.enum).toEqual(['custom', 'pr_review', 'issue_triage', 'ci_failure', 'generic_webhook']); + expect(sc.Automation.properties.approval_kinds.items.enum).toEqual(ACTION_KINDS); + expect(sc.Automation.properties.max_runs_per_hour).toMatchObject({ minimum: 1, maximum: 120 }); + expect(sc.Automation.properties.max_trigger_depth).toMatchObject({ minimum: 0, maximum: 5 }); + expect(sc.Automation.properties.approval_ttl_seconds).toMatchObject({ minimum: 3600, maximum: 604800 }); + expect(sc.Automation.properties.secrets.items.properties.exposure.enum).toEqual(['agent', 'actions']); + expect(sc.Automation.description).toMatch(/Never carries a secret value/); + }); + + it('Trigger, template and delivery objects match the handlers', () => { + expect(Object.keys(sc.Trigger.properties)).toEqual([ + 'id', 'object', 'automation_id', 'kind', 'enabled', 'token_prefix', 'signature_scheme', 'event_filter', + 'actor_filter', 'prev_secret_until', 'cron', 'timezone', 'next_fire_at', 'last_fired_at', 'last_outcome', + 'created_at', 'updated_at', + ]); + expect(sc.Trigger.properties.signature_scheme.enum).toEqual(['github', 'stripe', 'nan', null]); + // automations/api.go ScheduleOutcomes, in CHECK order. + expect(sc.Trigger.properties.last_outcome.enum).toEqual([ + 'run_created', 'skipped_active', 'not_accepted', 'missed', 'rate_limited', 'queue_full', 'automation_disabled', 'workspace_deleted', 'error', null, + ]); + expect(Object.keys(sc.EventFilter.properties)).toEqual(['events', 'actions', 'exclude_drafts', 'conclusions']); + expect(Object.keys(sc.AutomationTemplate.properties)).toEqual([ + 'id', 'name', 'description', 'instructions', 'approval_kinds', 'trigger_kind', 'signature_scheme', 'event_filter', + 'secret_slots', 'requires_github_repo', 'suggested_concurrency', + ]); + expect(Object.keys(sc.WebhookDelivery.properties)).toEqual(['trigger_id', 'delivery_id', 'received_at', 'outcome', 'run_id']); + // automations/api.go PersistedOutcomes: only these are ever listed. + expect(sc.WebhookDelivery.properties.outcome.enum).toEqual(['run_created', 'ignored', 'loop_suppressed', 'skipped_active', 'not_accepted', 'too_large']); + }); + + it('the trigger list never returns the webhook URL or the signing secret', () => { + const props = Object.keys(sc.Trigger.properties); + for (const secret of ['webhook_url', 'signing_secret', 'token', 'token_hash']) expect(props).not.toContain(secret); + expect(op('/automations/{id}/triggers', 'get').description).toMatch(/shown once, in the portal/); + }); + + it('deliveries: limit 1 to 100, default 50, 30 days, and what is never stored', () => { + const o = op('/automations/{id}/deliveries', 'get'); + const limit = o.parameters.find((p: any) => p.name === 'limit'); + expect(limit.schema).toMatchObject({ minimum: 1, maximum: 100, default: 50 }); + expect(o.description).toMatch(/last 30 days/); + expect(o.description).toMatch(/never stored/); + }); + + it('run an automation: body, Idempotency-Key, statuses and every error code', () => { + const o = op('/automations/{id}/runs', 'post'); + expect(sc.RunAutomationRequest.additionalProperties).toBe(false); + expect(Object.keys(sc.RunAutomationRequest.properties)).toEqual(['input']); + expect(sc.RunAutomationRequest.properties.input.description).toMatch(/48 KiB, nested at most 8 levels/); + const idem = o.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(idem.description).toMatch(/`sch:` or `whk:` are reserved/); + expect(Object.keys(o.responses).sort()).toEqual(['200', '201', '400', '401', '403', '404', '409', '429']); + const text = JSON.stringify(o.responses); + for (const code of [ + 'tier_restricted', 'token_scope_denied', 'workspace_key_not_allowed', 'automation_disabled', 'workspace_not_running', + 'agent_not_installed', 'no_inference_key', 'idempotency_conflict', 'automation_rate_limited', 'run_queue_full', 'rate_limited', + ]) { + expect(text, code).toContain(code); + } + expect(o.description).toMatch(/never skipped by `skip_if_active`/); + expect(o.responses['201'].content['application/json'].example.phase).toBe('propose'); + }); +}); + +describe('openapi.json: the Approvals contract (cloud-api)', () => { + const sc = s.components.schemas; + + it('the approval object (§7.6) and the list item', () => { + expect(Object.keys(sc.Approval.properties)).toEqual([ + 'run_id', 'state', 'approval_from', 'expires_at', 'proposal', 'proposal_sha256', 'diffstat', 'has_diff', 'diff_masked', + 'decided_at', 'prefix_apply_run_id', 'apply_run_id', + ]); + expect(sc.Approval.properties.state.enum).toEqual(['auto', 'pending', 'approved', 'rejected', 'expired', 'cancelled']); + expect(Object.keys(sc.ApprovalListItem.allOf[1].properties)).toEqual(['run_state', 'workspace_id', 'automation_id']); + expect(sc.ProposalAction.properties.kind.enum).toEqual(ACTION_KINDS); + expect(sc.ProposalAction.properties.event.enum).toEqual(['COMMENT', 'APPROVE', 'REQUEST_CHANGES']); + expect(sc.ProposalAction.description).toMatch(/`\[masked\]`/); + }); + + it('list: state, limit (1 to 100, default 20) and cursor', () => { + const params = Object.fromEntries(op('/approvals', 'get').parameters.map((p: any) => [p.name, p])); + expect(Object.keys(params)).toEqual(['state', 'limit', 'cursor']); + expect(params.limit.schema).toMatchObject({ minimum: 1, maximum: 100, default: 20 }); + expect(params.state.schema.enum).toEqual(sc.Approval.properties.state.enum); + }); + + it('the diff is a text/plain attachment, only when the approval covers a push', () => { + const o = op('/runs/{id}/proposal.diff', 'get'); + expect(Object.keys(o.responses['200'].content)).toEqual(['text/plain']); + expect(o.description).toMatch(/`has_diff: true`/); + expect(o.description).toContain('`[masked]`'); + }); + + it('reject: optional reason, 200 run cancelled with approval_rejected, 409 when not waiting', () => { + const o = op('/runs/{id}/reject', 'post'); + expect(o.requestBody.required).toBe(false); + expect(Object.keys(o.requestBody.content['application/json'].schema.properties)).toEqual(['reason']); + expect(Object.keys(o.responses).sort()).toEqual(['200', '400', '401', '403', '404', '409']); + const run = o.responses['200'].content['application/json'].example; + expect(run).toMatchObject({ state: 'cancelled', error_code: 'approval_rejected' }); + expect(run.approval.state).toBe('rejected'); + expect(JSON.stringify(o.responses['409'])).toContain('not_awaiting_approval'); + expect(o.description).toMatch(/Approving is not available here/); + }); + + it('events: the stream also ends when the run waits for approval', () => { + expect(op('/runs/{id}/events', 'get').description).toContain('waiting for your approval (`awaiting_approval`)'); + expect(sc.RunEventDataApprovalRequired.required).toEqual(['proposal_sha256', 'kinds']); + expect(sc.RunEventDataAction.properties.status.enum).toEqual(['started', 'ok', 'failed']); + }); +}); + +describe('openapi.json: credentials for the platform API', () => { + const desc = s.info.description as string; + + it('the overview explains scopes and the three refusals with their real bodies', () => { + for (const scope of ['`runs`', '`workspaces:read`', '`automations:read`', '`automations:run`']) expect(desc).toContain(scope); + expect(desc).toContain('### Browser sign-in'); + const blocks = [...desc.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => JSON.parse(m[1])); + const byCode = Object.fromEntries(blocks.filter((b) => b.error?.code).map((b) => [b.error.code, b.error])); + // devops edge Worker TOKEN_NOT_VALID_FOR_INFERENCE, byte for byte. + expect(byCode.token_not_valid_for_inference).toEqual({ + message: + 'Platform tokens (nan_pat_) cannot be used for inference. Use an inference API key (sk-) instead. Platform tokens only work on the platform API (/v1/runs and GET /api/workspaces).', + type: 'invalid_request_error', + param: null, + code: 'token_not_valid_for_inference', + }); + // cloud-api middleware/auth.go browserSessionRequiredMessage, OpenAI envelope under /v1. + expect(byCode.browser_session_required).toEqual({ + message: + 'This action needs a browser session: sign in at cloud.nan.builders in your browser and try again from there. CLI sessions, API keys and platform tokens cannot approve runs, change automations or secrets, or create tokens with automations scopes.', + type: 'permission_error', + param: null, + code: 'browser_session_required', + }); + expect(byCode.token_scope_denied.type).toBe('permission_error'); + }); + + it('the errors table lists the new codes', () => { + expect(desc).toMatch(/\| `401` \|[^\n]*`token_not_valid_for_inference` \|/); + expect(desc).toMatch(/\| `403` \|[^\n]*`token_scope_denied` · `browser_session_required` \|/); + }); + + it('the platform token scheme names its scopes and the inference refusal', () => { + const d = s.components.securitySchemes.platformToken.description; + expect(d).toContain('`401 token_not_valid_for_inference`'); + expect(d).toContain('`403 token_scope_denied`'); + expect(d).toMatch(/can never approve a run or change automations or secrets/); + }); +}); + +describe('cloud-api golden responses validate against the published schemas', () => { + it('copies every golden file the reference covers', () => { + expect(readdirSync(fixtures).sort()).toEqual([ + 'approvals_list.json', 'automation_deliveries.json', 'automation_get.json', 'automation_templates.json', + 'automation_triggers.json', 'automations_list.json', 'run_get_adhoc.json', 'run_get_propose.json', + 'run_list_propose.json', 'trigger_create.json', + ]); + }); + + const ref = (name: string): Schema => ({ $ref: `#/components/schemas/${name}` }); + const cases: Array<[string, unknown, Schema]> = [ + ['automations_list', golden('automations_list'), ref('AutomationList')], + ['automation_get', golden('automation_get'), ref('Automation')], + ['automation_templates', golden('automation_templates'), op('/automations/templates', 'get').responses['200'].content['application/json'].schema], + ['automation_triggers', golden('automation_triggers'), ref('TriggerList')], + ['trigger_create .trigger', golden('trigger_create').trigger, ref('Trigger')], + ['automation_deliveries', golden('automation_deliveries'), ref('WebhookDeliveryList')], + ['approvals_list', golden('approvals_list'), ref('ApprovalList')], + ['run_get_propose', golden('run_get_propose'), ref('Run')], + ['run_get_adhoc', golden('run_get_adhoc'), ref('Run')], + ['run_list_propose', golden('run_list_propose'), ref('RunList')], + ]; + + it.each(cases)('%s', (_name, value, schema) => { + expect(validate(value, schema)).toEqual([]); + }); + + it('the validator still rejects a field the schema does not declare', () => { + expect(validate({ ...golden('automation_get'), secret_value: 'x' }, ref('Automation'))).not.toEqual([]); + }); +}); + +describe('openapi.json: every Automations and Approvals example matches its schema', () => { + const cases: Array<[string, unknown, Schema]> = []; + for (const [path, method] of 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]); + } + } + } + + it('finds the examples to check', () => { + expect(cases.length).toBeGreaterThan(30); + }); + + it.each(cases)('%s', (_name, example, schema) => { + expect(validate(example, schema)).toEqual([]); + }); +}); + +describe('the text the docs API serves for the reference', () => { + it('includes the Automations and Approvals operations', () => { + const text = openapiToText(resolveSpec(DEFAULT_RATE_LIMITS)); + for (const heading of [ + '## Automations', + '## Approvals', + '### GET /automations: List automations', + '### POST /automations/{id}/runs: Run an automation', + '### GET /automations/{id}/deliveries: List webhook deliveries', + '### GET /approvals: List approvals', + '### POST /runs/{id}/reject: Reject a proposal', + ]) { + expect(text, heading).toContain(heading); + } + }); + + it('no em-dashes in what Phase 2 adds', () => { + const added = JSON.stringify([ + s.tags.filter((t: any) => ['Runs', 'Automations', 'Approvals'].includes(t.name)), + ...OPS.map(([p, m]) => op(p, m)), + s.components.securitySchemes, + s.info.description, + Object.entries(s.components.schemas).filter(([k]) => + /^(Run|Automation|Approval|Proposal|Diffstat|EventFilter|Trigger|WebhookDelivery)/.test(k), + ), + ]); + expect(added).not.toContain('—'); + }); +}); diff --git a/src/lib/openapiRuns.test.ts b/src/lib/openapiRuns.test.ts index 755f433..a8f3f18 100644 --- a/src/lib/openapiRuns.test.ts +++ b/src/lib/openapiRuns.test.ts @@ -3,6 +3,7 @@ import spec from '../data/openapi.json'; import { resolveSpec } from './apiDoc'; import { DEFAULT_RATE_LIMITS } from './rateLimits'; import { openapiToText } from './openapiToText'; +import { validate, type Schema } from '../tests/helpers/openapiValidate'; /** * The Runs and Workspaces surface of the API reference. @@ -14,85 +15,12 @@ import { openapiToText } from './openapiToText'; * (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). + * Examples are checked against their schemas with the small validator in + * src/tests/helpers/openapiValidate.ts. */ -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'], @@ -136,14 +64,14 @@ describe('openapi.json: credentials', () => { 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; + if (/^\/(runs|automations|approvals|api)(\/|$)/.test(path)) 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_...`)'); + expect(op(path, method).description).toContain('a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope'); }); it('both Workspaces operations accept an API key or a platform token, never a workspace key (cloud-api #325)', () => { @@ -224,9 +152,13 @@ describe('openapi.json: the Runs contract (cloud-api)', () => { 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(Object.keys(params)).toEqual(['workspace', 'automation', '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']); + // internal/runs/api.go States, Phase 2 adds awaiting_approval. + expect(params.state.schema.enum).toEqual([ + 'queued', 'starting', 'running', 'awaiting_approval', 'succeeded', 'failed', 'cancelled', 'timed_out', + ]); + expect(params.automation.schema.format).toBe('uuid'); expect(Object.keys(s.components.schemas.RunList.properties)).toEqual(['object', 'data', 'next_cursor']); }); @@ -235,19 +167,31 @@ describe('openapi.json: the Runs contract (cloud-api)', () => { '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', + // Phase 2 (handlers/runs.go runJSON), same order. + 'phase', 'inference', 'automation_id', 'parent_run_id', 'apply_run_ids', 'approval', 'instructions', 'input', + 'approval_kinds', 'github_repo', 'trigger_depth', 'secrets', 'agent_finished_at', ]); expect(Object.keys(s.components.schemas.RunResult.properties)).toEqual([ - 'status', 'summary', 'branch', 'commit', 'changed_files', 'artifacts', + 'status', 'summary', 'branch', 'commit', 'changed_files', 'artifacts', 'actions_applied', + ]); + expect(s.components.schemas.Run.properties.trigger.enum).toEqual([ + 'api', 'cli', 'ui', 'webhook', 'schedule', 'approval', 'automation', ]); + expect(s.components.schemas.Run.properties.phase.enum).toEqual(['run', 'propose', 'apply']); + for (const code of ['secrets_unavailable', 'secret_missing', 'proposal_invalid', 'approval_rejected', 'approval_expired', 'action_failed', 'automation_deleted']) { + expect(s.components.schemas.Run.properties.error_code.enum, code).toContain(code); + expect(s.components.schemas.RunEventDataFinished.properties.error_code.enum, code).toContain(code); + } }); - it('events: the ten event kinds, the message chunks and the page shape', () => { + it('events: the twelve 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', + 'approval_required', 'action', ]); - expect(event.properties.data.oneOf).toHaveLength(10); + expect(event.properties.data.oneOf).toHaveLength(12); expect(s.components.schemas.RunEventDataMessage.description).toMatch(/incrementally.*`final: true`/s); expect(Object.keys(s.components.schemas.RunEventPage.properties)).toEqual(['data', 'next_after', 'done']); }); @@ -307,7 +251,9 @@ describe('openapi.json: the Workspaces contract (cloud-api)', () => { 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).', + // internal/middleware/auth.go sessionRequiredBody (cloud-api #326). + message: + 'This endpoint requires a signed-in session. API keys (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota.', }); }); diff --git a/src/lib/openapiSpec.test.ts b/src/lib/openapiSpec.test.ts index f00970b..451bdbd 100644 --- a/src/lib/openapiSpec.test.ts +++ b/src/lib/openapiSpec.test.ts @@ -44,6 +44,15 @@ const PUBLIC_SURFACE: Array<[string, string]> = [ ['/runs/{id}', 'get'], ['/runs/{id}/events', 'get'], ['/runs/{id}/cancel', 'post'], + ['/automations', 'get'], + ['/automations/templates', 'get'], + ['/automations/{id}', 'get'], + ['/automations/{id}/triggers', 'get'], + ['/automations/{id}/deliveries', 'get'], + ['/automations/{id}/runs', 'post'], + ['/approvals', 'get'], + ['/runs/{id}/proposal.diff', 'get'], + ['/runs/{id}/reject', 'post'], ['/api/workspaces', 'get'], ['/api/workspaces/{id}', 'get'], ]; diff --git a/src/tests/fixtures/phase2/approvals_list.json b/src/tests/fixtures/phase2/approvals_list.json new file mode 100644 index 0000000..330b433 --- /dev/null +++ b/src/tests/fixtures/phase2/approvals_list.json @@ -0,0 +1,79 @@ +{ + "data": [ + { + "apply_run_id": null, + "approval_from": 2, + "automation_id": "a0000000-0000-4000-8000-000000000001", + "decided_at": null, + "diff_masked": false, + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "expires_at": "2026-10-14T09:00:00Z", + "has_diff": false, + "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", + "proposal": { + "actions": [ + { + "base": "main", + "branch": "nan-run/6f1c2a9b", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "commit_message": "fix login", + "force": false, + "kind": "git_push" + }, + { + "body": "\u003cimg onerror=x\u003e looks fine", + "kind": "github_pr_comment", + "number": 42, + "repo": "acme/api" + }, + { + "body": "LGTM", + "event": "COMMENT", + "kind": "github_pr_review", + "number": 42, + "repo": "acme/api" + } + ], + "base": "main", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "diff_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "merge_base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "v": 1 + }, + "proposal_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "run_state": "awaiting_approval", + "state": "pending", + "workspace_id": "11111111-2222-4333-8444-555555555555" + } + ], + "next_cursor": "cursor-2", + "object": "list" +} diff --git a/src/tests/fixtures/phase2/automation_deliveries.json b/src/tests/fixtures/phase2/automation_deliveries.json new file mode 100644 index 0000000..be68146 --- /dev/null +++ b/src/tests/fixtures/phase2/automation_deliveries.json @@ -0,0 +1,12 @@ +{ + "data": [ + { + "delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958", + "outcome": "run_created", + "received_at": "2026-10-11T09:00:00Z", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "trigger_id": "b0000000-0000-4000-8000-000000000002" + } + ], + "object": "list" +} diff --git a/src/tests/fixtures/phase2/automation_get.json b/src/tests/fixtures/phase2/automation_get.json new file mode 100644 index 0000000..c265359 --- /dev/null +++ b/src/tests/fixtures/phase2/automation_get.json @@ -0,0 +1,36 @@ +{ + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "concurrency_policy": "skip_if_active", + "created_at": "2026-10-11T09:00:00Z", + "cwd": "/home/nan/projects/api", + "description": "reviews", + "disabled_reason": null, + "enabled": true, + "git_isolation": true, + "github_repo": "acme/api", + "id": "a0000000-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "instructions": "Review for correctness.", + "max_runs_per_hour": 30, + "max_trigger_depth": 2, + "model": "glm5.3", + "name": "PR review api", + "object": "automation", + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "secret": "GITHUB_WRITE_TOKEN" + } + ], + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "workspace": "11111111-2222-4333-8444-555555555555" +} diff --git a/src/tests/fixtures/phase2/automation_templates.json b/src/tests/fixtures/phase2/automation_templates.json new file mode 100644 index 0000000..7426901 --- /dev/null +++ b/src/tests/fixtures/phase2/automation_templates.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "approval_kinds": [ + "github_pr_review" + ], + "description": "Reviews PRs", + "event_filter": { + "events": [ + "pull_request" + ] + }, + "id": "pr_review", + "instructions": "Review.", + "name": "PR review", + "requires_github_repo": true, + "secret_slots": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "help": "write token", + "optional": false + } + ], + "signature_scheme": "github", + "suggested_concurrency": "skip_if_active", + "trigger_kind": "webhook" + } + ], + "object": "list" +} diff --git a/src/tests/fixtures/phase2/automation_triggers.json b/src/tests/fixtures/phase2/automation_triggers.json new file mode 100644 index 0000000..b9ce112 --- /dev/null +++ b/src/tests/fixtures/phase2/automation_triggers.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "actor_filter": { + "author_associations": [], + "exclude_bots": false, + "logins": [ + "octocat" + ] + }, + "automation_id": "a0000000-0000-4000-8000-000000000001", + "created_at": "2026-10-11T09:00:00Z", + "cron": null, + "enabled": true, + "event_filter": { + "events": [ + "pull_request" + ] + }, + "id": "b0000000-0000-4000-8000-000000000002", + "kind": "webhook", + "last_fired_at": null, + "last_outcome": null, + "next_fire_at": null, + "object": "trigger", + "prev_secret_until": null, + "signature_scheme": "github", + "timezone": null, + "token_prefix": "nan_whk_AbC1", + "updated_at": "2026-10-11T09:00:00Z" + } + ], + "object": "list" +} diff --git a/src/tests/fixtures/phase2/automations_list.json b/src/tests/fixtures/phase2/automations_list.json new file mode 100644 index 0000000..8c87409 --- /dev/null +++ b/src/tests/fixtures/phase2/automations_list.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "concurrency_policy": "skip_if_active", + "created_at": "2026-10-11T09:00:00Z", + "cwd": "/home/nan/projects/api", + "description": "reviews", + "disabled_reason": null, + "enabled": true, + "git_isolation": true, + "github_repo": "acme/api", + "id": "a0000000-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "instructions": "Review for correctness.", + "max_runs_per_hour": 30, + "max_trigger_depth": 2, + "model": "glm5.3", + "name": "PR review api", + "object": "automation", + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "secret": "GITHUB_WRITE_TOKEN" + } + ], + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "workspace": "11111111-2222-4333-8444-555555555555" + } + ], + "object": "list" +} diff --git a/src/tests/fixtures/phase2/run_get_adhoc.json b/src/tests/fixtures/phase2/run_get_adhoc.json new file mode 100644 index 0000000..37eca16 --- /dev/null +++ b/src/tests/fixtures/phase2/run_get_adhoc.json @@ -0,0 +1,52 @@ +{ + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": null, + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": null, + "phase": "run", + "prompt": "review PR 42 in full", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [], + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } +} diff --git a/src/tests/fixtures/phase2/run_get_propose.json b/src/tests/fixtures/phase2/run_get_propose.json new file mode 100644 index 0000000..0c79820 --- /dev/null +++ b/src/tests/fixtures/phase2/run_get_propose.json @@ -0,0 +1,147 @@ +{ + "agent": "pi", + "agent_finished_at": "2026-10-11T09:00:00Z", + "apply_run_ids": [ + "d0000000-0000-4000-8000-000000000004" + ], + "approval": { + "apply_run_id": null, + "approval_from": 2, + "decided_at": null, + "diff_masked": false, + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "expires_at": "2026-10-14T09:00:00Z", + "has_diff": false, + "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", + "proposal": { + "actions": [ + { + "base": "main", + "branch": "nan-run/6f1c2a9b", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "commit_message": "fix login", + "force": false, + "kind": "git_push" + }, + { + "body": "\u003cimg onerror=x\u003e looks fine", + "kind": "github_pr_comment", + "number": 42, + "repo": "acme/api" + }, + { + "body": "LGTM", + "event": "COMMENT", + "kind": "github_pr_review", + "number": 42, + "repo": "acme/api" + } + ], + "base": "main", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "diff_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "merge_base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "v": 1 + }, + "proposal_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "state": "pending" + }, + "approval_kinds": [ + "github_pr_review" + ], + "automation_id": "a0000000-0000-4000-8000-000000000001", + "cancel_requested": false, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "input": { + "delivery": "72d3162e-cc78-11e3-81ab-4c9367dc0958", + "event": "pull_request", + "payload": { + "number": 42, + "title": "\u003cb\u003efix\u003c/b\u003e" + }, + "source": "webhook:github", + "untrusted": true + }, + "instructions": "Review for correctness.", + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": "e0000000-0000-4000-8000-000000000005", + "phase": "propose", + "prompt": "review PR 42 in full", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [ + { + "kind": "github_pr_review", + "ok": true, + "ref": "https://github.com/acme/api/pull/42#pullrequestreview-1" + } + ], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "started_at": "2026-10-10T09:00:01Z", + "state": "awaiting_approval", + "timeout_seconds": 1800, + "trigger": "webhook", + "trigger_depth": 1, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } +} diff --git a/src/tests/fixtures/phase2/run_list_propose.json b/src/tests/fixtures/phase2/run_list_propose.json new file mode 100644 index 0000000..2ec4578 --- /dev/null +++ b/src/tests/fixtures/phase2/run_list_propose.json @@ -0,0 +1,141 @@ +{ + "data": [ + { + "agent": "pi", + "agent_finished_at": "2026-10-11T09:00:00Z", + "apply_run_ids": [ + "d0000000-0000-4000-8000-000000000004" + ], + "approval": { + "apply_run_id": null, + "approval_from": 2, + "decided_at": null, + "diff_masked": false, + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "expires_at": "2026-10-14T09:00:00Z", + "has_diff": false, + "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", + "proposal": { + "actions": [ + { + "base": "main", + "branch": "nan-run/6f1c2a9b", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "commit_message": "fix login", + "force": false, + "kind": "git_push" + }, + { + "body": "\u003cimg onerror=x\u003e looks fine", + "kind": "github_pr_comment", + "number": 42, + "repo": "acme/api" + }, + { + "body": "LGTM", + "event": "COMMENT", + "kind": "github_pr_review", + "number": 42, + "repo": "acme/api" + } + ], + "base": "main", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "diff_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "merge_base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "v": 1 + }, + "proposal_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "state": "pending" + }, + "approval_kinds": [ + "github_pr_review" + ], + "automation_id": "a0000000-0000-4000-8000-000000000001", + "cancel_requested": false, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": "e0000000-0000-4000-8000-000000000005", + "phase": "propose", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [ + { + "kind": "github_pr_review", + "ok": true, + "ref": "https://github.com/acme/api/pull/42#pullrequestreview-1" + } + ], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "started_at": "2026-10-10T09:00:01Z", + "state": "awaiting_approval", + "timeout_seconds": 1800, + "trigger": "webhook", + "trigger_depth": 1, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } + } + ], + "next_cursor": null, + "object": "list" +} diff --git a/src/tests/fixtures/phase2/trigger_create.json b/src/tests/fixtures/phase2/trigger_create.json new file mode 100644 index 0000000..83a0a7b --- /dev/null +++ b/src/tests/fixtures/phase2/trigger_create.json @@ -0,0 +1,33 @@ +{ + "signing_secret": "whsec_shown_once", + "trigger": { + "actor_filter": { + "author_associations": [], + "exclude_bots": false, + "logins": [ + "octocat" + ] + }, + "automation_id": "a0000000-0000-4000-8000-000000000001", + "created_at": "2026-10-11T09:00:00Z", + "cron": null, + "enabled": true, + "event_filter": { + "events": [ + "pull_request" + ] + }, + "id": "b0000000-0000-4000-8000-000000000002", + "kind": "webhook", + "last_fired_at": null, + "last_outcome": null, + "next_fire_at": null, + "object": "trigger", + "prev_secret_until": null, + "signature_scheme": "github", + "timezone": null, + "token_prefix": "nan_whk_AbC1", + "updated_at": "2026-10-11T09:00:00Z" + }, + "webhook_url": "https://api.nan.builders/hooks/nan_whk_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +} diff --git a/src/tests/helpers/openapiValidate.ts b/src/tests/helpers/openapiValidate.ts new file mode 100644 index 0000000..0681a21 --- /dev/null +++ b/src/tests/helpers/openapiValidate.ts @@ -0,0 +1,85 @@ +import { expect } from 'vitest'; +import spec from '../../data/openapi.json'; + +/** + * A small JSON Schema validator for the examples of the API reference. + * + * There is no OpenAPI validator among the dependencies, so the examples are + * checked against their schemas with this: enough of JSON Schema for what the + * spec uses ($ref, type, enum, required, properties, additionalProperties: + * false, items, oneOf, allOf, pattern, minimum/maximum, minLength/maxLength). + * An object property the schema does not declare is an error unless the + * schema says `additionalProperties: true`. + */ +export 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. */ +export 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; +} + From 012e3d2c7e1e4d9329cc0b1fbdfb36c3e885289f Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 22:29:40 +0200 Subject: [PATCH 3/4] docs(automations): address QA and UX review GitHub ping is always ignored; an empty event filter uses the template's filter and {} lets everything through; the automation's run ends when its proposal is settled and a separate apply run applies the actions (where action_failed shows); skip_if_active subjects per sender; chain depth starts at 1 for webhook and schedule runs; every cause of not_accepted; Stripe needs its secret before the webhook is added; CI token scopes and where the automation id is; the GitHub token needs Actions read and Workflows write when relevant; 400/500 answers, 50 deliveries, schedule Error outcome; exit code 10 in order; natural Spanish. Tests run the published bash signing line against an independent HMAC vector. Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 89 +++++++++++++---------- src/content/docs-es/runs.mdx | 2 +- src/content/docs-es/webhooks.mdx | 49 +++++++------ src/content/docs/automations.mdx | 79 ++++++++++++-------- src/content/docs/runs.mdx | 2 +- src/content/docs/webhooks.mdx | 49 +++++++------ src/tests/layouts/automationsDocs.test.ts | 33 +++++++-- 7 files changed, 184 insertions(+), 119 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index 6e15574..ba42a3d 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -26,6 +26,7 @@ El agente de una automatización no hace push ni publica nada por su cuenta. **P - **Un workspace encendido**, con Pi o Hermes instalado, como para cualquier [run](/es/docs/runs#antes-de-empezar). - **Un plan con inferencia.** Los runs de una automatización usan la clave de inferencia de tu workspace y cuentan para el uso de tu plan como cualquier otro run. - **Una sesión en el navegador.** Crear o cambiar automatizaciones, escribir secretos y aprobar acciones exige que hayas iniciado sesión en [cloud.nan.builders](https://cloud.nan.builders/) desde tu navegador, con el enlace por correo. Una sesión de la CLI, una API key o un token pueden listar automatizaciones y lanzarlas, pero nunca cambiarlas. Consulta [Por qué algunas acciones necesitan el navegador](#por-qué-algunas-acciones-necesitan-el-navegador). +- **Para las plantillas de GitHub, un token de GitHub guardado como secreto.** Sin él la automatización no se puede guardar, así que créalo antes: consulta [Un token de GitHub para las acciones](#un-token-de-github-para-las-acciones). ## Crea una automatización @@ -33,7 +34,7 @@ El agente de una automatización no hace push ni publica nada por su cuenta. **P ### Abre Automations -Entra en [cloud.nan.builders/automations](https://cloud.nan.builders/automations), o en **Automations** en la barra lateral, bajo **Workspaces**. La página tiene dos pestañas: **Automations**, tu lista, y **Needs approval**, las propuestas que esperan por ti. +Entra en [cloud.nan.builders/automations](https://cloud.nan.builders/automations), o en **Automations** en la barra lateral, bajo **Workspaces**. La página tiene dos pestañas: **Automations**, tu lista, y **Needs approval**, las propuestas pendientes de tu aprobación. ### Elige una plantilla o empieza de cero @@ -47,7 +48,7 @@ Pulsa **New automation**. Una plantilla rellena valores razonables que puedes ca | **Generic webhook** | Ejecuta tus instrucciones con cualquier evento JSON firmado. | Un webhook con la [firma de NaN](/es/docs/webhooks#envía-tus-propios-eventos). | | **From scratch** | Tus propias instrucciones, triggers y acciones. | Lo que añadas. | -Las tres plantillas de GitHub actúan sobre un repositorio, así que necesitan un **GitHub repository** y un token de GitHub para las acciones. Consulta [Un token de GitHub para las acciones](#un-token-de-github-para-las-acciones). +Las tres plantillas de GitHub actúan sobre un repositorio, así que necesitan un **GitHub repository** y un token de GitHub para las acciones. Su tarjeta **Secrets** trae ya preparada una fila `GITHUB_TOKEN`: elige en ella el secreto de tu token. Consulta [Un token de GitHub para las acciones](#un-token-de-github-para-las-acciones). ### Rellena los ajustes @@ -59,17 +60,17 @@ Las tres plantillas de GitHub actúan sobre un repositorio, así que necesitan u | **Workspace** | El workspace donde trabaja el agente. | | **Agent** | Pi o Hermes. | | **Folder** | Dónde trabaja el agente, dentro de `/home/nan`. | -| **Separate branch** | Rama aparte: **On**, **Auto** u **Off**, como en un [run](/es/docs/runs#dónde-trabaja-el-agente). | +| **Separate branch** | Rama aparte: **On** (por defecto), **Auto** u **Off (work in the folder directly)** (trabajar directamente en la carpeta), como en un [run](/es/docs/runs#dónde-trabaja-el-agente). | | **Time limit (minutes)** | El tiempo máximo, de 1 a 120. 30 por defecto. | | **GitHub repository (optional)** | `owner/name`, por ejemplo `acme/api`. El repositorio sobre el que actúan las acciones. | | **Secrets** | Cuáles de tus [secretos](#secretos) reciben los runs, y quién recibe cada uno. | -| **Approval** | Qué tipos de acción esperan por ti. Consulta [Acciones y aprobación](#acciones-y-aprobación). | +| **Approval** | Qué tipos de acción esperan tu aprobación. Consulta [Acciones y aprobación](#acciones-y-aprobación). | | **Runs per hour** | Runs por hora, de 1 a 120, contados sobre la última hora. 30 por defecto. Por encima, los nuevos arranques se rechazan hasta que los runs más antiguos salen de esa hora. | | **When a run is active** | **Skip the new event** (descarta el evento nuevo) o **Queue it** (lo pone en cola). Consulta [Cuando ya hay un run activo](#cuando-ya-hay-un-run-activo). | -| **Chain depth** | Profundidad de cadena, de 0 a 5. 2 por defecto. Cuántas veces un comentario o un push del propio run puede lanzar otro run. Consulta [Bucles](/es/docs/webhooks#bucles). | +| **Chain depth** | Profundidad de cadena, de 0 a 5. 2 por defecto. Hasta dónde puede llegar una cadena de runs: un run lanzado por un webhook o una programación está en la profundidad 1, y un run lanzado por un comentario o un push de otro run está un nivel más abajo. Con 2, un run de un webhook puede lanzar un run más, no dos. Consulta [Bucles](/es/docs/webhooks#bucles). | | **Enabled** | Si está desactivada, nada la lanza: ni sus triggers, ni **Run now**. | -Pulsa **Create automation**. +**Runs per hour**, **When a run is active**, **Chain depth** y **Enabled** están en la tarjeta **Limits**. Pulsa **Create automation**. ### Conecta lo que la lanza @@ -93,7 +94,7 @@ Abre tus ajustes en el portal, **Secrets**, debajo de **Tokens**, y pulsa **Add Los secretos son de **solo escritura**: una vez guardados, nadie puede volver a leer el valor, ni siquiera tú. La lista muestra el nombre, los 4 últimos caracteres cuando el valor es lo bastante largo, la descripción y qué automatizaciones usan cada uno. Puedes: -- **Replace value**: el valor nuevo se aplica en el siguiente run. El anterior no se puede recuperar. +- **Replace value**: el valor nuevo lo usan todos los runs que arranquen a partir de ese momento, incluidos los que ya están en cola. El anterior no se puede recuperar. - Cambiar su **Description**. - **Delete**. El borrado se rechaza mientras una automatización lo use: quítalo antes de esas automatizaciones. @@ -114,7 +115,7 @@ En la tarjeta **Secrets** de la automatización, **Add a secret** y elige, para El mismo nombre de variable puede aparecer una vez por opción. La configuración habitual de GitHub le da al agente un token de solo lectura y a las acciones un token de escritura, los dos como `GITHUB_TOKEN`. -Una automatización admite hasta 20 secretos, y todos los secretos que recibe un run pueden sumar como mucho 48 KiB. Los secretos solo llegan a los runs de las automatizaciones a las que los asignas: un run que lanzas con `nan run` o desde la pestaña **Runs** no recibe ninguno. +Una automatización admite hasta 20 secretos, y todos los secretos que recibe un run pueden sumar como mucho 48 KiB. Los secretos solo llegan a los runs de las automatizaciones a las que los asignas: un run que lanzas con `nan run` o desde la pestaña **Runs** de un workspace no recibe ninguno. En los logs, resultados y propuestas de los runs, todos los valores de los secretos aparecen enmascarados. Un run solo arranca si todos los secretos que necesita están disponibles; si no, falla con `secrets_unavailable` y nada se ejecuta sin ellos. @@ -131,7 +132,9 @@ Para actuar sobre un repositorio, una automatización necesita: Crea un [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) limitado a ese único repositorio, con los permisos que necesitan las acciones: **Contents** de lectura y escritura para hacer push, **Pull requests** de lectura y escritura para abrir, comentar y revisar pull requests, e **Issues** de lectura y escritura para comentar en issues y añadir etiquetas. Guárdalo en **Secrets**, por ejemplo como `GITHUB_WRITE_TOKEN`, y asígnalo a la automatización como `GITHUB_TOKEN`, **Actions only**. -Si el agente también necesita leer el repositorio (uno privado, o para traer logs de CI), crea un segundo token solo con permisos de lectura y asígnalo como `GITHUB_TOKEN`, **Agent**. +Si un push puede cambiar archivos dentro de `.github/workflows/` (la plantilla **CI failure** puede hacerlo), el token de escritura también necesita **Workflows** de lectura y escritura. + +Si el agente también necesita leer el repositorio (uno privado, o para traer logs de CI), crea un segundo token solo con permisos de lectura (**Contents**, **Pull requests**, **Issues**, y **Actions** para los logs de CI) y asígnalo como `GITHUB_TOKEN`, **Agent**. ## Acciones y aprobación @@ -141,7 +144,7 @@ El agente de una automatización trabaja como en cualquier run: lee el código, | Acción | En el portal aparece como | Qué hace NaN | |---|---|---| -| `git_push` | **Push commits** | Hace push de los cambios del agente a una rama, como un único commit encima de la rama base. | +| `git_push` | **Push commits** | Hace push de los cambios del agente a la rama que indica la propuesta, como un único commit encima del punto de la rama base desde el que empezó el agente. | | `github_create_pr` | **Open pull requests** | Abre una pull request (o un borrador). | | `github_pr_comment` | **Comment on pull requests** | Publica un comentario en una pull request. | | `github_pr_review` | **Review pull requests** | Publica una review: comentar, aprobar o pedir cambios. | @@ -152,21 +155,21 @@ Hasta 10 acciones por propuesta, siempre sobre el **GitHub repository** de la au Después, un paso aparte, sin agente y sin ningún modelo de IA, aplica las acciones con tu token **Actions only**. El agente nunca ve ese token. Un run que no propone nada simplemente termina como `succeeded`. -Todo lo que hace NaN va marcado: los commits que sube llevan una línea `Nan-Run: `, los comentarios, reviews y pull requests terminan con una marca oculta, y las ramas creadas por runs empiezan por `nan-run/`. El autor de un commit subido es `NaN run `. +Todo lo que hace NaN va marcado: los commits que sube llevan una línea `Nan-Run: `, los comentarios, reviews y pull requests terminan con una marca oculta, y las ramas aparte en las que trabajan los runs empiezan por `nan-run/` (el push en sí va a la rama que indica la propuesta). El autor de un commit subido es `NaN run `. -Un push nunca lleva la clave de inferencia de tu workspace: una propuesta cuyos cambios la contengan se rechaza (`proposal_invalid`), necesite aprobación o no. Tus propios secretos son cosa tuya: si un diff contiene el valor de alguno de ellos, el portal te avisa antes de aprobar. +Un push nunca lleva la clave de inferencia de tu workspace: una propuesta cuyos cambios la contengan se rechaza (`proposal_invalid`), necesite aprobación o no. Tus propios secretos son cosa tuya: si un diff contiene el valor de un secreto que recibe ese run, el portal te avisa antes de aprobar. -### Elige qué espera por ti +### Elige qué espera tu aprobación La tarjeta **Approval** de una automatización tiene tres opciones: -- **Approve everything**: todas las acciones esperan por ti. Las plantillas empiezan con esta. +- **Approve everything**: todas las acciones esperan tu aprobación. Las plantillas empiezan con esta. - **Fully automatic**: las acciones se aplican sin preguntar, push y pull requests incluidos. - **Custom**: marca qué tipos de acción necesitan aprobación. Las demás se aplican solas. Cuando algo de fuera (un webhook o una programación) puede lanzar una automatización que aplica alguna acción sin preguntar, el editor muestra un aviso. Nunca te impide guardar: lo que pasa en tu repositorio es decisión tuya. -**Proposals expire after**: cuánto tiempo espera una propuesta por ti: **1 hour**, **24 hours**, **72 hours** (por defecto) o **7 days**. +**Proposals expire after** (aparece cuando al menos un tipo de acción necesita aprobación): cuánto tiempo te espera una propuesta: **1 hour**, **24 hours**, **72 hours** (por defecto) o **7 days**. La aprobación controla lo que NaN hace con tus secretos **Actions only**. El agente en sí se ejecuta en tu workspace y puede usar cualquier cosa que tengas ahí, como una clave SSH o una sesión de `gh`. @@ -182,37 +185,47 @@ Pulsa **Review** para abrir la propuesta: cada acción, con sus textos completos ### Apruébala -**Approve and apply** aplica, en orden, las acciones que esperaban por ti. Aprobar exige una sesión en el navegador. Si la propuesta ha cambiado desde que la abriste, el portal te pide que la revises de nuevo. +**Approve and apply** manda aplicar, en orden, las acciones pendientes de tu aprobación. Se activa cuando la propuesta y su diff han cargado; si el portal no puede mostrar la propuesta exactamente, solo te ofrece **Reject**. Aprobar exige una sesión en el navegador. Si la propuesta ha cambiado desde que la abriste, el portal te pide que la revises de nuevo. ### O recházala -**Reject**, con un motivo opcional. No se aplica nada de lo que esperaba aprobación. También puedes rechazarla desde la terminal con `nan runs reject ` o desde la API. +**Reject** te pide confirmación, con un campo **Reason (optional)** para el motivo. No se aplica nada de lo que esperaba aprobación. También puedes rechazarla desde la terminal con `nan runs reject ` o desde la API. Si nadie decide antes de que caduque, la propuesta se descarta y el run termina como `cancelled`. -Cómo termina un run, según su propuesta: +Las acciones las aplica un **run de aplicación** aparte: aparece en la pestaña **Runs** del workspace, sin agente y sin gastar tokens, y su log tiene una línea por acción. El run propio de la automatización termina en cuanto su propuesta queda resuelta, antes de que se apliquen las acciones: -| La propuesta... | El run termina como | -|---|---| -| no tiene acciones | `succeeded` | -| solo tiene acciones que se aplican solas | `succeeded`, y las acciones se aplican | -| tiene acciones que necesitan aprobación, y apruebas | `succeeded`, y las acciones se aplican | -| se rechaza | `cancelled` (`approval_rejected`) | -| caduca | `cancelled` (`approval_expired`) | -| no es válida | `failed` (`proposal_invalid`) | +| La propuesta... | El run de la automatización termina como | Y después | +|---|---|---| +| no tiene acciones | `succeeded` | No hay nada que aplicar. | +| solo tiene acciones que se aplican solas | `succeeded` | Un run de aplicación las aplica. | +| tiene acciones que necesitan aprobación, y apruebas | `succeeded` | Un run de aplicación las aplica. | +| se rechaza | `cancelled` (`approval_rejected`) | No se aplica nada de lo que esperaba aprobación. | +| caduca | `cancelled` (`approval_expired`) | No se aplica nada de lo que esperaba aprobación. | +| no es válida | `failed` (`proposal_invalid`) | No se aplica nada. | -La aprobación empieza en la primera acción que la necesita. Las acciones anteriores se aplican solas mientras la propuesta espera por ti, y en cuanto apruebas, el resto se aplica en orden, incluidas las acciones posteriores que por sí solas no necesitarían aprobación: una pull request necesita que antes se haya hecho push de su rama. Si una acción falla, las siguientes no se aplican y el run muestra `action_failed`. +Así que `succeeded` significa que la propuesta se aceptó, no que todas las acciones funcionaran: revisa el run de aplicación. Si una acción falla, el run de aplicación termina como `failed` (`action_failed`) y las acciones siguientes no se aplican. -El paso que aplica las acciones aparece como un run propio en la pestaña **Runs** del workspace, sin agente y sin gastar tokens. Su log tiene una línea por acción. +La aprobación empieza en la primera acción que la necesita. Las acciones anteriores se aplican solas mientras la propuesta espera tu aprobación (si una de ellas falla, el run de la automatización falla con `action_failed` y no queda nada que aprobar), y en cuanto apruebas, el resto se aplica en orden, incluidas las acciones posteriores que por sí solas no necesitarían aprobación: una pull request necesita que antes se haya hecho push de su rama. ## Cuando ya hay un run activo **When a run is active** decide qué pasa si llega un evento nuevo sobre lo mismo mientras sigue en marcha un run para ello, incluido un run que espera tu aprobación: -- **Skip the new event**: se descarta. Para una pull request, una issue o un workflow, "lo mismo" es esa pull request, issue o ejecución de workflow. Para una programación, es la automatización. Las plantillas de GitHub usan esta opción, así que hacer push de tres commits seguidos a una pull request no lanza tres reviews. -- **Queue it**: se pone en cola un run nuevo, como siempre. +- **Skip the new event**: se descarta. Las plantillas de GitHub usan esta opción, así que hacer push de tres commits seguidos a una pull request no lanza tres reviews. +- **Queue it**: se pone en cola un run nuevo, como siempre. La plantilla **Generic webhook** usa esta opción. + +Qué cuenta como "lo mismo" depende de qué lanzó el run: + +| Lanzado por | Lo mismo es | +|---|---| +| Un evento de GitHub de pull request o issue | Esa pull request o issue. | +| Un evento `workflow_run` de GitHub | El mismo repositorio y la misma rama: un fallo nuevo en esa rama se descarta mientras haya un run activo para ella, aunque venga de otra ejecución de workflow. | +| Un evento de Stripe | El mismo objeto de Stripe (`data.object.id`). | +| Cualquier otro evento (emisores NaN, otros eventos de GitHub) | Cualquier evento de la automatización: un run cada vez. | +| Una programación | La automatización. | Los runs que lanzas tú nunca se descartan. @@ -220,7 +233,7 @@ Los runs que lanzas tú nunca se descartan. ### Desde el portal -**Run now**, arriba en la automatización, abre una caja para un **Input** opcional: un objeto JSON (hasta 48 KiB) que el agente recibe como datos junto a tus instrucciones, nunca como instrucciones. **Start run** te lleva a la página del run. +**Run now**, arriba en la automatización, abre una caja para el campo opcional **Input (JSON object, optional)**: un objeto JSON (hasta 48 KiB) que el agente recibe como datos junto a tus instrucciones, nunca como instrucciones. **Start run** te lleva a la página del run. ### Desde la terminal @@ -235,12 +248,12 @@ gh api repos/acme/api/pulls/42 | nan automations run "PR review" --input - nan automations deliveries "PR review" # webhooks recibidos, últimos 30 días ``` -`--input` acepta el propio JSON, `@file.json` o `-` para stdin. No pongas secretos en él: el agente lo lee. Una automatización se indica por su nombre o por su id. +`--input` acepta el propio JSON, `@file.json` o `-` para stdin. No pongas secretos en él: el agente lo lee. Una automatización se indica por su nombre o por su id; con un nombre, la CLI la busca primero, y eso exige el scope `automations:read` en un token. Los comandos de aprobaciones y secretos: ```bash -nan approvals ls # propuestas que esperan por ti +nan approvals ls # propuestas pendientes de tu aprobación nan runs proposal # las acciones propuestas, y dónde aprobarlas nan runs proposal --diff # con el diff completo, cuando se guarda nan runs reject --reason "not now" @@ -253,7 +266,7 @@ No hay ningún comando para crear o cambiar automatizaciones, escribir secretos ### Desde la API -Con un [token de plataforma](#tokens-para-automatizaciones) que tenga el scope `automations:run`, o con tu API key: +La API usa el id de la automatización: está en la dirección de su página (`cloud.nan.builders/automations/`), en `nan automations ls` y en `GET /v1/automations`. Con un [token de plataforma](#tokens-para-automatizaciones) que tenga el scope `automations:run`, o con tu API key: ```bash curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ @@ -264,7 +277,7 @@ curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ La respuesta es el run, que sigues como cualquier otro ([Crea un run y síguelo](/es/docs/runs#crea-un-run-y-síguelo)). La referencia completa está en [Referencia de la API → Automations](/es/docs/api#tag/automations) y [Referencia de la API → Approvals](/es/docs/api#tag/approvals). -### Tokens para automatizaciones +## Tokens para automatizaciones Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crearlo, en **Settings → Tokens**: @@ -277,6 +290,8 @@ Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crear Los tokens nuevos llevan `runs` y `workspaces:read` por defecto. Un token con `automations:read` o `automations:run` solo se puede crear desde una sesión en el navegador. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. +Para CI, dale al token `automations:run` y `runs` (para seguir el run y conocer su resultado), y además `automations:read` si llamas a la automatización por su nombre. Por ejemplo: `nan automations run ` en un paso con `NAN_TOKEN` definido, como en [Usa la CLI en CI o en un servidor](/es/docs/runs#usa-la-cli-en-ci-o-en-un-servidor). + ## Límites | Límite | Valor | @@ -294,9 +309,9 @@ Los runs de las automatizaciones comparten los límites de todos los runs: cuán ## Cambia o borra una automatización -Un run conserva los ajustes con los que arrancó. Editar una automatización nunca cambia un run que ya está en cola o trabajando. +Cambia cualquier ajuste en la pestaña **Settings** y pulsa **Save changes**. Un run conserva los ajustes con los que arrancó: editar una automatización nunca cambia un run que ya está en cola o trabajando. -**Delete** para sus triggers al momento y cancela sus runs en cola. Los runs que ya están trabajando terminan, y las acciones que ya aprobaste, o que se aplican solas, se completan igualmente. Los runs pasados se quedan en el historial del workspace. +Al borrarla con **Delete**, sus triggers se detienen al momento y se cancelan sus runs en cola. Los runs que ya están trabajando terminan, y las acciones que ya aprobaste, o que se aplican solas, se completan igualmente. Los runs pasados se quedan en el historial del workspace, y un run que ya espera tu aprobación todavía se puede aprobar o rechazar. ## Por qué algunas acciones necesitan el navegador diff --git a/src/content/docs-es/runs.mdx b/src/content/docs-es/runs.mdx index 6a1748b..88cfa42 100644 --- a/src/content/docs-es/runs.mdx +++ b/src/content/docs-es/runs.mdx @@ -230,10 +230,10 @@ nan runs cancel | 2 | se agotó el tiempo máximo | | 3 | cancelado | | 4 | el workspace no está listo: agente no instalado, sin clave de inferencia o configuración incorrecta | +| 10 | el run espera tu aprobación (solo runs de [automatizaciones](/es/docs/automations#aprueba-o-rechaza-una-propuesta)) | | 64 | error de uso, también tener más de un workspace y no pasar `--ws` | | 65 | sin sesión, sesión caducada o sin permiso | | 69 | nan.builders no disponible, o se ha perdido el stream (el run sigue, y se imprime el id) | -| 10 | el run espera tu aprobación (solo runs de [automatizaciones](/es/docs/automations#aprueba-o-rechaza-una-propuesta)) | | 75 | el run se ha aceptado y sigue en marcha (`--detach`, o Ctrl-C una vez) | Con `set -e`, ten en cuenta el 75 de `--detach`: diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index 333699e..22fbdd1 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -13,7 +13,7 @@ import Callout from '../../components/docs/Callout.astro'; Un **trigger** lanza una [automatización](/es/docs/automations) sin ti: un **webhook** cuando GitHub, Stripe o uno de tus sistemas envía un evento, o una **programación** a las horas que elijas. Cada arranque crea un run en tu workspace con los ajustes de la automatización, exactamente igual que **Run now**. -Una automatización puede tener hasta **5 triggers**, de cualquiera de los dos tipos. Los gestionas en su pestaña **Triggers**, en [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule** y, en cada trigger, **Pause** / **Resume**, **Rotate** (webhooks) y borrar. Añadir, cambiar y borrar triggers exige una [sesión en el navegador](/es/docs/automations#por-qué-algunas-acciones-necesitan-el-navegador). +Una automatización puede tener hasta **5 triggers**, de cualquiera de los dos tipos. Los gestionas en su pestaña **Triggers**, en [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule** y, en cada trigger, **Pause** / **Resume**, **Rotate** (webhooks) y el icono de la papelera para borrarlo. Añadir, cambiar y borrar triggers exige una [sesión en el navegador](/es/docs/automations#por-qué-algunas-acciones-necesitan-el-navegador). ## Añade un webhook @@ -31,6 +31,8 @@ En la pestaña **Triggers**, pulsa **+ Webhook** y elige el **Sender** (el emiso Cuando la automatización viene de una plantilla, el emisor y el filtro de eventos ya están rellenos. +Para **Stripe**, es Stripe quien te da el secreto de firma, y tú lo pegas en este formulario, así que crea antes el endpoint en Stripe: sigue [Desde Stripe](#desde-stripe). + ### Elige qué eventos lanzan un run **Event filter (JSON, optional)**: qué eventos lanzan un run. Consulta [Filtros de eventos](#filtros-de-eventos). Para GitHub también puedes limitar **Who can start it** (quién puede lanzarla): consulta [Solo algunos usuarios de GitHub](#solo-algunos-usuarios-de-github). @@ -40,9 +42,9 @@ Cuando la automatización viene de una plantilla, el emisor y el filtro de event Pulsa **Add webhook**. El portal te muestra, **una sola vez**: - **Webhook URL**: `https://api.nan.builders/hooks/nan_whk_...`. La propia URL es una credencial: cualquiera que la tenga puede enviarle eventos, así que trátala como una contraseña. -- **Signing secret** (GitHub y NaN): con lo que el emisor firma cada evento. +- **Signing secret** (GitHub y NaN): con lo que el emisor firma cada evento. Para Stripe solo se muestra la URL: el secreto es el que pegaste. -Copia los dos y pulsa **I've saved them**. Después, el portal solo muestra el principio de la URL. Si los pierdes, [rótalos](#rota-la-url-o-el-secreto) para conseguir unos nuevos. +Cópialos y pulsa **I've saved them**. Después, el portal solo muestra el principio de la URL. Si los pierdes, [rótalos](#rota-la-url-o-el-secreto) para conseguir unos nuevos. ### Configúralo en el emisor @@ -61,9 +63,9 @@ En el repositorio, abre **Settings → Webhooks → Add webhook** (o lo mismo en | **Secret** | El secreto de firma. | | **Which events** | **Let me select individual events**, y marca los que maneja tu automatización: **Pull requests** para una review, **Issues** para el triaje, **Workflow runs** para los fallos de CI. | -GitHub firma cada entrega con `X-Hub-Signature-256` y NaN la comprueba. Al guardar, GitHub envía un `ping`: aparece como **Ignored by a filter** en **Deliveries** salvo que tu filtro acepte `ping`. Eso te dice que la URL y el secreto funcionan. +GitHub firma cada entrega con `X-Hub-Signature-256` y NaN la comprueba. Al guardar, GitHub envía un `ping`: se ignora siempre y aparece como **Ignored by a filter** en **Deliveries**. Eso te dice que la URL y el secreto funcionan. -GitHub no firma ninguna marca de tiempo, así que NaN recuerda el id de cada entrega (`X-GitHub-Delivery`) y lanza como mucho un run por entrega. **Redeliver** en GitHub envía el mismo id: si ya lanzó un run, se responde como duplicado. +GitHub no firma ninguna marca de tiempo, así que NaN recuerda el id de cada entrega (`X-GitHub-Delivery`) y lanza como mucho un run por entrega durante 30 días. **Redeliver** en GitHub envía el mismo id: si ya lanzó un run, se responde como duplicado. ## Desde Stripe @@ -93,7 +95,7 @@ El emisor **NaN (HMAC)** es para tus propios sistemas: un script de despliegue, |---|---|---| | `X-Nan-Timestamp` | Sí | La hora actual, en segundos Unix. Los eventos con más de 5 minutos de desfase se rechazan. | | `X-Nan-Signature` | Sí | `v1=` seguido del HMAC-SHA256 en hexadecimal de `.` con el secreto de firma. Puedes enviar varias, separadas por comas, mientras rotas el secreto. | -| `X-Nan-Delivery` | No | Un id único por evento, de hasta 128 caracteres entre `A-Z a-z 0-9 . _ : -`. El mismo id nunca lanza dos runs. Sin él, el id es el hash de la marca de tiempo y el cuerpo. | +| `X-Nan-Delivery` | No | Un id único por evento, de hasta 128 caracteres entre `A-Z a-z 0-9 . _ : -`. El mismo id nunca lanza dos runs. Sin él, el id es el hash de la marca de tiempo y el cuerpo. La misma marca de tiempo y el mismo cuerpo son siempre un único evento, diga lo que diga esta cabecera. | | `X-Nan-Event` | No | El nombre del evento, para tu [filtro de eventos](#filtros-de-eventos). Por ejemplo `deploy.finished`. | | `X-Nan-Run-Id` | No | Si un run ha causado este evento, su id (está en `NAN_RUN_ID` dentro del run). Alimenta la [protección contra bucles](#bucles). | @@ -164,7 +166,7 @@ Guarda la URL y el secreto de firma en el almacén de secretos de tus sistemas, ## Filtros de eventos -El **Event filter** es un objeto JSON. Cada lista que rellenes tiene que coincidir; una lista vacía, o no poner filtro, lo deja pasar todo: +El **Event filter** es un objeto JSON. Cada lista que rellenes tiene que coincidir, y una lista vacía lo deja pasar todo. Si dejas el campo vacío, el webhook usa el filtro de la plantilla de la automatización (cuando el emisor es el de la plantilla); para dejar pasar todos los eventos, escribe `{}`: | Campo | Coincide con | Ejemplo | |---|---|---| @@ -190,7 +192,7 @@ Hasta 32 entradas por lista, cada una con caracteres de `A-Z a-z 0-9 _ . : -`. U Por defecto, cualquier evento con una firma válida lanza un run. En un repositorio público, eso significa cualquiera que pueda abrir una pull request o una issue. Para limitarlo, marca **Only some GitHub users** en **Who can start it**: - **GitHub logins**: solo los eventos enviados por estos usuarios. Vacío significa cualquiera. -- **Relationship to the repository**: solo los autores con esa relación con el repositorio (owner, member, collaborator, contributor, first-time contributor...). Ninguna marcada significa cualquiera. +- **Relationship to the repository**: solo los autores con esa relación con el repositorio (owner, member, collaborator, contributor, first time contributor, first timer, none). Ninguna marcada significa cualquiera. - **Ignore bots**: descarta los eventos enviados por cuentas bot. Puedes cambiarlo después con **Change** en el trigger. @@ -199,29 +201,33 @@ Puedes cambiarlo después con **Change** en el trigger. El evento le llega al agente como datos, después de tus instrucciones y claramente marcado como algo que viene de fuera. Nunca se toma como instrucciones. -- Los eventos de **GitHub** de pull requests, issues y ejecuciones de workflow llegan recortados a los campos que importan: el repositorio, el número, el título, el cuerpo (hasta 8 KiB), el autor y su relación con el repositorio, las ramas y el commit, el enlace, y los archivos cambiados o el workflow que ha fallado. +- Los eventos de **GitHub** de pull requests, issues y ejecuciones de workflow llegan recortados a los campos que importan, con las cadenas largas cortadas con una marca: + - Pull requests: el repositorio, el número, el título, el cuerpo (hasta 8 KiB), el autor y su relación con el repositorio, las ramas y el commit, el enlace y cuántos archivos han cambiado. + - Issues: el repositorio, el número, el título, el cuerpo (hasta 8 KiB), el autor, las etiquetas y el enlace. + - Ejecuciones de workflow: el repositorio, el workflow, la ejecución, la rama y el commit, el enlace y las pull requests relacionadas. - **Cualquier otro evento** (otros eventos de GitHub, Stripe, NaN) llega como su cuerpo JSON, si ocupa como mucho 48 KiB y tiene 8 niveles de anidamiento como mucho. Si no, aparece como **Too large** y no arranca ningún run. -Las cadenas largas se cortan con una marca. Los cuerpos de los webhooks tienen un tope de 1 MiB. +Los cuerpos de los webhooks tienen un tope de 1 MiB. ## Qué recibe el emisor de vuelta | Estado | Cuándo | Cuerpo | |---|---|---| | `202` | Se ha creado un run. | `{"status":"run_created","run_id":"..."}` | -| `200` | Aceptado sin run: filtrado, una entrega repetida, un bucle, un run ya activo, una membresía sin runs, o demasiado grande. | `{"status":"ignored"}`, `"duplicate"`, `"loop_suppressed"`, `"skipped_active"`, `"not_accepted"` o `"too_large"` | +| `200` | Definitivo, sin run: filtrado, una entrega repetida, un bucle, un run ya activo, no aceptado (consulta [Entregas](#entregas)), o demasiado grande. | `{"status":"ignored"}`, `"duplicate"`, `"loop_suppressed"`, `"skipped_active"`, `"not_accepted"` o `"too_large"` | | `401` | La firma falta o es incorrecta, o la marca de tiempo tiene demasiado desfase. | Vacío | | `404` | URL desconocida, o un trigger en pausa o una automatización desactivada. | Vacío | | `413` | El cuerpo pasa de 1 MiB. | Vacío | -| `415` | Un webhook de GitHub que no es `application/json`. | Vacío | +| `400` | No se ha podido leer el cuerpo. | Vacío | +| `415` | El cuerpo no es un objeto JSON (por ejemplo, un webhook de GitHub que no se envía como `application/json`), o un evento de Stripe sin un id de evento válido. | Vacío | | `429` | Demasiadas entregas de momento, la automatización ha llegado a sus runs por hora, o tu cola está llena. Reintenta más tarde (`Retry-After`). | Vacío | -| `503` | No disponible temporalmente. Reintenta más tarde. | Vacío | +| `500`, `503` | Algo ha fallado en nuestro lado, o no disponible temporalmente. Reintenta más tarde. | Vacío | Un `200` es definitivo: el emisor no debería reintentarlo. Un `429` o un `503` no se registra, así que un reintento o un reenvío se procesa como nuevo. ## Entregas -La pestaña **Deliveries** lista los webhooks que ha recibido la automatización en los últimos **30 días**, del más reciente al más antiguo, con cuándo llegó, su id de entrega, qué pasó y el run que lanzó: +La pestaña **Deliveries** lista los últimos 50 webhooks que ha recibido la automatización en los últimos **30 días** (hasta 100 con `--limit` en la CLI o `limit` en la API), del más reciente al más antiguo, con cuándo llegó, su id de entrega, qué pasó y el run que lanzó: | Resultado | Significado | |---|---| @@ -229,7 +235,7 @@ La pestaña **Deliveries** lista los webhooks que ha recibido la automatización | **Ignored by a filter** | El filtro de eventos o de autores no ha coincidido. | | **Stopped: caused by one of its own runs** | Se ha parado un [bucle](#bucles). | | **Skipped: a run for the same subject is active** | Consulta [Cuando ya hay un run activo](/es/docs/automations#cuando-ya-hay-un-run-activo). | -| **Not accepted: your membership does not include runs** | Tu plan no tiene inferencia. | +| **Not accepted: your membership does not include runs** | No se ha podido crear el run: tu plan no tiene inferencia, o el workspace está parado o borrado, su agente no está instalado o no tiene clave de inferencia. El emisor no lo reintenta, así que el evento se pierde: mantén el workspace encendido. | | **Too large** | El evento no cabía en lo que puede recibir el agente. | No se guarda ningún payload ni ninguna cabecera. Las entregas con una firma incorrecta nunca se guardan, y las limitadas (límite de peticiones, cola llena, no disponible temporalmente) se responden pidiendo un reintento y no se listan. @@ -241,7 +247,7 @@ Desde la terminal: `nan automations deliveries `. Desde la API: [`GE **Rotate**, en un webhook, te da credenciales nuevas. Marca qué reemplazar: - **New webhook URL**: la URL actual deja de funcionar al momento. -- **New signing secret**: por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. +- **New signing secret** (marcado por defecto): por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. @@ -249,8 +255,8 @@ Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Una automatización que comenta en una pull request puede recibir un webhook por su propio comentario, y una automatización que hace push puede disparar el CI que a su vez la vuelve a lanzar. NaN para esos bucles: -- Todo lo que hace un run lleva su marca: una línea `Nan-Run:` en los commits que sube, una marca oculta en comentarios, reviews y pull requests, y ramas con el nombre `nan-run/...`. Tus propios scripts pueden reenviar `NAN_RUN_ID` como `X-Nan-Run-Id`. -- Un evento causado por un run cuenta un paso más que ese run. Por encima de la **Chain depth** de la automatización (2 por defecto, de 0 a 5), se para y aparece como **Stopped: caused by one of its own runs**. +- Todo lo que hace un run lleva su marca: una línea `Nan-Run:` en los commits que sube, una marca oculta en comentarios, reviews y pull requests, y las ramas `nan-run/...` en las que trabajan los runs (un evento en una rama con otro nombre se reconoce por las demás marcas). Tus propios scripts pueden reenviar `NAN_RUN_ID` como `X-Nan-Run-Id`. +- Un run lanzado por un webhook o una programación está en la profundidad 1, y un evento causado por un run está un nivel más abajo que ese run. Por encima de la **Chain depth** de la automatización (2 por defecto, de 0 a 5) se para y aparece como **Stopped: caused by one of its own runs**. Con 2, un run de un webhook puede lanzar un run más, no dos. Los runs por hora de cada automatización, los 120 runs por hora desde webhooks y programaciones entre todas tus automatizaciones, y **Skip the new event** paran un bucle que se cuele entre las marcas. @@ -285,8 +291,9 @@ Cada programación muestra su próxima ejecución y cómo fue la última: | **Skipped: too many runs waiting** | Tu cola de runs está llena. | | **Skipped: automation disabled** | La automatización no está activada. | | **Skipped: workspace deleted** | Su workspace ya no existe. | -| **Not accepted: your membership does not include runs** | Tu plan no tiene inferencia. | +| **Not accepted: your membership does not include runs** | No se ha podido crear el run: tu plan no tiene inferencia, o el workspace está parado, borrado o no está listo para el agente. | | **Missed (more than 15 minutes late)** | No pudo arrancar a tiempo y se ha saltado. | +| **Error** | Algo ha fallado en nuestro lado. | Un run programado recibe, como datos, la hora para la que estaba programado, la expresión cron y la zona horaria: `scheduled_for`, `cron` y `timezone`. @@ -300,7 +307,7 @@ Abre la entrega en GitHub y mira la respuesta. `401` significa que el secreto qu
-El cuerpo te dice por qué: `ignored` (el filtro no coincidió, a menudo el `ping` o una acción que no incluiste), `skipped_active`, `loop_suppressed` o `duplicate`. La pestaña **Deliveries** muestra lo mismo. +El cuerpo te dice por qué: `ignored` (el `ping`, o el filtro no coincidió, a menudo por una acción que no incluiste), `not_accepted` (a menudo un workspace parado), `skipped_active`, `loop_suppressed` o `duplicate`. La pestaña **Deliveries** muestra lo mismo.
@@ -312,7 +319,7 @@ No. Una URL de webhook pertenece a una automatización de un miembro, y solo lan
-Sí: con un token de plataforma que tenga el scope `automations:run`, llama a `nan automations run` o a la API. Consulta [Lánzala ahora](/es/docs/automations#lánzala-ahora). +Sí: con un token de plataforma que tenga los scopes `automations:run` y `runs` (y `automations:read` para llamarla por su nombre), ejecuta `nan automations run` o llama a la API. Consulta [Tokens para automatizaciones](/es/docs/automations#tokens-para-automatizaciones).
diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index c1e9491..90fc1d7 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -26,6 +26,7 @@ The agent of an automation does not push or post on its own. It **proposes actio - **A workspace that is running**, with Pi or Hermes installed, as for any [run](/docs/runs#before-you-start). - **A plan with inference.** Automation runs use your workspace's inference key and count against your plan's usage like any other run. - **A browser sign-in.** Creating or changing automations, writing secrets and approving actions need you signed in at [cloud.nan.builders](https://cloud.nan.builders/) in your browser, with the email link. A CLI session, an API key or a token can list automations and run them, but never change them. See [Why some actions need the browser](#why-some-actions-need-the-browser). +- **For the GitHub templates, a GitHub token saved as a secret.** The automation cannot be saved without it, so create it first: see [A GitHub token for actions](#a-github-token-for-actions). ## Create an automation @@ -47,7 +48,7 @@ Press **New automation**. A template fills in sensible defaults that you can cha | **Generic webhook** | Runs your instructions on any signed JSON event. | A webhook with the [NaN signature](/docs/webhooks#send-your-own-events). | | **From scratch** | Your own instructions, triggers and actions. | Whatever you add. | -The three GitHub templates act on a repository, so they need a **GitHub repository** and a GitHub token for actions. See [A GitHub token for actions](#a-github-token-for-actions). +The three GitHub templates act on a repository, so they need a **GitHub repository** and a GitHub token for actions. Their **Secrets** card comes with a `GITHUB_TOKEN` row ready: pick your token's secret in it. See [A GitHub token for actions](#a-github-token-for-actions). ### Fill in the settings @@ -59,17 +60,17 @@ The three GitHub templates act on a repository, so they need a **GitHub reposito | **Workspace** | The workspace the agent runs in. | | **Agent** | Pi or Hermes. | | **Folder** | Where the agent works, inside `/home/nan`. | -| **Separate branch** | **On**, **Auto** or **Off**, as in a [run](/docs/runs#where-the-agent-works). | +| **Separate branch** | **On** (the default), **Auto** or **Off (work in the folder directly)**, as in a [run](/docs/runs#where-the-agent-works). | | **Time limit (minutes)** | From 1 to 120. 30 by default. | | **GitHub repository (optional)** | `owner/name`, for example `acme/api`. The repository the actions act on. | | **Secrets** | Which of your [secrets](#secrets) the runs receive, and who receives each one. | | **Approval** | Which kinds of action wait for you. See [Actions and approval](#actions-and-approval). | | **Runs per hour** | From 1 to 120, counted over the last hour. 30 by default. Beyond it, new starts are refused until older runs leave that hour. | | **When a run is active** | **Skip the new event** or **Queue it**. See [When a run is already active](#when-a-run-is-already-active). | -| **Chain depth** | From 0 to 5. 2 by default. How many times a run's own comment or push may start another run. See [Loops](/docs/webhooks#loops). | +| **Chain depth** | From 0 to 5. 2 by default. How deep a chain of runs may go: a webhook or schedule run is at depth 1, and a run started by another run's comment or push is one deeper. With 2, a webhook run can start one more run, not two. See [Loops](/docs/webhooks#loops). | | **Enabled** | Off, nothing starts it: not its triggers, not **Run now**. | -Press **Create automation**. +**Runs per hour**, **When a run is active**, **Chain depth** and **Enabled** are in the **Limits** card. Press **Create automation**. ### Connect what starts it @@ -93,7 +94,7 @@ Open your settings on the portal, **Secrets**, below **Tokens**, and press **Add Secrets are **write-only**: once saved, nobody can read a value back, not even you. The list shows the name, the last 4 characters when the value is long enough, the description and which automations use each one. You can: -- **Replace value**: the new value takes effect on the next run. The old one cannot be recovered. +- **Replace value**: the new value is used by every run that starts from then on, including runs already queued. The old one cannot be recovered. - Change its **Description**. - **Delete** it. Deleting is refused while an automation uses it: remove it from those automations first. @@ -114,7 +115,7 @@ In the **Secrets** card of the automation, **Add a secret** and pick, for each o The same variable name can appear once per option. The usual GitHub setup gives the agent a read-only token and the actions a write token, both as `GITHUB_TOKEN`. -An automation takes up to 20 secrets, and all the secrets one run receives must add up to 48 KiB at most. Secrets go only to the runs of the automations you assign them to: a run you start with `nan run` or from the **Runs** tab never receives any. +An automation takes up to 20 secrets, and all the secrets one run receives must add up to 48 KiB at most. Secrets go only to the runs of the automations you assign them to: a run you start with `nan run` or from a workspace's **Runs** tab never receives any. In run logs, results and proposals, every secret value is masked. A run only starts if every secret it needs is available; otherwise it fails with `secrets_unavailable` and nothing runs without them. @@ -131,7 +132,9 @@ To act on a repository, an automation needs: Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) limited to that one repository, with the permissions the actions need: **Contents** read and write to push, **Pull requests** read and write to open, comment on and review pull requests, and **Issues** read and write to comment on issues and add labels. Save it in **Secrets**, for example as `GITHUB_WRITE_TOKEN`, and assign it to the automation as `GITHUB_TOKEN`, **Actions only**. -If the agent also needs to read the repository (a private one, or to fetch CI logs), create a second token with read permissions only and assign it as `GITHUB_TOKEN`, **Agent**. +If a push may change files under `.github/workflows/` (the **CI failure** template can), the write token also needs **Workflows** read and write. + +If the agent also needs to read the repository (a private one, or to fetch CI logs), create a second token with read permissions only (**Contents**, **Pull requests**, **Issues**, and **Actions** for CI logs) and assign it as `GITHUB_TOKEN`, **Agent**. ## Actions and approval @@ -141,7 +144,7 @@ The agent of an automation works like any run: it reads the code, runs commands | Action | Shown in the portal as | What NaN does | |---|---|---| -| `git_push` | **Push commits** | Pushes the agent's changes to a branch, as one commit on top of the base branch. | +| `git_push` | **Push commits** | Pushes the agent's changes to the branch the proposal names, as one commit on top of the point where the agent started from the base branch. | | `github_create_pr` | **Open pull requests** | Opens a pull request (or a draft). | | `github_pr_comment` | **Comment on pull requests** | Posts a comment on a pull request. | | `github_pr_review` | **Review pull requests** | Posts a review: comment, approve or request changes. | @@ -152,9 +155,9 @@ Up to 10 actions per proposal, always on the automation's **GitHub repository**. Then a separate step, with no agent and no AI model in it, applies the actions with your **Actions only** token. The agent never sees that token. A run that proposes nothing just ends as `succeeded`. -Everything NaN does is marked: pushed commits carry a `Nan-Run: ` line, comments, reviews and pull requests end with a hidden marker, and branches made by runs start with `nan-run/`. The author of a pushed commit is `NaN run `. +Everything NaN does is marked: pushed commits carry a `Nan-Run: ` line, comments, reviews and pull requests end with a hidden marker, and the separate branches runs work on start with `nan-run/` (the push itself goes to the branch the proposal names). The author of a pushed commit is `NaN run `. -A push never carries the inference key of your workspace: a proposal whose changes contain it is refused (`proposal_invalid`), whether it needs approval or not. Your own secrets are your call: if a diff contains the value of one of them, the portal warns you before you approve. +A push never carries the inference key of your workspace: a proposal whose changes contain it is refused (`proposal_invalid`), whether it needs approval or not. Your own secrets are your call: if a diff contains the value of a secret that run receives, the portal warns you before you approve. ### Choose what waits for you @@ -166,7 +169,7 @@ The **Approval** card of an automation has three presets: When something from outside (a webhook or a schedule) can start an automation that applies any action without asking, the editor shows a notice. It never stops you from saving: what happens in your repository is your decision. -**Proposals expire after**: how long a proposal waits for you: **1 hour**, **24 hours**, **72 hours** (the default) or **7 days**. +**Proposals expire after** (shown when at least one kind needs approval): how long a proposal waits for you: **1 hour**, **24 hours**, **72 hours** (the default) or **7 days**. Approval controls what NaN does with your **Actions only** secrets. The agent itself runs in your workspace and can use anything you keep there, such as an SSH key or a `gh` login. @@ -182,37 +185,47 @@ Press **Review** to open the proposal: each action, with its texts in full, the ### Approve it -**Approve and apply** applies the actions that waited for you, in order. Approving needs a browser sign-in. If the proposal changed since you opened it, the portal asks you to review it again. +**Approve and apply** sends the actions that waited for you to be applied, in order. It is enabled once the proposal and its diff have loaded; if the portal cannot show the proposal exactly, only **Reject** is offered. Approving needs a browser sign-in. If the proposal changed since you opened it, the portal asks you to review it again. ### Or reject it -**Reject**, with an optional reason. Nothing that waited for approval is applied. You can also reject from the terminal with `nan runs reject ` or from the API. +**Reject** asks you to confirm, with a **Reason (optional)** field. Nothing that waited for approval is applied. You can also reject from the terminal with `nan runs reject ` or from the API. If nobody decides before it expires, the proposal is dropped and the run ends as `cancelled`. -How a run ends, depending on its proposal: +The actions are applied by a separate **apply run**: it appears in the workspace's **Runs** tab, with no agent and no tokens used, and its log has one line per action. The automation's own run ends as soon as its proposal is settled, before the actions are applied: -| The proposal... | The run ends as | -|---|---| -| has no actions | `succeeded` | -| has only actions that apply automatically | `succeeded`, and the actions are applied | -| has actions that need approval, and you approve | `succeeded`, and the actions are applied | -| is rejected | `cancelled` (`approval_rejected`) | -| expires | `cancelled` (`approval_expired`) | -| is not valid | `failed` (`proposal_invalid`) | +| The proposal... | The automation's run ends as | And then | +|---|---|---| +| has no actions | `succeeded` | Nothing to apply. | +| has only actions that apply automatically | `succeeded` | An apply run applies them. | +| has actions that need approval, and you approve | `succeeded` | An apply run applies them. | +| is rejected | `cancelled` (`approval_rejected`) | Nothing that waited is applied. | +| expires | `cancelled` (`approval_expired`) | Nothing that waited is applied. | +| is not valid | `failed` (`proposal_invalid`) | Nothing is applied. | -Approval starts at the first action that needs it. The actions before it apply automatically while the proposal waits for you, and once you approve, the rest apply in order, including later actions that would not need approval on their own: a pull request needs its branch pushed first. If an action fails, the ones after it are not applied and the run shows `action_failed`. +So `succeeded` means the proposal was accepted, not that every action worked: check the apply run. If an action fails, the apply run ends as `failed` (`action_failed`) and the actions after it are not applied. -The step that applies the actions appears as its own run in the workspace's **Runs** tab, with no agent and no tokens used. Its log has one line per action. +Approval starts at the first action that needs it. The actions before it apply automatically while the proposal waits for you (if one of them fails, the automation's run fails with `action_failed` and there is nothing left to approve), and once you approve, the rest apply in order, including later actions that would not need approval on their own: a pull request needs its branch pushed first. ## When a run is already active **When a run is active** decides what happens if a new event arrives for the same thing while a run for it is still going, including a run waiting for your approval: -- **Skip the new event**: it is skipped. For a pull request, an issue or a workflow, "the same thing" is that pull request, issue or workflow run. For a schedule, it is the automation. The GitHub templates use this, so pushing three commits to a pull request in a row does not start three reviews. -- **Queue it**: a new run is queued as usual. +- **Skip the new event**: it is skipped. The GitHub templates use this, so pushing three commits to a pull request in a row does not start three reviews. +- **Queue it**: a new run is queued as usual. The **Generic webhook** template uses this. + +What counts as "the same thing" depends on what started the run: + +| Started by | The same thing is | +|---|---| +| A GitHub pull request or issue event | That pull request or issue. | +| A GitHub `workflow_run` event | The same repository and branch: a new failure on that branch is skipped while a run for it is active, even from another workflow run. | +| A Stripe event | The same Stripe object (`data.object.id`). | +| Any other event (NaN senders, other GitHub events) | Any event of the automation: one run at a time. | +| A schedule | The automation. | Runs you start yourself are never skipped. @@ -220,7 +233,7 @@ Runs you start yourself are never skipped. ### From the portal -**Run now**, at the top of the automation, opens a box for an optional **Input**: a JSON object (up to 48 KiB) that the agent receives as data next to your instructions, never as instructions. **Start run** takes you to the run's page. +**Run now**, at the top of the automation, opens a box for an optional **Input (JSON object, optional)**: a JSON object (up to 48 KiB) that the agent receives as data next to your instructions, never as instructions. **Start run** takes you to the run's page. ### From the terminal @@ -235,7 +248,7 @@ gh api repos/acme/api/pulls/42 | nan automations run "PR review" --input - nan automations deliveries "PR review" # webhooks received, last 30 days ``` -`--input` takes the JSON itself, `@file.json` or `-` for stdin. Do not put secrets in it: the agent reads it. An automation is named by its name or its id. +`--input` takes the JSON itself, `@file.json` or `-` for stdin. Do not put secrets in it: the agent reads it. An automation is named by its name or its id; with a name, the CLI looks it up first, which needs the `automations:read` scope on a token. The approvals and secrets commands: @@ -253,7 +266,7 @@ There is no command to create or change automations, write secrets or approve: t ### From the API -With a [platform token](#tokens-for-automations) that has the `automations:run` scope, or your API key: +The API takes the automation's id: it is in the address of its page (`cloud.nan.builders/automations/`), in `nan automations ls` and in `GET /v1/automations`. With a [platform token](#tokens-for-automations) that has the `automations:run` scope, or your API key: ```bash curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ @@ -264,7 +277,7 @@ curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ The answer is the run, which you follow like any other ([Create a run and follow it](/docs/runs#create-a-run-and-follow-it)). The full reference is in [API reference → Automations](/docs/api#tag/automations) and [API reference → Approvals](/docs/api#tag/approvals). -### Tokens for automations +## Tokens for automations A platform token carries **scopes**: what it may do. You pick them when you create it, under **Settings → Tokens**: @@ -277,6 +290,8 @@ A platform token carries **scopes**: what it may do. You pick them when you crea New tokens get `runs` and `workspaces:read` by default. A token with `automations:read` or `automations:run` can only be created from a browser sign-in. A token without the scope an endpoint needs gets `403 token_scope_denied`. +For CI, give the token `automations:run` and `runs` (to follow the run and get its outcome), plus `automations:read` if you call the automation by its name. For example: `nan automations run ` in a step with `NAN_TOKEN` set, as in [Use the CLI in CI or on a server](/docs/runs#use-the-cli-in-ci-or-on-a-server). + ## Limits | Limit | Value | @@ -294,9 +309,9 @@ Automation runs share the limits of every run: how many work at once in each wor ## Change or delete an automation -A run keeps the settings it started with. Editing an automation never changes a run that is already queued or working. +Change any setting in the **Settings** tab and press **Save changes**. A run keeps the settings it started with: editing an automation never changes a run that is already queued or working. -**Delete** stops its triggers at once and cancels its queued runs. Runs already working finish, and actions you already approved, or that apply automatically, still complete. Past runs stay in the workspace's history. +**Delete** stops its triggers at once and cancels its queued runs. Runs already working finish, and actions you already approved, or that apply automatically, still complete. Past runs stay in the workspace's history, and a run already waiting for your approval can still be approved or rejected. ## Why some actions need the browser diff --git a/src/content/docs/runs.mdx b/src/content/docs/runs.mdx index 58934fb..0a55582 100644 --- a/src/content/docs/runs.mdx +++ b/src/content/docs/runs.mdx @@ -230,10 +230,10 @@ nan runs cancel | 2 | timed out | | 3 | cancelled | | 4 | the workspace is not ready for it: agent not installed, no inference key or a bad configuration | +| 10 | the run is waiting for your approval (only [automation](/docs/automations#approve-or-reject-a-proposal) runs) | | 64 | usage error, including more than one workspace and no `--ws` | | 65 | not signed in, session expired or not allowed | | 69 | nan.builders unavailable, or the stream was lost (the run goes on, and the id is printed) | -| 10 | the run is waiting for your approval (only [automation](/docs/automations#approve-or-reject-a-proposal) runs) | | 75 | the run was accepted and is still going (`--detach`, or Ctrl-C once) | Under `set -e`, allow for the 75 of `--detach`: diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index 764d482..d519a2c 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -13,7 +13,7 @@ import Callout from '../../components/docs/Callout.astro'; A **trigger** starts an [automation](/docs/automations) without you: a **webhook** when GitHub, Stripe or one of your systems sends an event, or a **schedule** at the times you pick. Each start creates a run in your workspace with the automation's settings, exactly like **Run now**. -An automation can have up to **5 triggers**, of either kind. You manage them in its **Triggers** tab, at [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule**, and on each trigger **Pause** / **Resume**, **Rotate** (webhooks) and delete. Adding, changing and deleting triggers needs a [browser sign-in](/docs/automations#why-some-actions-need-the-browser). +An automation can have up to **5 triggers**, of either kind. You manage them in its **Triggers** tab, at [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule**, and on each trigger **Pause** / **Resume**, **Rotate** (webhooks) and the bin icon to delete it. Adding, changing and deleting triggers needs a [browser sign-in](/docs/automations#why-some-actions-need-the-browser). ## Add a webhook @@ -31,6 +31,8 @@ In the **Triggers** tab, press **+ Webhook** and pick the **Sender**: When the automation comes from a template, the sender and the event filter are already filled in. +For **Stripe**, Stripe gives you the signing secret, and you paste it in this form, so create the endpoint in Stripe first: follow [From Stripe](#from-stripe). + ### Choose which events start a run **Event filter (JSON, optional)**: which events start a run. See [Event filters](#event-filters). For GitHub you can also limit **Who can start it**: see [Only some GitHub users](#only-some-github-users). @@ -40,9 +42,9 @@ When the automation comes from a template, the sender and the event filter are a Press **Add webhook**. The portal shows, **only once**: - **Webhook URL**: `https://api.nan.builders/hooks/nan_whk_...`. The URL itself is a credential: anyone who has it can send to it, so treat it like a password. -- **Signing secret** (GitHub and NaN): what the sender signs each event with. +- **Signing secret** (GitHub and NaN): what the sender signs each event with. For Stripe only the URL is shown: the secret is the one you pasted. -Copy both, then press **I've saved them**. Afterwards the portal only shows the start of the URL. If you lose them, [rotate](#rotate-the-url-or-the-secret) to get new ones. +Copy them, then press **I've saved them**. Afterwards the portal only shows the start of the URL. If you lose them, [rotate](#rotate-the-url-or-the-secret) to get new ones. ### Set it up on the sender @@ -61,9 +63,9 @@ In the repository, open **Settings → Webhooks → Add webhook** (or the same i | **Secret** | The signing secret. | | **Which events** | **Let me select individual events**, and tick the ones your automation handles: **Pull requests** for a review, **Issues** for triage, **Workflow runs** for CI failures. | -GitHub signs each delivery with `X-Hub-Signature-256` and NaN checks it. When you save, GitHub sends a `ping`: it shows as **Ignored by a filter** in **Deliveries** unless your filter takes `ping`. That tells you the URL and the secret work. +GitHub signs each delivery with `X-Hub-Signature-256` and NaN checks it. When you save, GitHub sends a `ping`: it is always ignored and shows as **Ignored by a filter** in **Deliveries**. That tells you the URL and the secret work. -GitHub does not sign a timestamp, so NaN remembers each delivery id (`X-GitHub-Delivery`) and starts at most one run per delivery. **Redeliver** in GitHub sends the same id: if it already started a run, it is answered as a duplicate. +GitHub does not sign a timestamp, so NaN remembers each delivery id (`X-GitHub-Delivery`) and starts at most one run per delivery for 30 days. **Redeliver** in GitHub sends the same id: if it already started a run, it is answered as a duplicate. ## From Stripe @@ -93,7 +95,7 @@ The **NaN (HMAC)** sender is for your own systems: a deploy script, a monitoring |---|---|---| | `X-Nan-Timestamp` | Yes | The current time, in Unix seconds. Events more than 5 minutes off are refused. | | `X-Nan-Signature` | Yes | `v1=` followed by the hex HMAC-SHA256 of `.` with the signing secret. You can send several, separated by commas, while you rotate the secret. | -| `X-Nan-Delivery` | No | A unique id per event, up to 128 of `A-Z a-z 0-9 . _ : -`. The same id never starts two runs. Without it, the id is the hash of the timestamp and the body. | +| `X-Nan-Delivery` | No | A unique id per event, up to 128 of `A-Z a-z 0-9 . _ : -`. The same id never starts two runs. Without it, the id is the hash of the timestamp and the body. The same timestamp and body are always one event, whatever this header says. | | `X-Nan-Event` | No | The event's name, for your [event filter](#event-filters). For example `deploy.finished`. | | `X-Nan-Run-Id` | No | If a run caused this event, its id (it is in `NAN_RUN_ID` inside the run). It feeds the [loop protection](#loops). | @@ -164,7 +166,7 @@ Keep the URL and the signing secret in your systems' secret store, never in a re ## Event filters -The **Event filter** is a JSON object. Each list you fill must match; an empty list, or no filter at all, lets everything through: +The **Event filter** is a JSON object. Each list you fill must match, and an empty list lets everything through. If you leave the field empty, the webhook uses the filter of the automation's template (when the sender is the template's); to let every event through, write `{}`: | Field | Matches | Example | |---|---|---| @@ -190,7 +192,7 @@ Up to 32 entries per list, each of `A-Z a-z 0-9 _ . : -`. An event that does not By default, any event with a valid signature starts a run. On a public repository that means anyone who can open a pull request or an issue. To limit it, tick **Only some GitHub users** under **Who can start it**: - **GitHub logins**: only events sent by these users. Empty means any. -- **Relationship to the repository**: only authors with that relationship (owner, member, collaborator, contributor, first-time contributor...). None ticked means any. +- **Relationship to the repository**: only authors with that relationship (owner, member, collaborator, contributor, first time contributor, first timer, none). None ticked means any. - **Ignore bots**: skip events sent by bot accounts. You can change it later with **Change** on the trigger. @@ -199,29 +201,33 @@ You can change it later with **Change** on the trigger. The event reaches the agent as data, after your instructions and clearly marked as coming from outside. It is never taken as instructions. -- **GitHub** pull request, issue and workflow run events arrive trimmed to the fields that matter: the repository, the number, the title, the body (up to 8 KiB), the author and their relationship to the repository, the branches and commit, the link, and the changed files or the failed workflow. +- **GitHub** pull request, issue and workflow run events arrive trimmed to the fields that matter, with long strings cut with a marker: + - Pull requests: the repository, the number, the title, the body (up to 8 KiB), the author and their relationship to the repository, the branches and commit, the link and how many files changed. + - Issues: the repository, the number, the title, the body (up to 8 KiB), the author, the labels and the link. + - Workflow runs: the repository, the workflow, the run, the branch and commit, the link and the related pull requests. - **Any other event** (other GitHub events, Stripe, NaN) arrives as its JSON body, if it is at most 48 KiB and nested at most 8 levels. Otherwise it is listed as **Too large** and no run starts. -Long strings are cut with a marker. Webhook bodies are capped at 1 MiB. +Webhook bodies are capped at 1 MiB. ## What the sender gets back | Status | When | Body | |---|---|---| | `202` | A run was created. | `{"status":"run_created","run_id":"..."}` | -| `200` | Accepted without a run: filtered out, a repeated delivery, a loop, a run already active, a membership without runs, or too large. | `{"status":"ignored"}`, `"duplicate"`, `"loop_suppressed"`, `"skipped_active"`, `"not_accepted"` or `"too_large"` | +| `200` | Final, without a run: filtered out, a repeated delivery, a loop, a run already active, not accepted (see [Deliveries](#deliveries)), or too large. | `{"status":"ignored"}`, `"duplicate"`, `"loop_suppressed"`, `"skipped_active"`, `"not_accepted"` or `"too_large"` | | `401` | The signature is missing or wrong, or the timestamp is too far off. | Empty | | `404` | Unknown URL, or a paused trigger or a disabled automation. | Empty | | `413` | The body is over 1 MiB. | Empty | -| `415` | A GitHub webhook that is not `application/json`. | Empty | +| `400` | The body could not be read. | Empty | +| `415` | The body is not a JSON object (a GitHub webhook not sent as `application/json`, for example), or a Stripe event without a valid event id. | Empty | | `429` | Too many deliveries for now, the automation reached its runs per hour, or your queue is full. Retry later (`Retry-After`). | Empty | -| `503` | Temporarily unavailable. Retry later. | Empty | +| `500`, `503` | Something failed on our side, or temporarily unavailable. Retry later. | Empty | A `200` is final: the sender should not retry it. A `429` or a `503` is not recorded, so a retry or a redelivery is processed as new. ## Deliveries -The **Deliveries** tab lists the webhooks the automation received in the last **30 days**, newest first, with when it arrived, its delivery id, what happened and the run it started: +The **Deliveries** tab lists the last 50 webhooks the automation received in the last **30 days** (up to 100 with `--limit` in the CLI or `limit` in the API), newest first, with when it arrived, its delivery id, what happened and the run it started: | Outcome | Meaning | |---|---| @@ -229,7 +235,7 @@ The **Deliveries** tab lists the webhooks the automation received in the last ** | **Ignored by a filter** | The event or actor filter did not match. | | **Stopped: caused by one of its own runs** | A [loop](#loops) was stopped. | | **Skipped: a run for the same subject is active** | See [When a run is already active](/docs/automations#when-a-run-is-already-active). | -| **Not accepted: your membership does not include runs** | Your plan has no inference. | +| **Not accepted: your membership does not include runs** | The run could not be created: your plan has no inference, or the workspace is stopped or deleted, its agent is not installed or it has no inference key. The sender does not retry it, so the event is lost: keep the workspace running. | | **Too large** | The event did not fit in what the agent can receive. | No payload and no headers are kept. Deliveries with a bad signature are never stored, and throttled ones (rate limit, full queue, temporarily unavailable) are answered with a retry and not listed. @@ -241,7 +247,7 @@ From the terminal: `nan automations deliveries `. From the API: [`GE **Rotate**, on a webhook, gives you new credentials. Tick what to replace: - **New webhook URL**: the current URL stops working at once. -- **New signing secret**: by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. +- **New signing secret** (ticked by default): by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. The new values are shown once, like when you added the webhook. @@ -249,8 +255,8 @@ The new values are shown once, like when you added the webhook. An automation that comments on a pull request can receive a webhook for its own comment, and an automation that pushes can trigger the CI that triggers it again. NaN stops those loops: -- Everything a run does carries its mark: a `Nan-Run:` line in pushed commits, a hidden marker in comments, reviews and pull requests, and branches named `nan-run/...`. Your own scripts can forward `NAN_RUN_ID` as `X-Nan-Run-Id`. -- An event caused by a run counts one step more than that run. Beyond the automation's **Chain depth** (2 by default, 0 to 5), it is stopped and listed as **Stopped: caused by one of its own runs**. +- Everything a run does carries its mark: a `Nan-Run:` line in pushed commits, a hidden marker in comments, reviews and pull requests, and the `nan-run/...` branches runs work on (an event on a branch with another name is recognised by the other marks). Your own scripts can forward `NAN_RUN_ID` as `X-Nan-Run-Id`. +- A webhook or schedule run is at depth 1, and an event caused by a run is one deeper than that run. Beyond the automation's **Chain depth** (2 by default, 0 to 5) it is stopped and listed as **Stopped: caused by one of its own runs**. With 2, a webhook run can start one more run, not two. The runs per hour of each automation, the 120 runs per hour from webhooks and schedules across your automations, and **Skip the new event** stop a loop that slips past the marks. @@ -285,8 +291,9 @@ Each schedule shows its next run and how the last one went: | **Skipped: too many runs waiting** | Your run queue is full. | | **Skipped: automation disabled** | The automation is not enabled. | | **Skipped: workspace deleted** | Its workspace no longer exists. | -| **Not accepted: your membership does not include runs** | Your plan has no inference. | +| **Not accepted: your membership does not include runs** | The run could not be created: your plan has no inference, or the workspace is stopped, deleted or not ready for the agent. | | **Missed (more than 15 minutes late)** | It could not start in time and was skipped. | +| **Error** | Something failed on our side. | A scheduled run receives, as data, the time it was scheduled for, the cron expression and the time zone: `scheduled_for`, `cron` and `timezone`. @@ -300,7 +307,7 @@ Open the delivery in GitHub and look at the response. `401` means the secret in
-The body tells you why: `ignored` (the filter did not match, often the `ping` or an action you did not include), `skipped_active`, `loop_suppressed` or `duplicate`. The **Deliveries** tab lists the same. +The body tells you why: `ignored` (the `ping`, or the filter did not match, often an action you did not include), `not_accepted` (often a stopped workspace), `skipped_active`, `loop_suppressed` or `duplicate`. The **Deliveries** tab lists the same.
@@ -312,7 +319,7 @@ No. A webhook URL belongs to one automation of one member, and it only starts ru
-Yes: with a platform token that has the `automations:run` scope, call `nan automations run` or the API. See [Run it now](/docs/automations#run-it-now). +Yes: with a platform token that has the `automations:run` and `runs` scopes (and `automations:read` to call it by name), run `nan automations run` or call the API. See [Tokens for automations](/docs/automations#tokens-for-automations).
diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts index 7ca6d77..6b3b312 100644 --- a/src/tests/layouts/automationsDocs.test.ts +++ b/src/tests/layouts/automationsDocs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { readFileSync } from 'node:fs'; -import { createHmac } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { parseFrontmatter } from '@astrojs/markdown-remark'; @@ -161,7 +161,9 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) }); test('secrets: write-only, exposures, reserved names and budgets', () => { - for (const fact of ['16 KiB', '48 KiB', '100', '20', '`GITHUB_TOKEN`', '`OPENAI_API_KEY`', '`NAN_`', '`PI_`', '`HERMES_`', '`secrets_unavailable`']) { + expect(autoFlat).toMatch(/100 (secrets|secretos)/); + expect(autoFlat).toMatch(/20 (secrets|secretos)/); + for (const fact of ['16 KiB', '48 KiB', '`GITHUB_TOKEN`', '`OPENAI_API_KEY`', '`NAN_`', '`PI_`', '`HERMES_`', '`secrets_unavailable`']) { expect(autoFlat, fact).toContain(fact); } expect(autoBody).toContain('**Add secret**'); @@ -206,7 +208,9 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) }); test('webhooks: the answers to the sender match cloud-api (DeliveryOutcome.HTTPStatus)', () => { - for (const status of ['202', '200', '401', '404', '413', '415', '429', '503']) expect(hooksBody, status).toContain(`| \`${status}\` |`); + for (const status of ['202', '200', '400', '401', '404', '413', '415', '429', '500', '503']) { + expect(hooksBody, status).toMatch(new RegExp(`^\\| (\`\\d{3}\`, )?\`${status}\``, 'm')); + } for (const outcome of ['run_created', 'ignored', 'duplicate', 'loop_suppressed', 'skipped_active', 'not_accepted', 'too_large']) { expect(hooksBody, outcome).toContain(`"${outcome}"`); } @@ -236,9 +240,26 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) expect(hooksBody).toMatch(/printf '%s\.%s' "\$TS" "\$BODY" \| openssl dgst -sha256 -hmac "\$SECRET"/); expect(hooksBody).toContain('hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()'); expect(hooksBody).toContain("createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')"); - // The scheme itself: what cloud-api's verifyNaN recomputes. - const sig = createHmac('sha256', 'secret').update('1760000000.{"a":1}').digest('hex'); - expect(sig).toMatch(/^[0-9a-f]{64}$/); + }); + + test('the bash signing line, run as published, gives the signature verifyNaN expects', () => { + const line = /^SIG=.*$/m.exec(hooksBody)![0]; + const out = execFileSync('bash', ['-c', `${line}\nprintf '%s' "$SIG"`], { + env: { ...process.env, TS: '1760000000', BODY: '{"service":"api","version":"1.4.2"}', SECRET: 'whsec_test' }, + }).toString(); + // HMAC-SHA256("whsec_test", "1760000000."), computed independently. + expect(out).toBe('6e718ca934186a37aacbceec46f0226f3567a3017fd84ac1011f01870ff37651'); + }); + + test('review fixes: ping, empty filter, apply runs, CI scopes, skip subjects, chain depth', () => { + expect(hooksBody).toMatch(/`ping`[^.]*(always ignored|se ignora siempre)/); + expect(hooksBody).toContain('`{}`'); + expect(autoFlat).toMatch(/\*\*apply run\*\*|\*\*run de aplicación\*\*/); + expect(autoBody).toContain('`action_failed`'); + expect(autoFlat).toMatch(/`automations:run` (and|y) `runs`/); + expect(autoBody).toContain('`data.object.id`'); + expect(autoBody).toContain('`cloud.nan.builders/automations/`'); + for (const body of [autoFlat, hooksFlat]) expect(body).toMatch(/(depth 1|profundidad 1)/); }); test('never discloses internals, never uses em-dashes', () => { From 59746a0e54ee0f05068422628a40cb968cc698e2 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 22:55:14 +0200 Subject: [PATCH 4/4] docs(automations): skip subjects for other GitHub events, schedule not_accepted causes Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 6 +++--- src/content/docs-es/webhooks.mdx | 4 ++-- src/content/docs/automations.mdx | 6 +++--- src/content/docs/webhooks.mdx | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index ba42a3d..f74f0b8 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -195,7 +195,7 @@ Pulsa **Review** para abrir la propuesta: cada acción, con sus textos completos Si nadie decide antes de que caduque, la propuesta se descarta y el run termina como `cancelled`. -Las acciones las aplica un **run de aplicación** aparte: aparece en la pestaña **Runs** del workspace, sin agente y sin gastar tokens, y su log tiene una línea por acción. El run propio de la automatización termina en cuanto su propuesta queda resuelta, antes de que se apliquen las acciones: +Las acciones las aplica un **run de aplicación** aparte: aparece en la pestaña **Runs** del workspace, sin agente y sin gastar inferencia, y su log muestra cada acción cuando empieza y cuando termina. El run propio de la automatización termina en cuanto su propuesta queda resuelta, antes de que se apliquen las acciones: | La propuesta... | El run de la automatización termina como | Y después | |---|---|---| @@ -221,10 +221,10 @@ Qué cuenta como "lo mismo" depende de qué lanzó el run: | Lanzado por | Lo mismo es | |---|---| -| Un evento de GitHub de pull request o issue | Esa pull request o issue. | +| Un evento de GitHub sobre una pull request o una issue (abierta, comentada, revisada...) | Esa pull request o issue. | | Un evento `workflow_run` de GitHub | El mismo repositorio y la misma rama: un fallo nuevo en esa rama se descarta mientras haya un run activo para ella, aunque venga de otra ejecución de workflow. | | Un evento de Stripe | El mismo objeto de Stripe (`data.object.id`). | -| Cualquier otro evento (emisores NaN, otros eventos de GitHub) | Cualquier evento de la automatización: un run cada vez. | +| Cualquier otro evento (emisores NaN, otros eventos de GitHub) | Cualquier otro evento de ese tipo: un run de ese tipo a la vez por automatización. | | Una programación | La automatización. | Los runs que lanzas tú nunca se descartan. diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index 22fbdd1..be9a073 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -223,7 +223,7 @@ Los cuerpos de los webhooks tienen un tope de 1 MiB. | `429` | Demasiadas entregas de momento, la automatización ha llegado a sus runs por hora, o tu cola está llena. Reintenta más tarde (`Retry-After`). | Vacío | | `500`, `503` | Algo ha fallado en nuestro lado, o no disponible temporalmente. Reintenta más tarde. | Vacío | -Un `200` es definitivo: el emisor no debería reintentarlo. Un `429` o un `503` no se registra, así que un reintento o un reenvío se procesa como nuevo. +Un `200` es definitivo: el emisor no debería reintentarlo. Un `429`, `500` o `503` no se registra, así que un reintento o un reenvío se procesa como nuevo. ## Entregas @@ -291,7 +291,7 @@ Cada programación muestra su próxima ejecución y cómo fue la última: | **Skipped: too many runs waiting** | Tu cola de runs está llena. | | **Skipped: automation disabled** | La automatización no está activada. | | **Skipped: workspace deleted** | Su workspace ya no existe. | -| **Not accepted: your membership does not include runs** | No se ha podido crear el run: tu plan no tiene inferencia, o el workspace está parado, borrado o no está listo para el agente. | +| **Not accepted: your membership does not include runs** | No se ha podido crear el run: tu plan no tiene inferencia, o el workspace está parado o no está listo para el agente. | | **Missed (more than 15 minutes late)** | No pudo arrancar a tiempo y se ha saltado. | | **Error** | Algo ha fallado en nuestro lado. | diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index 90fc1d7..a2ddcb2 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -195,7 +195,7 @@ Press **Review** to open the proposal: each action, with its texts in full, the If nobody decides before it expires, the proposal is dropped and the run ends as `cancelled`. -The actions are applied by a separate **apply run**: it appears in the workspace's **Runs** tab, with no agent and no tokens used, and its log has one line per action. The automation's own run ends as soon as its proposal is settled, before the actions are applied: +The actions are applied by a separate **apply run**: it appears in the workspace's **Runs** tab, with no agent and no inference used, and its log shows each action as it starts and ends. The automation's own run ends as soon as its proposal is settled, before the actions are applied: | The proposal... | The automation's run ends as | And then | |---|---|---| @@ -221,10 +221,10 @@ What counts as "the same thing" depends on what started the run: | Started by | The same thing is | |---|---| -| A GitHub pull request or issue event | That pull request or issue. | +| A GitHub event about a pull request or an issue (opened, commented, reviewed...) | That pull request or issue. | | A GitHub `workflow_run` event | The same repository and branch: a new failure on that branch is skipped while a run for it is active, even from another workflow run. | | A Stripe event | The same Stripe object (`data.object.id`). | -| Any other event (NaN senders, other GitHub events) | Any event of the automation: one run at a time. | +| Any other event (NaN senders, other GitHub events) | Every other event of that kind: one such run at a time per automation. | | A schedule | The automation. | Runs you start yourself are never skipped. diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index d519a2c..f8c2199 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -223,7 +223,7 @@ Webhook bodies are capped at 1 MiB. | `429` | Too many deliveries for now, the automation reached its runs per hour, or your queue is full. Retry later (`Retry-After`). | Empty | | `500`, `503` | Something failed on our side, or temporarily unavailable. Retry later. | Empty | -A `200` is final: the sender should not retry it. A `429` or a `503` is not recorded, so a retry or a redelivery is processed as new. +A `200` is final: the sender should not retry it. A `429`, `500` or `503` is not recorded, so a retry or a redelivery is processed as new. ## Deliveries @@ -291,7 +291,7 @@ Each schedule shows its next run and how the last one went: | **Skipped: too many runs waiting** | Your run queue is full. | | **Skipped: automation disabled** | The automation is not enabled. | | **Skipped: workspace deleted** | Its workspace no longer exists. | -| **Not accepted: your membership does not include runs** | The run could not be created: your plan has no inference, or the workspace is stopped, deleted or not ready for the agent. | +| **Not accepted: your membership does not include runs** | The run could not be created: your plan has no inference, or the workspace is stopped or not ready for the agent. | | **Missed (more than 15 minutes late)** | It could not start in time and was skipped. | | **Error** | Something failed on our side. |