diff --git a/src/content/docs-es/apps.md b/src/content/docs-es/apps.md index 0591bb3e..5cd89072 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: 25 +order: 26 group: Guías --- diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index 404d9fad..553a02eb 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -21,6 +21,8 @@ Qué puede lanzar una automatización: 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. +Para enterarte en Slack, Microsoft Teams, Discord o tu propio endpoint cuando un run espera tu aprobación o falla, añade un [canal de notificaciones](/es/docs/notifications). + ## Antes de empezar - **Un workspace encendido**, con el agente que quieras instalado (Pi, Pi + Gentle Shell o Hermes), como para cualquier [run](/es/docs/runs#elige-el-agente). @@ -475,8 +477,10 @@ Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crear | `automations:write` | Crear, cambiar, desactivar y borrar automatizaciones y sus triggers. **Puede darle cualquiera de tus secretos a un agente.** | | `secrets:read` | Leer los metadatos de tus secretos: nombres, pistas, descripciones y qué automatizaciones los usan. Nunca los valores. | | `secrets:write` | Crear secretos, reemplazar sus valores, cambiar sus descripciones y borrarlos. **Puede reemplazar cualquiera de tus secretos.** | +| `notifications:read` | Leer tus [canales de notificaciones](/es/docs/notifications) y sus envíos. Nunca sus URLs. | +| `notifications:write` | Crear, cambiar, probar y borrar canales de notificaciones y rotar sus secretos de firma. **Puede enviar tus notificaciones a cualquier URL.** | -Los tokens nuevos llevan `runs` y `workspaces:read` por defecto. Un token con un scope `automations:` o `secrets:` solo se puede crear en el portal, con sesión iniciada en el navegador, no desde la CLI. Los tres últimos scopes aparecen ahí solo si tu cuenta tiene la API de escritura, con un aviso. Los scopes son independientes: `secrets:write` no incluye `secrets:read`, ni `automations:write` incluye `automations:read`, así que un token que gestiona secretos desde la CLI suele llevar los dos, y uno que cambia automatizaciones por su nombre también necesita `automations:read` para buscar el nombre. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. +Los tokens nuevos llevan `runs` y `workspaces:read` por defecto. Un token con un scope `automations:`, `secrets:` o `notifications:` solo se puede crear en el portal, con sesión iniciada en el navegador, no desde la CLI. `automations:write` y los scopes `secrets:` aparecen ahí solo si tu cuenta tiene la API de escritura, y los scopes `notifications:` solo si además tiene notificaciones, con un aviso. Los scopes son independientes: `secrets:write` no incluye `secrets:read`, ni `automations:write` incluye `automations:read`, así que un token que gestiona secretos desde la CLI suele llevar los dos, y uno que cambia automatizaciones por su nombre también necesita `automations:read` para buscar el nombre. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. Un agente o un script con un token con `secrets:write` o `automations:write` puede cambiar tus secretos o tus automatizaciones. Con `automations:write` puede crear una automatización que reciba cualquiera de tus secretos y se ejecute sin esperar tu aprobación, lo que equivale a leerlos todos. Da estos scopes solo a los tokens que los necesiten, mantenlos fuera de los workspaces, ponles caducidad y revócalos cuando acabes. @@ -512,7 +516,7 @@ Al borrarla con **Delete**, sus triggers se detienen al momento y se cancelan su Una automatización decide cuáles de tus secretos recibe un agente y qué puede hacer en tu repositorio sin preguntar. Estas acciones siempre te necesitan a ti, con sesión iniciada en el navegador: - **Aprobar una propuesta.** Aprobar es tu revisión de un cambio concreto que hizo un agente. Si una credencial que puede tener un script o un agente pudiera aprobar, un agente podría aprobar su propia propuesta. -- **Crear un token con un scope `automations:` o `secrets:`.** Así una sesión de la CLI robada no puede convertirse en un token de larga duración, y cada uno de esos tokens lo creaste tú, después del aviso. +- **Crear un token con un scope `automations:`, `secrets:` o `notifications:`.** Así una sesión de la CLI robada no puede convertirse en un token de larga duración, y cada uno de esos tokens lo creaste tú, después del aviso. Crear, cambiar o borrar automatizaciones, triggers y secretos también necesita el navegador mientras tu cuenta no tenga la API de escritura. Con ella, tu sesión de la CLI y los tokens con los scopes de escritura también pueden hacerlo: quién tiene esas credenciales lo decides tú, así que dalas solo donde confíes en quien las tiene (consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api)). diff --git a/src/content/docs-es/examples.md b/src/content/docs-es/examples.md index ff92d520..6ecb46e5 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: 24 +order: 25 group: Guías --- diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index c8be9e05..d37851f4 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -198,6 +198,8 @@ nan automations triggers add "PR review" -f webhook.yaml # imprime la URL y el El valor de un secreto se lee de un archivo o de una tubería, nunca de la línea de comandos, y nada lo vuelve a imprimir. Todas las opciones de `nan secrets`, cómo rotar un secreto y los errores que puedes ver están en [Secretos](/es/docs/secrets#gestiona-los-secretos-desde-la-cli). Mientras tu cuenta no tenga la API de escritura, estos comandos te lo dicen y te mandan al portal. Aprobar propuestas siempre se hace en el portal, con sesión iniciada en tu navegador. +Desde la v0.1.34, donde tu cuenta tiene notificaciones, `nan notifications` gestiona los canales que te avisan en Slack, Microsoft Teams, Discord o tu propio endpoint cuando una automatización necesita tu aprobación o falla: `nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt`, y luego `nan notifications test "team alerts"`. La URL del webhook se lee de un archivo o de una tubería, nunca de la línea de comandos. Todos los comandos están en [Notificaciones](/es/docs/notifications#gestiona-los-canales-desde-la-cli). + Estos comandos usan primero tu sesión de `nan auth login` (gana a `NAN_TOKEN`; `--token-file` gana a las dos), o un token de plataforma con los scopes que necesitan: `secrets:read` y `secrets:write` para secretos, `automations:write` (y `automations:read` para usar nombres) para automatizaciones; consulta [Tokens para automatizaciones](/es/docs/automations#tokens-para-automatizaciones). Inicia sesión en tu propia máquina, nunca dentro de un workspace: los agentes de ahí podrían leer la sesión y cambiar tus secretos. Todos los comandos, los archivos YAML y los avisos están en [Gestiónalas desde la CLI o la API](/es/docs/automations#gestiónalas-desde-la-cli-o-la-api). 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). diff --git a/src/content/docs-es/notifications.mdx b/src/content/docs-es/notifications.mdx new file mode 100644 index 00000000..68b4ade4 --- /dev/null +++ b/src/content/docs-es/notifications.mdx @@ -0,0 +1,477 @@ +--- +title: Notificaciones +description: Entérate en Slack, Microsoft Teams, Discord o tu propio endpoint HTTPS cuando una automatización necesita tu aprobación, falla, se desactiva o su webhook no deja de fallar. +order: 23 +group: Guías +parent: automations +--- + +import Callout from '../../components/docs/Callout.astro'; + +# Notificaciones. + +Un **canal de notificaciones** es un sitio donde NaN te cuenta qué hacen tus [automatizaciones](/es/docs/automations), para que no tengas que tener el portal abierto: un canal de Slack, un canal de Microsoft Teams, un canal de Discord o un endpoint HTTPS tuyo. Añades el canal una vez, eliges los eventos que recibe y NaN publica un mensaje corto con un enlace al portal cada vez que ocurre uno de ellos. + +Solo notifican los runs de automatizaciones. Un run que lanzas tú con `nan run` o desde la pestaña **Runs** de un workspace nunca envía una notificación. + +En resumen: consigue la URL de un webhook de [Slack](#consigue-la-url-de-un-webhook-de-slack), [Microsoft Teams](#consigue-la-url-de-un-webhook-de-microsoft-teams) o [Discord](#consigue-la-url-de-un-webhook-de-discord), [añade el canal](#añade-un-canal) en **Settings → Notifications** y pulsa **Test**. + +Las notificaciones se están abriendo poco a poco. Si no ves **Notifications** en **Settings**, tu cuenta aún no las tiene. + +## Canales + +Cada canal tiene: + +| Parte | Qué es | +|---|---| +| **Tipo** | `slack`, `teams`, `discord` o `https`. Se fija al crearlo. | +| **Nombre** | Cómo lo reconoces, por ejemplo `team alerts`. De 1 a 64 caracteres, único en tu cuenta (sin distinguir mayúsculas). | +| **URL** | Dónde publica NaN. De solo escritura: nunca se vuelve a mostrar, solo una pista. Mira [Las URLs son credenciales](#las-urls-son-credenciales). | +| **Eventos** | Lo que recibe, para todas tus automatizaciones o para una. Mira [Eventos](#eventos). | + +Puedes tener hasta 10 canales. Cada uno envía el mensaje en el formato que espera su proveedor: + +| Tipo | Lo que recibes | +|---|---| +| **Slack** | Un mensaje con el título como enlace al portal, una línea con el evento, el estado del run y el código de error, y el resumen del run cuando lo tiene. | +| **Microsoft Teams** | Una adaptive card con el título, el evento, el estado y el código de error, el resumen y un botón **Open in NaN**. | +| **Discord** | Un embed con el título como enlace, el evento, el estado y el código de error, y el resumen. Nunca menciona a nadie. | +| **HTTPS** | Un `POST` JSON firmado a tu endpoint, con el [esquema documentado](#contenido-del-mensaje). | + +### Consigue la URL de un webhook de Slack + +1. Ve a [api.slack.com/apps](https://api.slack.com/apps), pulsa **Create New App**, **From scratch**, ponle nombre (por ejemplo `NaN`) y elige tu workspace de Slack. +2. En **Incoming Webhooks**, activa **Activate Incoming Webhooks**. +3. Pulsa **Add New Webhook to Workspace**, elige el canal y pulsa **Allow**. +4. Copia la **Webhook URL**. Tiene esta forma: `https://hooks.slack.com/services/T…/B…/…`. + +### Consigue la URL de un webhook de Microsoft Teams + +Teams publica webhooks con su app **Workflows**: + +1. En el canal de Teams, abre **…** (más opciones) y elige **Workflows**. +2. Elige la plantilla **Post to a channel when a webhook request is received** (en algunos tenants, **Send webhook alerts to a channel**), ponle nombre y confirma el equipo y el canal. +3. Copia la URL que muestra Teams al final. Está en un host que termina en `logic.azure.com`, `powerplatform.com` o `webhook.office.com`. + +NaN envía una adaptive card, que es lo que publica esa plantilla. La URL de un conector **Incoming Webhook** antiguo (en `webhook.office.com`) se sigue aceptando, pero Microsoft está retirando esos conectores: usa Workflows. + +### Consigue la URL de un webhook de Discord + +1. En Discord, abre **Server Settings**, luego **Integrations** y luego **Webhooks**. Necesitas el permiso **Manage Webhooks**. +2. Pulsa **New Webhook** y abre el webhook que crea. Ponle nombre, elige el canal y pulsa **Copy Webhook URL**. Tiene esta forma: `https://discord.com/api/webhooks/…/…`. + +### Tu propio endpoint HTTPS + +Cualquier URL `https` en el puerto 443 con un nombre DNS público. NaN firma cada petición con un **secreto de firma** que crea para el canal y te muestra **una sola vez**, al crear el canal: mira [Verifica la firma](#verifica-la-firma). Los canales de Slack, Teams y Discord no tienen secreto de firma. + +## Eventos + +| Evento | En el portal | Cuándo se envía | Activo por defecto | +|---|---|---|---| +| `approval.pending` | **Approval needed** | Un run de una automatización espera tu aprobación. | Sí | +| `run.failed` | **Run failed** | Un run de una automatización falló o agotó su tiempo (`timed_out`), o falló aplicar las acciones que aprobaste. | Sí | +| `automation.disabled` | **Automation turned off** | Se desactivó una automatización: tú, porque se borró su workspace o porque se borró un secreto que usa. | Sí | +| `trigger.failing` | **Webhook signatures failing** | El webhook de una automatización no deja de recibir peticiones con una firma incorrecta: unas 10 seguidas en una hora. Como mucho una vez al día por trigger. | Sí | +| `run.finished` | **Run succeeded** | Un run de una automatización terminó bien. | No: actívalo tú | + +Un run cancelado nunca notifica, ni tampoco un run que lanzas tú (fuera de una automatización). `run.finished` viene desactivado porque una automatización con mucho uso publicaría en cada run: actívalo donde lo quieras. + +**Para todas tus automatizaciones o para una.** Cada evento de un canal es para **todas tus automatizaciones** (también las que crees más adelante) o para **una automatización**. Al crear un canal puedes limitar sus eventos por defecto a una automatización, y luego mezclar ambos: por ejemplo, `run.failed` para todas y `run.finished` solo para `nightly report`. Un canal admite hasta 50 de estas suscripciones. Al borrar una automatización se borran las suscripciones que la nombraban. + +El mismo evento se envía una sola vez por canal, aunque el canal esté suscrito a él para todas las automatizaciones y para esa en concreto. + +## Gestiona los canales en el portal + +Tus canales están en los ajustes de [cloud.nan.builders](https://cloud.nan.builders/): abre **Settings** y luego **Notifications**, justo debajo de **Secrets**. Los ajustes de cada automatización también tienen una tarjeta **Notifications** que lista los canales que la cubren, con **Manage notifications** para venir aquí. + +### Añade un canal + +1. Pulsa **Add channel**. En **Where**, elige **Slack**, **Microsoft Teams**, **Discord** o **HTTPS endpoint**. +2. Escribe un **Name** y pega la URL en el campo de URL de ese tipo (**Slack URL**, por ejemplo). Dónde conseguirla: [Slack](#consigue-la-url-de-un-webhook-de-slack), [Microsoft Teams](#consigue-la-url-de-un-webhook-de-microsoft-teams), [Discord](#consigue-la-url-de-un-webhook-de-discord) o [tu propio endpoint](#tu-propio-endpoint-https). +3. Revisa los **Events**: **Approval needed**, **Run failed**, **Automation turned off** y **Webhook signatures failing** vienen activados; **Run succeeded** viene desactivado, actívalo si lo quieres. En **Automations**, deja **All automations, including new ones** o elige una. +4. Guarda. En un **HTTPS endpoint** el portal te muestra el **secreto de firma** una sola vez: cópialo ya al almacén de secretos de tu endpoint, no se vuelve a mostrar. + +Después pulsa **Test**: NaN envía un mensaje de prueba al momento y te dice si llegó. + +### Prueba, cambia, rota, borra + +- **Test** envía un mensaje de prueba al canal. Solo funciona en un canal encendido, y puedes enviar hasta 5 seguidos y luego uno por minuto, más o menos. En Teams, `delivered` significa que el workflow aceptó el mensaje: luego el workflow lo publica, y si falla, el error está en el historial de ejecuciones del workflow. +- **Events** cambia qué eventos recibe el canal, y para qué automatizaciones. +- **Edit** cambia el nombre del canal o reemplaza su URL (**New URL (optional)**). El tipo no se puede cambiar: añade un canal nuevo. +- **Rotate secret** (solo HTTPS) crea un secreto de firma nuevo y lo muestra una sola vez. El antiguo sigue firmando también durante 24 horas, para que puedas actualizar tu endpoint sin perder ninguna petición. +- **Turn off** y **Turn on** paran y reanudan un canal sin borrarlo. +- **Delete** te pide confirmación, y el canal y sus suscripciones desaparecen para siempre. +- **Deliveries** muestra los envíos de los últimos 30 días: evento, estado, estado HTTP y código de error. Nunca el mensaje. + +Cada canal muestra su estado: **On**, **Deliveries failing** (los últimos envíos fallaron, sigue encendido), **Off** (lo apagaste tú), **Turned off: failing** o **Turned off: URL refused** (lo apagó NaN, mira [Cuando un canal falla](#cuando-un-canal-falla)). + +## Resúmenes y límites + +NaN mantiene cada canal legible y nunca inunda a un proveedor: + +- **Resúmenes (digests).** Cuando hay más de 3 notificaciones pendientes para un canal a la vez, o más de las que aún puede recibir en el minuto actual, llegan como **un solo mensaje de resumen**: cuántas hay de cada evento, y hasta 10 de ellas con sus enlaces. +- **Por canal:** como mucho 30 mensajes por minuto y 1.000 al día. +- **Por cuenta:** como mucho 3.000 mensajes al día entre todos tus canales. + +Por encima del límite por minuto, los mensajes esperan y se envían el minuto siguiente (como resumen si son varios). Por encima de un límite diario, las demás notificaciones de ese día se descartan y el registro de envíos muestra `rate_limited`. Los mensajes de prueba cuentan como mensajes. + +Una notificación que no se entrega en 24 horas se descarta: una alerta tardía es peor que ninguna. + +## Cuando un canal falla + +Cada mensaje se reintenta cuando el fallo puede ser pasajero: un timeout, un error de conexión o de TLS, un fallo de DNS, `408`, `429` o un `5xx`. Se intenta hasta 5 veces, tras 1 minuto, 5 minutos, 30 minutos y 2 horas. Cualquier otro `4xx`, y cualquier redirección, falla al momento: la URL está mal o se revocó. + +**Apagado tras 20 fallos.** Tras **20 intentos fallidos seguidos**, el canal se apaga con el motivo `failing`, y sus mensajes pendientes se descartan. El portal muestra **Turned off: failing**, y `nan notifications ls` también lo muestra. Para arreglarlo: + +1. Mira el error en el registro de **Deliveries** (`http_4xx`, `timeout`, `dns`, etc.). +2. Si el webhook se borró o se revocó en el proveedor, crea uno nuevo y **reemplaza la URL**: eso vuelve a activar el canal. +3. Si el problema estaba en tu lado y ya está resuelto, pulsa **Turn on** (o `nan notifications edit NAME --enable`). Luego pulsa **Test**. + +Volver a encenderlo o reemplazar la URL pone a cero el contador de fallos. + +**Apagado como `invalid_url`.** Si el host de la URL empieza a resolver a una dirección que NaN rechaza (una privada o reservada, mira [Qué URLs se aceptan](#qué-urls-se-aceptan)), el canal se apaga al momento con el motivo `invalid_url` (**Turned off: URL refused** en el portal). Reemplaza la URL para volver a encenderlo. + +Un canal que apagaste tú (motivo `member`, **Off** en el portal) sigue apagado hasta que lo enciendas, aunque reemplaces su URL. + +## Verifica la firma + +Cada petición a un canal **HTTPS** lleva estas cabeceras, con el mismo esquema de firma que los [webhooks que tú envías a NaN](/es/docs/webhooks#envía-tus-propios-eventos): + +| Cabecera | Valor | +|---|---| +| `X-Nan-Event` | El evento: `approval.pending`, `run.failed`, `run.finished`, `automation.disabled`, `trigger.failing`, `digest` o `test`. | +| `X-Nan-Delivery` | Un id único por notificación, el mismo en cada reintento. Úsalo para ignorar repeticiones. | +| `X-Nan-Timestamp` | Cuándo se firmó, en segundos Unix. | +| `X-Nan-Signature` | `v1=` seguido del HMAC-SHA256 en hexadecimal de `.` con el secreto de firma (la clave es la cadena entera `nan_nsec_…`). Durante las 24 horas tras una rotación hay dos, separadas por una coma, la nueva primero. | + +Acepta una petición cuando **cualquier** valor `v1=` coincide y el timestamp no se desvía más de 5 minutos. Calcula el HMAC sobre el cuerpo exactamente como llega, byte a byte, antes de parsearlo. Responde rápido con cualquier `2xx`; el cuerpo de la respuesta se ignora. + +Para comprobar tu código: con el secreto `nan_nsec_test`, el timestamp `1700000000` y el cuerpo `{"a":1}`, la firma es `v1=5c39c853e058b14c0d0e29bbdeb6d0dc325318231d07d1df377a284c89ad4e01`. Cuando tu endpoint ya verifique firmas, pulsa **Test**: el mensaje de prueba va firmado como cualquier otro. + +En bash, para comprobar una petición que guardaste (cuerpo en `body.json`): + +```bash +SECRET="nan_nsec_..." # el secreto de firma del canal +TS="1760436000" # la cabecera X-Nan-Timestamp +SIGS="v1=...,v1=..." # la cabecera X-Nan-Signature +EXPECTED=$(printf '%s.' "$TS" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') + +NOW=$(date +%s) +if [ $((NOW - TS)) -gt 300 ] || [ $((TS - NOW)) -gt 300 ]; then echo "too old"; exit 1; fi +case ",$SIGS," in *",v1=$EXPECTED,"*) echo "valid" ;; *) echo "invalid"; exit 1 ;; esac +``` + +En Python: + +```python +import hashlib, hmac, time + +SECRET = b"nan_nsec_..." + +def verify(body: bytes, timestamp: str, signature_header: str) -> bool: + try: + ts = int(timestamp) + except (TypeError, ValueError): + return False + if abs(time.time() - ts) > 300: + return False + expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() + return any( + part.startswith("v1=") and hmac.compare_digest(part[3:], expected) + for part in signature_header.split(",") + ) + +# En tu handler, con el cuerpo en bruto: +# verify(request_body, headers["X-Nan-Timestamp"], headers["X-Nan-Signature"]) +``` + +En Node.js: + +```js +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +const secret = process.env.NAN_SIGNING_SECRET; // nan_nsec_... + +function verify(body, timestamp, header) { + const ts = Number(timestamp); + if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; + const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')); + return (header ?? '').split(',').some((part) => { + const got = Buffer.from(part.trim().replace(/^v1=/, '')); + return part.trim().startsWith('v1=') && got.length === expected.length && timingSafeEqual(got, expected); + }); +} + +createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = Buffer.concat(chunks); + if (!verify(body, req.headers['x-nan-timestamp'], req.headers['x-nan-signature'])) { + res.writeHead(401).end(); + return; + } + const event = JSON.parse(body); + console.log(event.event, event.text, event.url); + res.writeHead(204).end(); + }); +}).listen(8080); +``` + +La entrega es al menos una vez: la misma notificación puede llegar dos veces (por ejemplo, tras un error de red cuando ya se había recibido). Ignora un `X-Nan-Delivery` que ya hayas procesado. + +## Contenido del mensaje + +Un canal **HTTPS** recibe peticiones `POST` con `Content-Type: application/json` y `User-Agent: NaN-Notifications/1`. El cuerpo lleva `version: 1`, y los campos que no aplican a un evento son `null`: + +```json +{ + "version": 1, + "id": "…", + "event": "run.failed", + "created_at": "2026-10-14T10:00:00Z", + "automation": { "id": "…", "name": "PR review" }, + "run": { + "id": "…", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "trigger": null, + "reason": null, + "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "text": "Run failed: PR review (agent_failed)" +} +``` + +| Campo | Qué contiene | +|---|---| +| `id` | El id de la notificación, el mismo que `X-Nan-Delivery`. | +| `event` | Uno de los [eventos](#eventos). | +| `automation` | `{ "id", "name" }` de la automatización. | +| `run` | En los eventos de run: `id`, `state` (`awaiting_approval`, `succeeded`, `failed` o `timed_out`), `error_code`, `phase` y `summary`. Si no, `null`. | +| `run.summary` | El resumen del run tal como lo muestra el portal, con los secretos ya enmascarados y sin caracteres de control, como mucho 280 caracteres. Solo en `run.finished` y `run.failed`. | +| `trigger` | `{ "id", "kind" }` en `trigger.failing`. | +| `reason` | `automation.disabled`: `member`, `workspace_deleted` o `secret_deleted`. `trigger.failing`: `bad_signature`. | +| `url` | Dónde mirar en el portal: el run en los eventos de run, la automatización en los demás. | +| `text` | Un título de una línea, en inglés, por ejemplo `Approval needed: PR review`, `Run timed out: PR review` o `Automation disabled: PR review (its workspace was deleted)`. | + +Un **resumen** (`digest`) agrupa varias notificaciones: + +```json +{ + "version": 1, + "id": "…", + "event": "digest", + "created_at": "2026-10-14T10:00:00Z", + "count": 3, + "events": { "approval.pending": 1, "run.failed": 2 }, + "items": [ + { + "event": "run.failed", + "automation": { "id": "…", "name": "PR review" }, + "run": { "id": "…", "state": "timed_out", "error_code": "timed_out", "phase": "propose", "summary": "…" }, + "url": "https://cloud.nan.builders/workspaces/…/runs/…" + } + ], + "url": "https://cloud.nan.builders/automations", + "text": "3 notifications: 1 approval needed, 2 runs failed" +} +``` + +`count` es cuántas notificaciones agrupa, `events` cuántas hay de cada evento, e `items` hasta 10 de ellas (el ejemplo muestra una). + +Un mensaje de **prueba**: + +```json +{ + "version": 1, + "id": "…", + "event": "test", + "created_at": "2026-10-14T10:00:00Z", + "url": "https://cloud.nan.builders/settings#notifications", + "text": "Test notification from NaN" +} +``` + +La versión 1 puede ganar campos nuevos; ignora los que no conozcas. + +## Seguridad + +### Las URLs son credenciales + +Cualquiera que tenga la URL de un webhook de Slack, Teams o Discord puede publicar en ese canal, así que NaN trata cada URL de canal como un [secreto](/es/docs/secrets): + +- Es de **solo escritura**: se cifra con una clave que solo pertenece a tu cuenta, y el portal, la CLI y la API nunca la devuelven. Ves una pista en su lugar, el host y los 4 últimos caracteres de la ruta: `hooks.slack.com/…/abcd`. +- Nunca aparece en un error, un log ni la lista de envíos. +- Desde la CLI se lee de un archivo o de una tubería, nunca de la línea de comandos. + +Si se filtra la URL de un webhook, borra el webhook en el proveedor, crea uno nuevo y reemplaza la URL en NaN. + +### Lo que un mensaje nunca lleva + +Los mensajes llevan nombres, estados, códigos de error, ids y enlaces. **Nunca** llevan: + +- el valor de ningún secreto, ni la URL de un canal; +- el cuerpo ni las cabeceras de la petición de webhook que lanzó un run; +- la salida del agente, más allá del resumen corto del run (como mucho 280 caracteres, con los secretos enmascarados). + +El texto que escribes tú (el nombre de una automatización, un resumen) se escapa para cada proveedor: no puede mencionar a nadie, avisar a un rol ni formar un enlace. + +### Qué URLs se aceptan + +Los servidores de NaN llaman a estas URLs, así que siguen reglas estrictas, que se comprueban al guardar la URL y otra vez en cada envío: + +- Solo `https`, en el puerto 443. Sin usuario ni contraseña en la URL, sin fragmento, sin dirección IP: un nombre DNS con al menos dos etiquetas. +- Ni los hosts de NaN (`nan.builders`, `helmcode.com` y sus subdominios) ni nombres locales (`localhost`, `.local`, `.internal`, `.lan`, `.home.arpa`, `.svc`). +- Todas las direcciones a las que resuelve el nombre deben ser públicas: se rechazan las privadas, de loopback, link-local, compartidas y reservadas, y también las de NaN. +- Las URLs de Slack deben estar en `hooks.slack.com` con una ruta que empiece por `/services/`. Las de Teams, en un subdominio de `webhook.office.com`, `logic.azure.com` o `powerplatform.com`. Las de Discord, en `discord.com` (o `discordapp.com`, `canary.discord.com`, `ptb.discord.com`) con una ruta que empiece por `/api/webhooks/`. +- No se siguen redirecciones: un `3xx` es un fallo. + +Una URL rechazada recibe un único error genérico que dice qué regla incumple, nunca lo que enviaste, y un nombre que no resuelve recibe la misma respuesta que uno que resuelve a una dirección rechazada. + +## Gestiona los canales desde la CLI + +Con la [CLI de NaN](/es/docs/nan-cli) **0.1.34** o posterior, donde tu cuenta tiene notificaciones y la API de escritura: + +```bash +nan notifications ls # tipo, nombre, pista de la URL, estado +nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt +pbpaste | nan notifications add "ops endpoint" --kind https --url-stdin --event run.failed --event run.finished +nan notifications add "nightly" --kind discord --url-file ./discord-url.txt --automation "nightly report" +nan notifications show "team alerts" +nan notifications test "team alerts" +nan notifications subscriptions "team alerts" # las lista +nan notifications subscriptions "team alerts" --add "run.finished=nightly report" +nan notifications subscriptions "team alerts" --set run.failed --set approval.pending +nan notifications edit "team alerts" --url-file ./new-url.txt +nan notifications edit "team alerts" --enable +nan notifications rotate "ops endpoint" # muestra el nuevo secreto de firma una vez +nan notifications deliveries "team alerts" --limit 50 +nan notifications rm "team alerts" +``` + +- **La URL nunca va en la línea de comandos**, donde se quedaría en el historial de tu shell. `--url-file PATH` la lee de un archivo, `--url-stdin` de una tubería. No hay flag `--url`. +- `add` usa los eventos por defecto salvo que pases `--event` (repetible, o separados por comas) o `--no-events`; `--automation` (un nombre o un id) los limita a una automatización. +- En un canal HTTPS, `add` y `rotate` muestran el secreto de firma **una sola vez**, con un aviso para que lo guardes ya. Con `--json` va dentro del JSON, como `signing_secret`. +- `subscriptions` sin flags las lista. `EVENT` solo significa todas tus automatizaciones, `EVENT=AUTOMATION` una de ellas. `--set` reemplaza el conjunto entero, `--add` y `--remove` lo cambian, `--clear` las quita todas. +- `edit` cambia el nombre (`--name`), enciende o apaga el canal (`--enable`, `--disable`) o reemplaza la URL. Reemplazar la URL o `--enable` vuelve a activar un canal que apagó NaN y pone a cero su contador de fallos. +- `test` sale con código 0 cuando el mensaje se entregó y con 1 cuando no, con el motivo. +- `deliveries` muestra los últimos 20 envíos por defecto (`--limit` de 1 a 100). +- `rm` pregunta antes de borrar; `--yes` se salta la pregunta en scripts. +- Los comandos aceptan el nombre o el id de un canal, y `--json` imprime JSON. +- Donde tu cuenta no tiene notificaciones o la API de escritura, los comandos lo dicen, te mandan al portal y salen con código 64. Una credencial o un scope rechazados salen con 65, y un límite de peticiones o un error del servidor con 69. + +Con un token de plataforma, los comandos necesitan `notifications:read` para listar o ver canales (y para encontrar uno por nombre) y `notifications:write` para cambiarlos; `--automation` con un nombre también necesita `automations:read`. + +La lista completa de flags está en `nan notifications --help`. + +## Gestiona los canales desde la API + +Las mismas operaciones están en la API, bajo `https://api.nan.builders/v1/notification-channels`: + +| Método y ruta | Scope | Qué hace | +|---|---|---| +| `GET /v1/notification-channels` | `notifications:read` | Lista tus canales. | +| `GET /v1/notification-channels/{id}` | `notifications:read` | Un canal. | +| `GET /v1/notification-channels/{id}/deliveries?limit=` | `notifications:read` | Sus últimos envíos, los más nuevos primero (30 días, `limit` de 1 a 100, 50 por defecto). | +| `POST /v1/notification-channels` | `notifications:write` | Crea un canal: `{"name", "kind", "url", "events"?, "automation_id"?}`. `201`, con `signing_secret` una sola vez en `https`. | +| `PATCH /v1/notification-channels/{id}` | `notifications:write` | Cambia `name`, `enabled` o `url`. | +| `DELETE /v1/notification-channels/{id}` | `notifications:write` | Lo borra. `204`. | +| `PUT /v1/notification-channels/{id}/subscriptions` | `notifications:write` | Reemplaza sus suscripciones: `{"subscriptions": [{"event", "automation_id"}]}`, `automation_id` `null` para todas las automatizaciones. Hasta 50; `[]` las quita todas. | +| `POST /v1/notification-channels/{id}/test` | `notifications:write` | Envía un mensaje de prueba y responde si se entregó. | +| `POST /v1/notification-channels/{id}/rotate-signing-secret` | `notifications:write` | Nuevo secreto de firma (se muestra una vez); el antiguo sigue firmando 24 horas. | + +**Qué credenciales sirven:** tu sesión de la CLI de `nan auth login`, o un token de plataforma con el scope de la ruta. Los dos scopes son independientes: `notifications:write` no incluye `notifications:read`. Un token con un scope `notifications:` solo se puede crear en el portal, **Settings → Tokens** (los scopes están en su propio grupo **Notifications**), con sesión iniciada desde tu navegador, y solo aparecen allí donde tu cuenta tiene notificaciones y la API de escritura. Una API key (`sk-...`) y la clave de dentro de un workspace se rechazan. Donde tu cuenta no tiene notificaciones o la API de escritura, todas las rutas responden `404`. + +Crea un canal desde un archivo, para que la URL nunca aparezca en el historial de tu shell: + +```bash +jq -n --rawfile u slack-url.txt '{name: "team alerts", kind: "slack", url: ($u | rtrimstr("\n"))}' | + curl https://api.nan.builders/v1/notification-channels \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @- +``` + +La respuesta es el canal, nunca su URL: + +```json +{ + "id": "…", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": null, + "last_delivery_status": null, + "subscriptions": [ + { "event": "approval.pending", "automation_id": null }, + { "event": "run.failed", "automation_id": null }, + { "event": "automation.disabled", "automation_id": null }, + { "event": "trigger.failing", "automation_id": null } + ], + "signing": null, + "updated_by": "platform_token", + "created_at": "…", + "updated_at": "…" +} +``` + +- `disabled_reason` es `null`, `member` (lo desactivaste tú), `failing` o `invalid_url`. `signing` es `{"previous_expires_at"}` en los canales `https`: cuándo deja de firmar el secreto antiguo tras una rotación. +- `updated_by` dice de dónde vino el último cambio: `browser_session`, `cli_session` o `platform_token`. +- **Los errores** llegan como `{"error": {"message", "type", "param", "code"}}`. `param` nombra el campo que está mal y nunca repite lo que enviaste. +- **Límites de peticiones.** Los cambios en canales (crear, editar, borrar, suscripciones, rotar) comparten un cupo por cuenta: una ráfaga de 30 y luego 120 por hora, sumando el portal, la CLI y la API. Las pruebas tienen el suyo: hasta 5 seguidas y luego 1 por minuto. Por encima, `429 rate_limited`. + +Todas las rutas, cuerpos y errores están en [Referencia de la API → Notifications](/es/docs/api#tag/notifications). + +## Límites + +| Límite | Valor | +|---|---| +| Canales | 10 por miembro | +| Suscripciones | 50 por canal | +| Nombre | De 1 a 64 caracteres | +| URL | Hasta 2048 bytes | +| Mensajes | 30 por minuto y 1.000 al día por canal; 3.000 al día por miembro | +| Resumen | Hasta 10 elementos listados | +| Reintentos | 5 intentos en unas 2,5 horas | +| Desactivación automática | Tras 20 intentos fallidos seguidos | +| Notificación pendiente | Se descarta a las 24 horas | +| Registro de envíos | 30 días | +| Cambios en canales | Una ráfaga de 30 y luego 120 por hora | +| Pruebas | Hasta 5 seguidas y luego 1 por minuto | +| Cuerpo de una petición a la API | 8 KiB | + +## Si algo sale mal + +| Lo que ves | Por qué, y qué hacer | +|---|---| +| `400 invalid_request` con un `param` | Ese campo incumple una regla: `name`, `kind`, `events` o `subscriptions` (un evento desconocido, o más de 50), o `limit` (de 1 a 100). | +| `400 invalid_url` | La URL incumple una de las [reglas](#qué-urls-se-aceptan): no es `https` en el 443, es una dirección IP, el host no corresponde a su tipo, o el host no resuelve o resuelve a una dirección rechazada. Vuelve a copiar la URL del proveedor. | +| `403 credential_not_allowed` | Una API key (`sk-...`) o una sesión de una CLI antigua. Usa tu sesión de `nan auth login` o un token de plataforma con el scope. | +| `403 token_scope_denied` | Al token le falta `notifications:read` o `notifications:write`. Crea uno con el scope en **Settings → Tokens**. | +| `403 browser_session_required` | En el portal: tu cuenta aún no tiene la API de escritura, y los cambios necesitan una sesión iniciada desde el navegador. Vuelve a entrar con el enlace del email desde tu navegador. | +| `403 workspace_key_not_allowed` | La clave de dentro de un workspace. Nunca lee ni cambia canales. | +| `404` en todas las rutas de `/v1/notification-channels` | Tu cuenta aún no tiene notificaciones o la API de escritura. Usa el portal. | +| `404 not_found` | No hay ningún canal con ese id en tu cuenta. | +| `404 automation_not_found` | El `automation_id` no es de una de tus automatizaciones. | +| `409 name_taken` | Ya tienes un canal con ese nombre. | +| `409 too_many_channels` | Tienes 10. Borra uno para añadir otro. | +| `409 channel_disabled` | Intentaste probar un canal apagado. Enciéndelo antes. | +| `409 signing_not_supported` | Solo los canales HTTPS tienen secreto de firma. | +| `429 rate_limited` | Demasiados cambios o pruebas. Espera y vuelve a intentarlo. | +| `503 notifications_unavailable` | Las notificaciones no están disponibles un momento. Vuelve a intentarlo en unos minutos. | +| La prueba responde `failed` con `http_4xx` | El proveedor rechazó el mensaje: se borró el webhook o la URL está incompleta. Crea uno nuevo y reemplaza la URL. | +| La prueba responde `delivered` pero no aparece nada en Teams | El workflow aceptó el mensaje pero no lo publicó. Ábrelo en la app **Workflows** de Teams, comprueba que está encendido y mira su historial de ejecuciones. Si su propietario dejó la organización, créalo de nuevo y reemplaza la URL. | +| La prueba responde `blocked` | El host de la URL resuelve a una dirección que NaN rechaza. Usa un endpoint público. | +| La prueba responde `rate_limited` | El canal ha gastado sus mensajes por ahora. Prueba más tarde. | +| El canal muestra **Turned off: failing** | 20 envíos seguidos fallaron. Mira [Cuando un canal falla](#cuando-un-canal-falla). | +| No ves **Notifications** en **Settings** | Tu cuenta aún no tiene notificaciones: se están abriendo poco a poco. | +| No llega nada de un run | El run no lo lanzó una automatización, se canceló, o su evento no está suscrito para esa automatización (`run.finished` viene desactivado). Revisa los eventos del canal y sus **Deliveries**. | +| Tu endpoint rechaza todas las firmas | Calcula el HMAC sobre el cuerpo en bruto antes de parsearlo, usa como clave el secreto entero `nan_nsec_…` y revisa el reloj de tu servidor. Tras una rotación, guarda el secreto nuevo. | diff --git a/src/content/docs-es/secrets.mdx b/src/content/docs-es/secrets.mdx index 4008939c..8859a53e 100644 --- a/src/content/docs-es/secrets.mdx +++ b/src/content/docs-es/secrets.mdx @@ -43,6 +43,8 @@ Cada secreto tiene: | `generic` | **Secret** | Un token o un valor que tus automatizaciones reciben como variable de entorno: un token de GitHub, un webhook de Slack, una API key de un servicio tuyo. | | `provider_key` | **Provider key** | Tu propia clave con un proveedor de IA, que se usa como inferencia de [runs](/es/docs/runs#usa-tu-propia-clave-de-proveedor-byok) y [automatizaciones](/es/docs/automations#usa-tu-propia-clave-de-proveedor-byok) en lugar de la inferencia de NaN (trae tu propia clave, BYOK). | +Para enterarte en Slack, Microsoft Teams o Discord cuando una automatización te necesita no hace falta un secreto: añade un [canal de notificaciones](/es/docs/notifications). Su URL se guarda de solo escritura, como un secreto. + ### Secret El valor se guarda tal cual lo pegas, hasta 16 KiB: nunca se recorta, así que una clave pegada nunca cambia sin que te enteres. Tiene que ser texto (UTF-8), sin caracteres de control salvo tabuladores y saltos de línea. Los saltos de línea dentro del valor están bien (una clave privada PEM, por ejemplo), pero no al principio ni al final. diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index cca5ff96..56881599 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -1,7 +1,7 @@ --- title: Webhooks y programaciones description: Lanza tus automatizaciones desde GitHub, Stripe o tus propios sistemas con webhooks firmados, o con una programación. -order: 23 +order: 24 group: Guías --- diff --git a/src/content/docs/apps.md b/src/content/docs/apps.md index 67d713eb..882d9349 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: 25 +order: 26 group: Guides --- diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index c9c93a40..6574af5e 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -21,6 +21,8 @@ What can start an automation: 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. +To hear in Slack, Microsoft Teams, Discord or your own endpoint when a run waits for your approval or fails, add a [notification channel](/docs/notifications). + ## Before you start - **A workspace that is running**, with the agent you want installed (Pi, Pi + Gentle Shell or Hermes), as for any [run](/docs/runs#pick-the-agent). @@ -475,8 +477,10 @@ A platform token carries **scopes**: what it may do. You pick them when you crea | `automations:write` | Create, change, switch off and delete automations and their triggers. **It can give any of your secrets to an agent.** | | `secrets:read` | Read your secrets' metadata: names, hints, descriptions, which automations use them. Never values. | | `secrets:write` | Create secrets, replace their values, change their descriptions and delete them. **It can replace any of your secrets.** | +| `notifications:read` | Read your [notification channels](/docs/notifications) and their deliveries. Never their URLs. | +| `notifications:write` | Create, change, test and delete notification channels and rotate their signing secrets. **It can send your notifications to any URL.** | -New tokens get `runs` and `workspaces:read` by default. A token with an `automations:` or `secrets:` scope can only be created in the portal, signed in through your browser, not from the CLI. The last three scopes appear there only where your account has the write API, with a warning. Scopes are independent: `secrets:write` does not include `secrets:read`, and `automations:write` does not include `automations:read`, so a token that manages secrets from the CLI usually carries both, and one that changes automations by name also needs `automations:read` to look the name up. A token without the scope an endpoint needs gets `403 token_scope_denied`. +New tokens get `runs` and `workspaces:read` by default. A token with an `automations:`, `secrets:` or `notifications:` scope can only be created in the portal, signed in through your browser, not from the CLI. `automations:write` and the `secrets:` scopes appear there only where your account has the write API, and the `notifications:` scopes only where it also has notifications, with a warning. Scopes are independent: `secrets:write` does not include `secrets:read`, and `automations:write` does not include `automations:read`, so a token that manages secrets from the CLI usually carries both, and one that changes automations by name also needs `automations:read` to look the name up. A token without the scope an endpoint needs gets `403 token_scope_denied`. An agent or a script holding a token with `secrets:write` or `automations:write` can change your secrets or automations. With `automations:write` it can create an automation that receives any of your secrets and runs without waiting for your approval, which is as good as reading them all. Give these scopes only to tokens that need them, keep them out of workspaces, set an expiry and revoke them when you are done. @@ -512,7 +516,7 @@ Change any setting in the **Settings** tab and press **Save changes**, or use `n An automation decides which of your secrets an agent receives and what it may do in your repository without asking. These always need you, signed in through your browser: - **Approving a proposal.** Approving is your review of one concrete change an agent made. If a credential that a script or an agent can hold could approve, an agent could approve its own proposal. -- **Creating a token with an `automations:` or `secrets:` scope.** So a stolen CLI session cannot turn itself into a long-lived token, and every such token was created by you, after the warning. +- **Creating a token with an `automations:`, `secrets:` or `notifications:` scope.** So a stolen CLI session cannot turn itself into a long-lived token, and every such token was created by you, after the warning. Creating, changing or deleting automations, triggers and secrets also needs the browser until your account has the write API. With it, your CLI session and tokens with the write scopes can do it too: who holds those credentials is your choice, so give them only where you trust the holder (see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api)). diff --git a/src/content/docs/examples.md b/src/content/docs/examples.md index 7f9878b0..504a4d3a 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: 24 +order: 25 group: Guides --- diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 6815cea5..a7893183 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -198,6 +198,8 @@ nan automations triggers add "PR review" -f webhook.yaml # prints the URL and s A secret's value is read from a file or a pipe, never from the command line, and nothing ever prints it back. Every `nan secrets` flag, how to rotate a secret and the errors you can see are in [Secrets](/docs/secrets#manage-secrets-from-the-cli). Until your account has the write API, these commands say so and point you to the portal. Approving proposals always happens in the portal, signed in through your browser. +From v0.1.34, where your account has notifications, `nan notifications` manages the channels that tell you in Slack, Microsoft Teams, Discord or your own endpoint when an automation needs your approval or fails: `nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt`, then `nan notifications test "team alerts"`. The webhook URL is read from a file or a pipe, never from the command line. Every command is in [Notifications](/docs/notifications#manage-channels-from-the-cli). + These commands use your `nan auth login` session first (it wins over `NAN_TOKEN`; `--token-file` wins over both), or a platform token with the scopes they need: `secrets:read` and `secrets:write` for secrets, `automations:write` (plus `automations:read` to use names) for automations; see [Tokens for automations](/docs/automations#tokens-for-automations). Sign in on your own machine, never inside a workspace: the agents there could read the session and change your secrets. Every command, the YAML files and the warnings are in [Manage them from the CLI or the API](/docs/automations#manage-them-from-the-cli-or-the-api). 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). diff --git a/src/content/docs/notifications.mdx b/src/content/docs/notifications.mdx new file mode 100644 index 00000000..adf8878f --- /dev/null +++ b/src/content/docs/notifications.mdx @@ -0,0 +1,477 @@ +--- +title: Notifications +description: Get told in Slack, Microsoft Teams, Discord or your own HTTPS endpoint when an automation needs your approval, fails, is switched off or its webhook keeps failing. +order: 23 +group: Guides +parent: automations +--- + +import Callout from '../../components/docs/Callout.astro'; + +# Notifications. + +A **notification channel** is a place where NaN tells you what your [automations](/docs/automations) are doing, so you do not have to keep the portal open: a Slack channel, a Microsoft Teams channel, a Discord channel or an HTTPS endpoint of your own. You add the channel once, pick the events it receives, and NaN posts a short message with a link back to the portal every time one of them happens. + +Only automation runs notify. A run you start yourself with `nan run` or from a workspace's **Runs** tab never sends a notification. + +The short version: get a webhook URL from [Slack](#get-a-slack-webhook-url), [Microsoft Teams](#get-a-microsoft-teams-webhook-url) or [Discord](#get-a-discord-webhook-url), then [add the channel](#add-a-channel) in **Settings → Notifications** and press **Test**. + +Notifications are being opened gradually. If you do not see **Notifications** in **Settings**, your account does not have them yet. + +## Channels + +Every channel has: + +| Part | What it is | +|---|---| +| **Kind** | `slack`, `teams`, `discord` or `https`. Fixed when you create it. | +| **Name** | How you recognise it, for example `team alerts`. 1 to 64 characters, unique in your account (capitals do not count). | +| **URL** | Where NaN posts. Write-only: it is never shown again, only a hint. See [URLs are credentials](#urls-are-credentials). | +| **Events** | What it receives, for all your automations or for one. See [Events](#events). | + +You can have up to 10 channels. Each one sends a message in the format its provider expects: + +| Kind | What you get | +|---|---| +| **Slack** | A message with the title as a link to the portal, a line with the event, the run state and the error code, and the run's summary when it has one. | +| **Microsoft Teams** | An adaptive card with the title, the event, state and error code, the summary and an **Open in NaN** button. | +| **Discord** | An embed with the title as a link, the event, state and error code, and the summary. It never mentions anyone. | +| **HTTPS** | A signed JSON `POST` to your endpoint, with the [documented schema](#payload). | + +### Get a Slack webhook URL + +1. Go to [api.slack.com/apps](https://api.slack.com/apps), press **Create New App**, **From scratch**, name it (for example `NaN`) and pick your workspace. +2. In **Incoming Webhooks**, switch **Activate Incoming Webhooks** on. +3. Press **Add New Webhook to Workspace**, pick the channel and press **Allow**. +4. Copy the **Webhook URL**. It looks like `https://hooks.slack.com/services/T…/B…/…`. + +### Get a Microsoft Teams webhook URL + +Teams posts webhooks through its **Workflows** app: + +1. In the Teams channel, open **…** (more options) and choose **Workflows**. +2. Pick the template **Post to a channel when a webhook request is received** (in some tenants, **Send webhook alerts to a channel**), name it and confirm the team and the channel. +3. Copy the URL Teams shows at the end. It is on a host ending in `logic.azure.com`, `powerplatform.com` or `webhook.office.com`. + +NaN sends an adaptive card, which is what that template posts. The URL of an old **Incoming Webhook** connector (on `webhook.office.com`) is still accepted, but Microsoft is retiring those connectors: use Workflows. + +### Get a Discord webhook URL + +1. In Discord, open **Server Settings**, then **Integrations**, then **Webhooks**. You need the **Manage Webhooks** permission. +2. Press **New Webhook** and open the webhook it creates. Name it, pick the channel and press **Copy Webhook URL**. It looks like `https://discord.com/api/webhooks/…/…`. + +### Your own HTTPS endpoint + +Any `https` URL on port 443 with a public DNS name. NaN signs every request with a **signing secret** it creates for the channel and shows you **once**, when you create the channel: see [Verify the signature](#verify-the-signature). Slack, Teams and Discord channels have no signing secret. + +## Events + +| Event | In the portal | When it is sent | On by default | +|---|---|---|---| +| `approval.pending` | **Approval needed** | A run of an automation is waiting for your approval. | Yes | +| `run.failed` | **Run failed** | A run of an automation failed or timed out (`timed_out`), or applying the actions you approved failed. | Yes | +| `automation.disabled` | **Automation turned off** | An automation was switched off: by you, because its workspace was deleted, or because a secret it uses was deleted. | Yes | +| `trigger.failing` | **Webhook signatures failing** | An automation's webhook keeps receiving requests with a bad signature: about 10 in a row within an hour. At most once a day per trigger. | Yes | +| `run.finished` | **Run succeeded** | A run of an automation finished successfully. | No: opt in | + +A cancelled run never notifies, and neither does a run you start yourself (outside an automation). `run.finished` is off by default because a busy automation would post on every run: turn it on where you want it. + +**For all your automations, or for one.** Every event of a channel is either for **all your automations** (including the ones you create later) or for **one automation**. When you create a channel you can scope its default events to one automation, and later mix both: for example, `run.failed` for every automation and `run.finished` only for `nightly report`. A channel takes up to 50 of these subscriptions. Deleting an automation removes the subscriptions that named it. + +The same event is sent once per channel, even when the channel is subscribed to it for all automations and for that one. + +## Manage channels in the portal + +Your channels are in your settings at [cloud.nan.builders](https://cloud.nan.builders/): open **Settings**, then **Notifications**, right below **Secrets**. Each automation's settings also have a **Notifications** card that lists the channels covering it, with **Manage notifications** to come here. + +### Add a channel + +1. Press **Add channel**. In **Where**, pick **Slack**, **Microsoft Teams**, **Discord** or **HTTPS endpoint**. +2. Type a **Name** and paste the URL in the URL field of that kind (**Slack URL**, for example). Where to get it: [Slack](#get-a-slack-webhook-url), [Microsoft Teams](#get-a-microsoft-teams-webhook-url), [Discord](#get-a-discord-webhook-url) or [your own endpoint](#your-own-https-endpoint). +3. Check the **Events**: **Approval needed**, **Run failed**, **Automation turned off** and **Webhook signatures failing** are on; **Run succeeded** is off, turn it on if you want it. In **Automations**, keep **All automations, including new ones** or pick one. +4. Save. For an **HTTPS endpoint** the portal shows the **signing secret** once: copy it to your endpoint's secret store now, it is never shown again. + +Then press **Test**: NaN sends a test message right away and tells you whether it arrived. + +### Test, change, rotate, delete + +- **Test** sends a test message to the channel. It works only on a channel that is on, and you can send up to 5 in a row, then about one a minute. For Teams, `delivered` means the workflow accepted the message: the workflow then posts it, and if it fails, the error is in the workflow's run history. +- **Events** changes which events the channel receives, and for which automations. +- **Edit** renames the channel or replaces its URL (**New URL (optional)**). The kind cannot be changed: add a new channel instead. +- **Rotate secret** (HTTPS only) creates a new signing secret and shows it once. The old one keeps signing too for 24 hours, so you can update your endpoint without missing a request. +- **Turn off** and **Turn on** stop and resume a channel without deleting it. +- **Delete** asks you to confirm, and the channel and its subscriptions are gone for good. +- **Deliveries** shows the last 30 days of sends: event, status, HTTP status and error code. Never the message itself. + +Each channel shows its status: **On**, **Deliveries failing** (recent sends failed, it is still on), **Off** (you turned it off), **Turned off: failing** or **Turned off: URL refused** (NaN turned it off, see [When a channel fails](#when-a-channel-fails)). + +## Digests and limits + +NaN keeps every channel readable and never floods a provider: + +- **Digests.** When more than 3 notifications are due for a channel at once, or more than it may still receive in the current minute, they arrive as **one digest message**: how many of each event, and up to 10 of them with their links. +- **Per channel:** at most 30 messages a minute and 1,000 a day. +- **Per account:** at most 3,000 messages a day across all your channels. + +Over the minute limit, messages wait and are sent the next minute (as a digest when there are several). Over a daily limit, that day's further notifications are dropped and the deliveries log shows `rate_limited`. Test messages count as messages. + +A notification not delivered within 24 hours is dropped: a late alert is worse than none. + +## When a channel fails + +Each message is retried when the failure may be temporary: a timeout, a connection or TLS error, a DNS failure, `408`, `429` or a `5xx`. It is tried up to 5 times, after 1 minute, 5 minutes, 30 minutes and 2 hours. Any other `4xx`, and any redirect, fails at once: the URL is wrong or was revoked. + +**Turned off after 20 failures.** After **20 failed attempts in a row**, the channel is turned off with the reason `failing`, and its pending messages are dropped. The portal shows **Turned off: failing**, and `nan notifications ls` shows it too. To fix it: + +1. Check the **Deliveries** log for the error (`http_4xx`, `timeout`, `dns` and so on). +2. If the webhook was deleted or revoked at the provider, create a new one and **replace the URL**: that re-enables the channel. +3. If the problem was on your side and is fixed, press **Turn on** (or `nan notifications edit NAME --enable`). Then press **Test**. + +Turning it back on or replacing the URL resets the failure count. + +**Turned off as `invalid_url`.** If the URL's host starts resolving to an address NaN refuses (a private or reserved one, see [Which URLs are accepted](#which-urls-are-accepted)), the channel is turned off at once with the reason `invalid_url` (**Turned off: URL refused** in the portal). Replace the URL to turn it back on. + +A channel you turned off yourself (reason `member`, **Off** in the portal) stays off until you turn it on, even if you replace its URL. + +## Verify the signature + +Every request to an **HTTPS** channel carries these headers, with the same signature scheme as the [webhooks you send to NaN](/docs/webhooks#send-your-own-events): + +| Header | Value | +|---|---| +| `X-Nan-Event` | The event: `approval.pending`, `run.failed`, `run.finished`, `automation.disabled`, `trigger.failing`, `digest` or `test`. | +| `X-Nan-Delivery` | A unique id per notification, the same on every retry of it. Use it to ignore repeats. | +| `X-Nan-Timestamp` | When it was signed, in Unix seconds. | +| `X-Nan-Signature` | `v1=` followed by the hex HMAC-SHA256 of `.` with the signing secret (the whole `nan_nsec_…` string is the key). For 24 hours after a rotation there are two, separated by a comma, the new one first. | + +Accept a request when **any** `v1=` value matches and the timestamp is less than 5 minutes off. Compute the HMAC over the body exactly as received, byte for byte, before parsing it. Answer with any `2xx` quickly; the response body is ignored. + +To check your code: with the secret `nan_nsec_test`, the timestamp `1700000000` and the body `{"a":1}`, the signature is `v1=5c39c853e058b14c0d0e29bbdeb6d0dc325318231d07d1df377a284c89ad4e01`. Once your endpoint verifies signatures, press **Test**: the test message is signed like every other. + +In bash, to check a request you saved (body in `body.json`): + +```bash +SECRET="nan_nsec_..." # the channel's signing secret +TS="1760436000" # the X-Nan-Timestamp header +SIGS="v1=...,v1=..." # the X-Nan-Signature header +EXPECTED=$(printf '%s.' "$TS" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') + +NOW=$(date +%s) +if [ $((NOW - TS)) -gt 300 ] || [ $((TS - NOW)) -gt 300 ]; then echo "too old"; exit 1; fi +case ",$SIGS," in *",v1=$EXPECTED,"*) echo "valid" ;; *) echo "invalid"; exit 1 ;; esac +``` + +In Python: + +```python +import hashlib, hmac, time + +SECRET = b"nan_nsec_..." + +def verify(body: bytes, timestamp: str, signature_header: str) -> bool: + try: + ts = int(timestamp) + except (TypeError, ValueError): + return False + if abs(time.time() - ts) > 300: + return False + expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() + return any( + part.startswith("v1=") and hmac.compare_digest(part[3:], expected) + for part in signature_header.split(",") + ) + +# In your handler, with the raw body: +# verify(request_body, headers["X-Nan-Timestamp"], headers["X-Nan-Signature"]) +``` + +In Node.js: + +```js +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createServer } from 'node:http'; + +const secret = process.env.NAN_SIGNING_SECRET; // nan_nsec_... + +function verify(body, timestamp, header) { + const ts = Number(timestamp); + if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; + const expected = Buffer.from(createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex')); + return (header ?? '').split(',').some((part) => { + const got = Buffer.from(part.trim().replace(/^v1=/, '')); + return part.trim().startsWith('v1=') && got.length === expected.length && timingSafeEqual(got, expected); + }); +} + +createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = Buffer.concat(chunks); + if (!verify(body, req.headers['x-nan-timestamp'], req.headers['x-nan-signature'])) { + res.writeHead(401).end(); + return; + } + const event = JSON.parse(body); + console.log(event.event, event.text, event.url); + res.writeHead(204).end(); + }); +}).listen(8080); +``` + +Delivery is at least once: the same notification can arrive twice (for example after a network error once it was already received). Ignore an `X-Nan-Delivery` you have already processed. + +## Payload + +An **HTTPS** channel receives `POST` requests with `Content-Type: application/json` and `User-Agent: NaN-Notifications/1`. The body has `version: 1`, and fields that do not apply to an event are `null`: + +```json +{ + "version": 1, + "id": "…", + "event": "run.failed", + "created_at": "2026-10-14T10:00:00Z", + "automation": { "id": "…", "name": "PR review" }, + "run": { + "id": "…", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "trigger": null, + "reason": null, + "url": "https://cloud.nan.builders/workspaces/…/runs/…", + "text": "Run failed: PR review (agent_failed)" +} +``` + +| Field | What it holds | +|---|---| +| `id` | The notification's id, the same as `X-Nan-Delivery`. | +| `event` | One of the [events](#events). | +| `automation` | `{ "id", "name" }` of the automation. | +| `run` | On run events: `id`, `state` (`awaiting_approval`, `succeeded`, `failed` or `timed_out`), `error_code`, `phase` and `summary`. `null` otherwise. | +| `run.summary` | The run's summary as the portal shows it, with secrets already masked and control characters removed, at most 280 characters. Only on `run.finished` and `run.failed`. | +| `trigger` | `{ "id", "kind" }` on `trigger.failing`. | +| `reason` | `automation.disabled`: `member`, `workspace_deleted` or `secret_deleted`. `trigger.failing`: `bad_signature`. | +| `url` | Where to look in the portal: the run for run events, the automation for the others. | +| `text` | A one-line title, for example `Approval needed: PR review`, `Run timed out: PR review` or `Automation disabled: PR review (its workspace was deleted)`. | + +A **digest** groups several notifications: + +```json +{ + "version": 1, + "id": "…", + "event": "digest", + "created_at": "2026-10-14T10:00:00Z", + "count": 3, + "events": { "approval.pending": 1, "run.failed": 2 }, + "items": [ + { + "event": "run.failed", + "automation": { "id": "…", "name": "PR review" }, + "run": { "id": "…", "state": "timed_out", "error_code": "timed_out", "phase": "propose", "summary": "…" }, + "url": "https://cloud.nan.builders/workspaces/…/runs/…" + } + ], + "url": "https://cloud.nan.builders/automations", + "text": "3 notifications: 1 approval needed, 2 runs failed" +} +``` + +`count` is how many notifications it groups, `events` how many of each, and `items` up to 10 of them (the example shows one). + +A **test** message: + +```json +{ + "version": 1, + "id": "…", + "event": "test", + "created_at": "2026-10-14T10:00:00Z", + "url": "https://cloud.nan.builders/settings#notifications", + "text": "Test notification from NaN" +} +``` + +New fields may be added to version 1; ignore the ones you do not know. + +## Security + +### URLs are credentials + +Anyone who has a Slack, Teams or Discord webhook URL can post to that channel, so NaN treats every channel URL like a [secret](/docs/secrets): + +- It is **write-only**: encrypted with a key that belongs to your account alone, and never returned by the portal, the CLI or the API. You see a hint instead, the host and the last 4 characters of the path: `hooks.slack.com/…/abcd`. +- It never appears in an error, a log or the deliveries list. +- From the CLI it is read from a file or a pipe, never from the command line. + +If a webhook URL leaks, delete the webhook at the provider, create a new one and replace the URL in NaN. + +### What a message never carries + +Messages carry names, states, error codes, ids and links. They **never** carry: + +- the value of any secret, or a channel URL; +- the body or headers of the webhook request that started a run; +- the agent's output, beyond the run's short summary (at most 280 characters, with secrets masked). + +Member text (an automation's name, a summary) is escaped for each provider: it cannot mention anyone, ping a role or form a link. + +### Which URLs are accepted + +NaN's servers call these URLs, so they follow strict rules, checked when you save the URL and again on every send: + +- `https` only, on port 443. No user or password in the URL, no fragment, no IP address: a DNS name with at least two labels. +- Not NaN's own hosts (`nan.builders`, `helmcode.com` and their subdomains), and not local names (`localhost`, `.local`, `.internal`, `.lan`, `.home.arpa`, `.svc`). +- Every address the name resolves to must be public: private, loopback, link-local, shared and reserved addresses are refused, and so are NaN's own. +- Slack URLs must be on `hooks.slack.com` with a path starting `/services/`. Teams URLs on a subdomain of `webhook.office.com`, `logic.azure.com` or `powerplatform.com`. Discord URLs on `discord.com` (or `discordapp.com`, `canary.discord.com`, `ptb.discord.com`) with a path starting `/api/webhooks/`. +- Redirects are not followed: a `3xx` is a failure. + +A refused URL gets one generic error that names the rule it breaks, never what you sent, and a name that does not resolve gets the same answer as one that resolves to a refused address. + +## Manage channels from the CLI + +With the [NaN CLI](/docs/nan-cli) **0.1.34** or newer, where your account has notifications and the write API: + +```bash +nan notifications ls # kind, name, URL hint, status +nan notifications add "team alerts" --kind slack --url-file ./slack-url.txt +pbpaste | nan notifications add "ops endpoint" --kind https --url-stdin --event run.failed --event run.finished +nan notifications add "nightly" --kind discord --url-file ./discord-url.txt --automation "nightly report" +nan notifications show "team alerts" +nan notifications test "team alerts" +nan notifications subscriptions "team alerts" # list them +nan notifications subscriptions "team alerts" --add "run.finished=nightly report" +nan notifications subscriptions "team alerts" --set run.failed --set approval.pending +nan notifications edit "team alerts" --url-file ./new-url.txt +nan notifications edit "team alerts" --enable +nan notifications rotate "ops endpoint" # prints the new signing secret once +nan notifications deliveries "team alerts" --limit 50 +nan notifications rm "team alerts" +``` + +- **The URL never goes on the command line**, where it would stay in your shell history. `--url-file PATH` reads it from a file, `--url-stdin` from a pipe. There is no `--url` flag. +- `add` takes the default events unless you pass `--event` (repeatable, or comma-separated) or `--no-events`; `--automation` (a name or an id) scopes them to one automation. +- For an HTTPS channel, `add` and `rotate` print the signing secret **once**, with a reminder to store it now. With `--json` it is inside the JSON, as `signing_secret`. +- `subscriptions` without flags lists them. `EVENT` alone means all your automations, `EVENT=AUTOMATION` one of them. `--set` replaces the whole set, `--add` and `--remove` change it, `--clear` removes them all. +- `edit` changes the name (`--name`), switches the channel on or off (`--enable`, `--disable`) or replaces the URL. Replacing the URL or `--enable` re-enables a channel NaN switched off and resets its failure count. +- `test` exits with code 0 when the message was delivered and 1 when it was not, with the reason. +- `deliveries` shows the last 20 sends by default (`--limit` 1 to 100). +- `rm` asks before deleting; `--yes` skips the question in scripts. +- Commands take a channel's name or its id, and `--json` prints JSON. +- Where your account does not have notifications or the write API, the commands say so, point you to the portal and exit with code 64. A refused credential or scope exits with 65, and a rate limit or a server error with 69. + +With a platform token, the commands need `notifications:read` to list or show channels (and to find one by name) and `notifications:write` to change them; `--automation` with a name also needs `automations:read`. + +The full list of flags is in `nan notifications --help`. + +## Manage channels from the API + +The same operations are in the API, under `https://api.nan.builders/v1/notification-channels`: + +| Method and path | Scope | What it does | +|---|---|---| +| `GET /v1/notification-channels` | `notifications:read` | Lists your channels. | +| `GET /v1/notification-channels/{id}` | `notifications:read` | One channel. | +| `GET /v1/notification-channels/{id}/deliveries?limit=` | `notifications:read` | Its last sends, newest first (30 days, `limit` 1 to 100, default 50). | +| `POST /v1/notification-channels` | `notifications:write` | Creates a channel: `{"name", "kind", "url", "events"?, "automation_id"?}`. `201`, with `signing_secret` once for `https`. | +| `PATCH /v1/notification-channels/{id}` | `notifications:write` | Changes `name`, `enabled` or `url`. | +| `DELETE /v1/notification-channels/{id}` | `notifications:write` | Deletes it. `204`. | +| `PUT /v1/notification-channels/{id}/subscriptions` | `notifications:write` | Replaces its subscriptions: `{"subscriptions": [{"event", "automation_id"}]}`, `automation_id` `null` for all automations. Up to 50; `[]` removes them all. | +| `POST /v1/notification-channels/{id}/test` | `notifications:write` | Sends a test message and answers whether it was delivered. | +| `POST /v1/notification-channels/{id}/rotate-signing-secret` | `notifications:write` | New signing secret (shown once); the old one keeps signing for 24 hours. | + +**Which credentials work:** your CLI session from `nan auth login`, or a platform token with the scope of the route. The two scopes are independent: `notifications:write` does not include `notifications:read`. A token with a `notifications:` scope can only be created in the portal, **Settings → Tokens** (the scopes are in their own **Notifications** group), signed in through your browser, and they are offered there only where your account has notifications and the write API. An API key (`sk-...`) and the key inside a workspace are refused. Where your account does not have notifications or the write API, every route answers `404`. + +Create a channel from a file, so the URL never appears in your shell history: + +```bash +jq -n --rawfile u slack-url.txt '{name: "team alerts", kind: "slack", url: ($u | rtrimstr("\n"))}' | + curl https://api.nan.builders/v1/notification-channels \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @- +``` + +The answer is the channel, never its URL: + +```json +{ + "id": "…", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": null, + "last_delivery_status": null, + "subscriptions": [ + { "event": "approval.pending", "automation_id": null }, + { "event": "run.failed", "automation_id": null }, + { "event": "automation.disabled", "automation_id": null }, + { "event": "trigger.failing", "automation_id": null } + ], + "signing": null, + "updated_by": "platform_token", + "created_at": "…", + "updated_at": "…" +} +``` + +- `disabled_reason` is `null`, `member` (you disabled it), `failing` or `invalid_url`. `signing` is `{"previous_expires_at"}` on `https` channels: when the old secret stops signing after a rotation. +- `updated_by` says where the last change came from: `browser_session`, `cli_session` or `platform_token`. +- **Errors** come as `{"error": {"message", "type", "param", "code"}}`. `param` names the field that is wrong and never repeats what you sent. +- **Rate limits.** Changes to channels (create, edit, delete, subscriptions, rotate) share one allowance per account: a burst of 30, then 120 per hour, counting the portal, the CLI and the API. Tests have their own: up to 5 in a row, then 1 a minute. Beyond them, `429 rate_limited`. + +Every route, body and error is in [API reference → Notifications](/docs/api#tag/notifications). + +## Limits + +| Limit | Value | +|---|---| +| Channels | 10 per member | +| Subscriptions | 50 per channel | +| Name | 1 to 64 characters | +| URL | Up to 2048 bytes | +| Messages | 30 a minute and 1,000 a day per channel; 3,000 a day per member | +| Digest | Up to 10 items listed | +| Retries | 5 attempts over about 2.5 hours | +| Auto-disable | After 20 failed attempts in a row | +| Pending notification | Dropped after 24 hours | +| Deliveries log | 30 days | +| Changes to channels | A burst of 30, then 120 per hour | +| Tests | Up to 5 in a row, then 1 a minute | +| API request body | 8 KiB | + +## If something goes wrong + +| What you see | Why, and what to do | +|---|---| +| `400 invalid_request` with a `param` | That field breaks a rule: `name`, `kind`, `events` or `subscriptions` (an unknown event, or more than 50), or `limit` (1 to 100). | +| `400 invalid_url` | The URL breaks one of the [rules](#which-urls-are-accepted): not `https` on 443, an IP address, the wrong host for its kind, or a host that does not resolve or resolves to a refused address. Copy the URL again from the provider. | +| `403 credential_not_allowed` | An API key (`sk-...`) or a session from an older CLI. Use your `nan auth login` session or a platform token with the scope. | +| `403 token_scope_denied` | The token lacks `notifications:read` or `notifications:write`. Create one with the scope in **Settings → Tokens**. | +| `403 browser_session_required` | In the portal: your account does not have the write API yet, and changes need a browser sign-in. Sign in again with the email link from your browser. | +| `403 workspace_key_not_allowed` | The key inside a workspace. It never reads or changes channels. | +| `404` on every `/v1/notification-channels` route | Your account does not have notifications or the write API yet. Use the portal. | +| `404 not_found` | No channel with that id in your account. | +| `404 automation_not_found` | The `automation_id` is not one of your automations. | +| `409 name_taken` | You already have a channel with that name. | +| `409 too_many_channels` | You have 10. Delete one to add another. | +| `409 channel_disabled` | You tried to test a channel that is off. Turn it on first. | +| `409 signing_not_supported` | Only HTTPS channels have a signing secret. | +| `429 rate_limited` | Too many changes or tests. Wait and try again. | +| `503 notifications_unavailable` | Notifications are briefly unavailable. Try again in a few minutes. | +| The test answers `failed` with `http_4xx` | The provider refused the message: the webhook was deleted, or the URL is incomplete. Create a new one and replace the URL. | +| The test answers `delivered` but nothing appears in Teams | The workflow accepted the message but did not post it. Open it in the Teams **Workflows** app, check that it is on and read its run history. If its owner left the organisation, create it again and replace the URL. | +| The test answers `blocked` | The URL's host resolves to an address NaN refuses. Use a public endpoint. | +| The test answers `rate_limited` | The channel used up its messages for now. Try later. | +| The channel shows **Turned off: failing** | 20 sends in a row failed. See [When a channel fails](#when-a-channel-fails). | +| You do not see **Notifications** in **Settings** | Your account does not have notifications yet: they are being opened gradually. | +| Nothing arrives for a run | The run was not started by an automation, was cancelled, or its event is not subscribed for that automation (`run.finished` is off by default). Check the channel's events and its **Deliveries**. | +| Your endpoint rejects every signature | Compute the HMAC over the raw body before parsing it, use the whole `nan_nsec_…` secret as the key, and check your server's clock. After a rotation, store the new secret. | diff --git a/src/content/docs/secrets.mdx b/src/content/docs/secrets.mdx index fe189b78..b7403c85 100644 --- a/src/content/docs/secrets.mdx +++ b/src/content/docs/secrets.mdx @@ -43,6 +43,8 @@ Every secret has: | `generic` | **Secret** | A token or value your automations receive as an environment variable: a GitHub token, a Slack webhook, an API key for your own service. | | `provider_key` | **Provider key** | Your own key with an AI provider, used as the inference of [runs](/docs/runs#use-your-own-provider-key-byok) and [automations](/docs/automations#use-your-own-provider-key-byok) instead of NaN inference (bring your own key, BYOK). | +To be told in Slack, Microsoft Teams or Discord when an automation needs you, you do not need a secret: add a [notification channel](/docs/notifications). Its URL is kept write-only like a secret. + ### Secret The value is saved exactly as you paste it, up to 16 KiB: it is never trimmed, so a pasted key is never silently changed. It must be text (UTF-8), with no control characters other than tabs and line breaks. Line breaks inside the value are fine (a PEM private key, for example), but not at its start or end. diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index a6f65166..e0cd2d44 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -1,7 +1,7 @@ --- title: Webhooks and schedules description: Start your automations from GitHub, Stripe or your own systems with signed webhooks, or on a schedule. -order: 23 +order: 24 group: Guides --- diff --git a/src/data/openapi.json b/src/data/openapi.json index e197f24d..03a1c703 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), [Automations](#tag/automations), [Secrets](#tag/secrets) 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 inference and on Runs, Approvals, Workspaces and reading and running Automations; never on Secrets or on changes to automations. 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), `automations:run` (run an existing automation), `automations:write` (create, change and delete automations and their triggers), `secrets:read` (read secret metadata, never values) and `secrets:write` (create, replace, change and delete secrets). Scopes are independent: `secrets:write` does not include `secrets:read`. 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### CLI session and the write scopes\n\nYour secrets and your automations (with their triggers) can be managed through the API and the [NaN CLI](https://nan.builders/docs/nan-cli) where the write API is available for your account (it is being opened gradually). Three credentials can change them: you signed in at [cloud.nan.builders](https://cloud.nan.builders/), the **CLI session** that `nan auth login` saves, and a platform token with `automations:write` or `secrets:write`. Secret metadata is read with a CLI session or `secrets:read`. API keys (`sk-...`), the key inside a workspace and sessions from older clients are refused:\n\n```json\n{\n \"error\": {\n \"message\": \"This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \\\"secrets:write\\\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"credential_not_allowed\"\n }\n}\n```\n\nA token with `secrets:write` lets whoever holds it replace any of your secrets. A token with `automations:write` lets whoever holds it create or change an automation that receives any of your secrets, with instructions of their choice and nothing waiting for approval: that is as good as reading every secret you have. Such a token, held by an agent or a script, can change your secrets or automations. These scopes can only be added to a token in the portal, signed in through your browser. Give a token only the scopes its job needs, and run `nan auth login` on your own machine, never inside a workspace: the agents there could read the session.\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, and creating a token with an `automations:` or `secrets:` scope. Where the write API is not yet available for your account, changing automations and their triggers is also browser-only. 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 or create tokens with automations or secrets 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 on `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash` and `glm5.3`; 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, a credential that cannot change secrets or automations (see [CLI session and the write scopes](#description/cli-session-and-the-write-scopes)), or an action that needs a [browser sign-in](#description/browser-sign-in). | `tier_restricted` · `token_scope_denied` · `credential_not_allowed` · `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.", + "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), [Secrets](#tag/secrets), [Notifications](#tag/notifications) 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 inference and on Runs, Approvals, Workspaces and reading and running Automations; never on Secrets, Notifications or changes to automations. 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), `automations:run` (run an existing automation), `automations:write` (create, change and delete automations and their triggers), `secrets:read` (read secret metadata, never values), `secrets:write` (create, replace, change and delete secrets), `notifications:read` (read notification channels and their deliveries, never their URLs) and `notifications:write` (create, change, test and delete notification channels, and rotate their signing secrets). Scopes are independent: `secrets:write` does not include `secrets:read`, and `notifications:write` does not include `notifications:read`. 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, /v1/automations, /v1/approvals, /v1/secrets, /v1/notification-channels and GET /api/workspaces).\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"token_not_valid_for_inference\"\n }\n}\n```\n\n### CLI session and the write scopes\n\nYour secrets and your automations (with their triggers) can be managed through the API and the [NaN CLI](https://nan.builders/docs/nan-cli) where the write API is available for your account (it is being opened gradually). Three credentials can change them: you signed in at [cloud.nan.builders](https://cloud.nan.builders/), the **CLI session** that `nan auth login` saves, and a platform token with `automations:write` or `secrets:write`. Secret metadata is read with a CLI session or `secrets:read`. API keys (`sk-...`), the key inside a workspace and sessions from older clients are refused:\n\n```json\n{\n \"error\": {\n \"message\": \"This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \\\"secrets:write\\\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"credential_not_allowed\"\n }\n}\n```\n\nA token with `secrets:write` lets whoever holds it replace any of your secrets. A token with `automations:write` lets whoever holds it create or change an automation that receives any of your secrets, with instructions of their choice and nothing waiting for approval: that is as good as reading every secret you have. Such a token, held by an agent or a script, can change your secrets or automations. These scopes can only be added to a token in the portal, signed in through your browser. The same goes for `notifications:read` and `notifications:write`, offered only where your account has notifications and the write API: a token with `notifications:write` decides where your notifications go. Give a token only the scopes its job needs, and run `nan auth login` on your own machine, never inside a workspace: the agents there could read the session.\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, and creating a token with an `automations:`, `secrets:` or `notifications:` scope. Where the write API is not yet available for your account, changing automations and their triggers is also browser-only. 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 or create tokens with automations or secrets 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 on `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash` and `glm5.3`; 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, a credential that cannot change secrets or automations (see [CLI session and the write scopes](#description/cli-session-and-the-write-scopes)), or an action that needs a [browser sign-in](#description/browser-sign-in). | `tier_restricted` · `token_scope_denied` · `credential_not_allowed` · `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" @@ -70,6 +70,10 @@ "name": "Secrets", "description": "The tokens and keys your automations receive, and your own provider keys. The API never returns a value: it lists metadata, creates secrets, replaces values, changes descriptions and base URLs, and deletes. Reading needs a CLI session (`nan auth login`) or a platform token with `secrets:read`; writing, a CLI session or a token with `secrets:write`. API keys (`sk-...`) never read or write secrets. Where the write API is not yet available for your account, these routes answer `404`. Guide: [Secrets](https://nan.builders/docs/secrets)." }, + { + "name": "Notifications", + "description": "Notification channels: Slack, Microsoft Teams, Discord or your own HTTPS endpoint, told when an automation run waits for approval (`approval.pending`), fails or times out (`run.failed`), finishes (`run.finished`, opt-in), when an automation is switched off (`automation.disabled`) or its webhook keeps failing (`trigger.failing`). A channel URL is a credential: write-only, never returned (only `url_hint`). An `https` channel's signing secret is returned once, by create and by rotate. Reading needs a CLI session (`nan auth login`) or a platform token with `notifications:read`; changing, a CLI session or a token with `notifications:write`. API keys (`sk-...`) never read or change channels. Where notifications or the write API are not yet available for your account, these routes answer `404`. Payloads, signatures, digests and limits: [Notifications](https://nan.builders/docs/notifications)." + }, { "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)." @@ -7183,159 +7187,2084 @@ } } } - } - }, - "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, Secrets (`/v1/secrets...`) and the two Workspaces endpoints, and only where it carries the scope the endpoint names: `runs`, `workspaces:read`, `automations:read`, `automations:run`, `automations:write`, `secrets:read` or `secrets:write`. A missing scope answers `403 token_scope_denied`. New tokens get `runs` and `workspaces:read` by default; a token with an `automations:` or `secrets:` 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, and it changes automations or secrets only with `automations:write` or `secrets:write`. 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." - }, - "cliSession": { - "type": "http", - "scheme": "bearer", - "description": "The session the [NaN CLI](https://nan.builders/docs/nan-cli) saves when you sign in with `nan auth login`, sent as `Authorization: Bearer `. It manages your secrets and automations where the write API is available for your account, and it can do everything you can do signed in, except approve. Sign in on your own machine: a session saved inside a workspace can be read by the agents that run there." - } }, - "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" + "/notification-channels": { + "get": { + "operationId": "listNotificationChannels", + "tags": [ + "Notifications" + ], + "summary": "List notification channels", + "security": [ + { + "cliSession": [] }, - "object": { - "type": "string", - "enum": [ - "model" - ], - "description": "The object type, which is always `model`.", - "example": "model" + { + "platformToken": [] + } + ], + "description": "Your notification channels: kind, name, URL hint, status and subscriptions. Never a URL or a signing secret.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:read` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "responses": { + "200": { + "description": "Your channels.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelList" + }, + "example": { + "object": "list", + "data": [ + { + "id": "7c1e4a9b-2d5f-4e8a-b3c6-9f0d1a2b3c4d", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": "2026-10-14T10:00:03Z", + "last_delivery_status": "delivered", + "subscriptions": [ + { + "event": "approval.pending", + "automation_id": null + }, + { + "event": "run.failed", + "automation_id": null + }, + { + "event": "automation.disabled", + "automation_id": null + }, + { + "event": "trigger.failing", + "automation_id": null + } + ], + "signing": null, + "updated_by": "cli_session", + "created_at": "2026-10-12T09:00:00Z", + "updated_at": "2026-10-14T09:30:00Z" + } + ] + } + } + } }, - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the model became available.", - "example": 1677610602 + "401": { + "$ref": "#/components/responses/RunsUnauthorized" }, - "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" + "403": { + "description": "A platform token without the `notifications:read` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications: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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:read\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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" + } + } + } + } } } }, - { - "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." + "404": { + "description": "Notifications through the API are not available for your account (`404 Not found.`): they need notifications and the write API, both being opened gradually.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } } } } } } - ] + } }, - "Message": { - "type": "object", - "required": [ - "role" + "post": { + "operationId": "createNotificationChannel", + "tags": [ + "Notifications" ], - "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`." + "summary": "Create a notification channel", + "security": [ + { + "cliSession": [] }, + { + "platformToken": [] + } + ], + "description": "Creates a channel and subscribes it to `events` (default `approval.pending`, `run.failed`, `automation.disabled` and `trigger.failing`; `run.finished` is opt-in), for every automation or, with `automation_id`, for that one. The URL is validated (`https` on 443, a public DNS name, the provider's hosts for `slack`, `teams` and `discord`, every resolved address public) and never returned. For an `https` channel the answer carries `signing_secret`, once: store it now. At most 10 channels per member.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:write` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "requestBody": { + "required": true, "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" + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateNotificationChannelRequest" }, - { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContentPart" + "examples": { + "slack": { + "summary": "A Slack channel with the default events.", + "value": { + "name": "team alerts", + "kind": "slack", + "url": "https://hooks.slack.com/services/..." + } + }, + "https": { + "summary": "Your own endpoint, failures and successes of one automation.", + "value": { + "name": "ops endpoint", + "kind": "https", + "url": "https://hooks.example.com/nan", + "events": [ + "run.failed", + "run.finished" + ], + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69" + } } } - ] - }, - "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": [ + }, + "responses": { + "201": { + "description": "Created. `signing_secret` only for `https`, and only in this answer.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannelCreated" + }, + "example": { + "id": "7c1e4a9b-2d5f-4e8a-b3c6-9f0d1a2b3c4d", + "object": "notification_channel", + "name": "ops endpoint", + "kind": "https", + "url_hint": "hooks.example.com/…/7f3a", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": null, + "last_delivery_status": null, + "subscriptions": [ + { + "event": "approval.pending", + "automation_id": null + }, + { + "event": "run.failed", + "automation_id": null + }, + { + "event": "automation.disabled", + "automation_id": null + }, + { + "event": "trigger.failing", + "automation_id": null + } + ], + "signing": { + "previous_expires_at": null + }, + "updated_by": "cli_session", + "created_at": "2026-10-14T10:00:00Z", + "updated_at": "2026-10-14T10:00:00Z", + "signing_secret": "nan_nsec_..." + } + } + } + }, + "400": { + "description": "A field that does not pass validation (`invalid_request`, `param` names it, never echoing the value), an unknown field or a body over 8 KiB, or a URL that breaks a rule (`invalid_url`, `param: url`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "name": { + "summary": "A name that is empty or too long.", + "value": { + "error": { + "message": "the name must be 1 to 64 characters without control characters", + "type": "invalid_request_error", + "param": "name", + "code": "invalid_request" + } + } + }, + "kind": { + "summary": "An unknown kind.", + "value": { + "error": { + "message": "kind must be slack, teams, discord or https", + "type": "invalid_request_error", + "param": "kind", + "code": "invalid_request" + } + } + }, + "events": { + "summary": "An unknown event.", + "value": { + "error": { + "message": "event must be one of approval.pending, run.finished, run.failed, automation.disabled, trigger.failing", + "type": "invalid_request_error", + "param": "events", + "code": "invalid_request" + } + } + }, + "invalid_url": { + "summary": "A URL that breaks a rule: the message names the rule, never the URL.", + "value": { + "error": { + "message": "a Slack channel needs an incoming-webhook URL (https://hooks.slack.com/services/…)", + "type": "invalid_request_error", + "param": "url", + "code": "invalid_url" + } + } + }, + "invalid_url_address": { + "summary": "A host that does not resolve, or resolves to a refused address (same answer for both).", + "value": { + "error": { + "message": "the URL's host must resolve only to public addresses", + "type": "invalid_request_error", + "param": "url", + "code": "invalid_url" + } + } + }, + "unknown_field": { + "summary": "An unknown field (`param` names it).", + "value": { + "error": { + "message": "send one JSON object with the documented fields", + "type": "invalid_request_error", + "param": "secret", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:write` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications:write\" 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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:write\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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": "Notifications through the API are not available for your account (`404 Not found.`): they need notifications and the write API, both being opened gradually. `automation_not_found` (`param: automation_id`): the automation is not one of yours.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "automation_not_found": { + "summary": "An `automation_id` that is not one of your automations (or is malformed).", + "value": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": "automation_id", + "code": "automation_not_found" + } + } + } + } + } + } + }, + "409": { + "description": "The name is taken (`name_taken`) or you have 10 channels (`too_many_channels`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "name_taken": { + "summary": "A channel with this name exists (names compare without case).", + "value": { + "error": { + "message": "a notification channel with this name already exists", + "type": "invalid_request_error", + "param": "name", + "code": "name_taken" + } + } + }, + "too_many_channels": { + "summary": "10 channels already.", + "value": { + "error": { + "message": "a member can have at most 10 notification channels", + "type": "invalid_request_error", + "param": null, + "code": "too_many_channels" + } + } + } + } + } + } + }, + "429": { + "description": "Too many channel changes (`rate_limited`): every create, change, delete, subscriptions replacement and rotation of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal and the CLI. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many notification channel changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + }, + "503": { + "description": "Notifications are temporarily unavailable (`notifications_unavailable`). Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "notifications are temporarily unavailable; try again", + "type": "server_error", + "param": null, + "code": "notifications_unavailable" + } + } + } + } + } + } + } + }, + "/notification-channels/{id}": { + "get": { + "operationId": "getNotificationChannel", + "tags": [ + "Notifications" + ], + "summary": "Get a notification channel", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "One channel. Never its URL or signing secret.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:read` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + } + ], + "responses": { + "200": { + "description": "The channel.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannel" + }, + "example": { + "id": "7c1e4a9b-2d5f-4e8a-b3c6-9f0d1a2b3c4d", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": "2026-10-14T10:00:03Z", + "last_delivery_status": "delivered", + "subscriptions": [ + { + "event": "approval.pending", + "automation_id": null + }, + { + "event": "run.failed", + "automation_id": null + }, + { + "event": "automation.disabled", + "automation_id": null + }, + { + "event": "trigger.failing", + "automation_id": null + } + ], + "signing": null, + "updated_by": "cli_session", + "created_at": "2026-10-12T09:00:00Z", + "updated_at": "2026-10-14T09:30:00Z" + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:read` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications: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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:read\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "updateNotificationChannel", + "tags": [ + "Notifications" + ], + "summary": "Change a notification channel", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Changes `name`, `enabled` or `url` (at least one). `enabled: false` disables it (`disabled_reason: member`); `enabled: true` re-enables it and resets `consecutive_failures`. A new `url` is validated like at create, keeps the kind, resets `consecutive_failures` and re-enables a channel the platform disabled (`failing` or `invalid_url`); a channel you disabled stays disabled. The kind never changes.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:write` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateNotificationChannelRequest" + }, + "examples": { + "enable": { + "summary": "Re-enable a channel (and reset its failures).", + "value": { + "enabled": true + } + }, + "replace_url": { + "summary": "Replace the URL (the old one is overwritten).", + "value": { + "url": "https://hooks.slack.com/services/..." + } + }, + "rename": { + "summary": "Rename it.", + "value": { + "name": "on-call" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The channel, changed.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannel" + }, + "example": { + "id": "7c1e4a9b-2d5f-4e8a-b3c6-9f0d1a2b3c4d", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": "2026-10-14T10:00:03Z", + "last_delivery_status": "delivered", + "subscriptions": [ + { + "event": "approval.pending", + "automation_id": null + }, + { + "event": "run.failed", + "automation_id": null + }, + { + "event": "automation.disabled", + "automation_id": null + }, + { + "event": "trigger.failing", + "automation_id": null + } + ], + "signing": null, + "updated_by": "cli_session", + "created_at": "2026-10-12T09:00:00Z", + "updated_at": "2026-10-14T09:30:00Z" + } + } + } + }, + "400": { + "description": "A field that does not pass validation, no field at all, an unknown field, or a URL that breaks a rule (`invalid_url`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "empty": { + "summary": "No field to change.", + "value": { + "error": { + "message": "send at least one of name, enabled, url", + "type": "invalid_request_error", + "param": null, + "code": "invalid_request" + } + } + }, + "name": { + "summary": "A name that is empty or too long.", + "value": { + "error": { + "message": "the name must be 1 to 64 characters without control characters", + "type": "invalid_request_error", + "param": "name", + "code": "invalid_request" + } + } + }, + "invalid_url": { + "summary": "A URL that breaks a rule: the message names the rule, never the URL.", + "value": { + "error": { + "message": "a Slack channel needs an incoming-webhook URL (https://hooks.slack.com/services/…)", + "type": "invalid_request_error", + "param": "url", + "code": "invalid_url" + } + } + }, + "unknown_field": { + "summary": "An unknown field (`param` names it).", + "value": { + "error": { + "message": "send one JSON object with the documented fields", + "type": "invalid_request_error", + "param": "secret", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:write` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications:write\" 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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:write\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + }, + "409": { + "description": "The name is taken (`name_taken`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "a notification channel with this name already exists", + "type": "invalid_request_error", + "param": "name", + "code": "name_taken" + } + } + } + } + }, + "429": { + "description": "Too many channel changes (`rate_limited`): every create, change, delete, subscriptions replacement and rotation of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal and the CLI. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many notification channel changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + }, + "503": { + "description": "Notifications are temporarily unavailable (`notifications_unavailable`). Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "notifications are temporarily unavailable; try again", + "type": "server_error", + "param": null, + "code": "notifications_unavailable" + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteNotificationChannel", + "tags": [ + "Notifications" + ], + "summary": "Delete a notification channel", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Deletes the channel, its subscriptions and its pending notifications.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:write` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + } + ], + "responses": { + "204": { + "description": "Deleted.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:write` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications:write\" 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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:write\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + }, + "429": { + "description": "Too many channel changes (`rate_limited`): every create, change, delete, subscriptions replacement and rotation of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal and the CLI. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many notification channel changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + } + }, + "/notification-channels/{id}/deliveries": { + "get": { + "operationId": "listNotificationDeliveries", + "tags": [ + "Notifications" + ], + "summary": "List a channel's deliveries", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "The channel's sends of the last 30 days, newest first: event, status, HTTP status and error code. Never a payload, a URL or a response body. A test is recorded with `event: test`; a digest with `event: digest` and `items` = the notifications it grouped.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:read` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "description": "How many deliveries, 1 to 100 (default 50)." + } + ], + "responses": { + "200": { + "description": "The deliveries.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDeliveryList" + }, + "example": { + "object": "list", + "data": [ + { + "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", + "object": "notification_delivery", + "event": "run.failed", + "items": 1, + "run_id": "3f8a2c1d-6b4e-4d7a-8e9f-1a2b3c4d5e6f", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "attempt": 1, + "status": "delivered", + "http_status": 200, + "error": null, + "created_at": "2026-10-14T10:00:03Z" + } + ] + } + } + } + }, + "400": { + "description": "`limit` outside 1 to 100.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "limit": { + "summary": "A limit outside 1 to 100.", + "value": { + "error": { + "message": "limit must be 1 to 100", + "type": "invalid_request_error", + "param": "limit", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:read` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications: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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:read\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + } + } + } + }, + "/notification-channels/{id}/subscriptions": { + "put": { + "operationId": "replaceNotificationSubscriptions", + "tags": [ + "Notifications" + ], + "summary": "Replace a channel's subscriptions", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Replaces the channel's whole set of subscriptions. Each one is an event, for every automation (`automation_id: null`) or for one of yours. Up to 50; an empty list removes them all (the channel then receives nothing). Duplicates are merged.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:write` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceNotificationSubscriptionsRequest" + }, + "example": { + "subscriptions": [ + { + "event": "run.failed", + "automation_id": null + }, + { + "event": "run.finished", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "The channel, with its new subscriptions.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationChannel" + }, + "example": { + "id": "7c1e4a9b-2d5f-4e8a-b3c6-9f0d1a2b3c4d", + "object": "notification_channel", + "name": "team alerts", + "kind": "slack", + "url_hint": "hooks.slack.com/…/abcd", + "enabled": true, + "disabled_reason": null, + "consecutive_failures": 0, + "last_delivery_at": "2026-10-14T10:00:03Z", + "last_delivery_status": "delivered", + "subscriptions": [ + { + "event": "run.failed", + "automation_id": null + }, + { + "event": "run.finished", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69" + } + ], + "signing": null, + "updated_by": "cli_session", + "created_at": "2026-10-12T09:00:00Z", + "updated_at": "2026-10-14T09:30:00Z" + } + } + } + }, + "400": { + "description": "A missing `subscriptions` key, an unknown event or more than 50 items.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "missing": { + "summary": "No `subscriptions` key.", + "value": { + "error": { + "message": "subscriptions is required (an empty list removes every subscription)", + "type": "invalid_request_error", + "param": "subscriptions", + "code": "invalid_request" + } + } + }, + "event": { + "summary": "An unknown event.", + "value": { + "error": { + "message": "event must be one of approval.pending, run.finished, run.failed, automation.disabled, trigger.failing", + "type": "invalid_request_error", + "param": "subscriptions", + "code": "invalid_request" + } + } + }, + "too_many": { + "summary": "More than 50.", + "value": { + "error": { + "message": "at most 50 subscriptions per channel", + "type": "invalid_request_error", + "param": "subscriptions", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:write` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications:write\" 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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:write\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`). `automation_not_found` (`param: automation_id`): the automation is not one of yours.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "automation_not_found": { + "summary": "An `automation_id` that is not one of your automations (or is malformed).", + "value": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": "automation_id", + "code": "automation_not_found" + } + } + } + } + } + } + }, + "429": { + "description": "Too many channel changes (`rate_limited`): every create, change, delete, subscriptions replacement and rotation of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal and the CLI. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many notification channel changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + } + }, + "/notification-channels/{id}/test": { + "post": { + "operationId": "testNotificationChannel", + "tags": [ + "Notifications" + ], + "summary": "Send a test notification", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Sends a test message to the channel now and answers whether it arrived. It is recorded in the deliveries (`event: test`), counts against the channel's delivery budget (30 a minute, 1,000 a day) and never changes `consecutive_failures`. The channel must be enabled. No request body.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:write` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + } + ], + "responses": { + "200": { + "description": "The result of the test send.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationTestResult" + }, + "example": { + "delivered": true, + "status": "delivered", + "http_status": 200, + "error": null + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:write` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications:write\" 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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:write\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + }, + "409": { + "description": "The channel is disabled (`channel_disabled`): enable it first.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "the channel is disabled; enable it first", + "type": "invalid_request_error", + "param": null, + "code": "channel_disabled" + } + } + } + } + }, + "429": { + "description": "Too many tests (`rate_limited`): 5 at once, then 1 a minute per member. Tests do not spend the budget of channel changes. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "too many notification requests recently; try again later", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + }, + "503": { + "description": "Notifications are temporarily unavailable (`notifications_unavailable`). Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "notifications are temporarily unavailable; try again", + "type": "server_error", + "param": null, + "code": "notifications_unavailable" + } + } + } + } + } + } + } + }, + "/notification-channels/{id}/rotate-signing-secret": { + "post": { + "operationId": "rotateNotificationSigningSecret", + "tags": [ + "Notifications" + ], + "summary": "Rotate a channel's signing secret", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Creates a new signing secret for an `https` channel and returns it, once. For the next 24 hours every request carries two signatures (the new one first), so your endpoint can switch without missing a notification. No request body.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `notifications:write` scope. An API key (`sk-...`) is refused (`403 credential_not_allowed`), and so is the key that lives inside a workspace (`403 workspace_key_not_allowed`).", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The notification channel id. A malformed id answers `404`." + } + ], + "responses": { + "200": { + "description": "The new signing secret, shown only here.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationSigningSecretRotated" + }, + "example": { + "signing_secret": "nan_nsec_...", + "previous_expires_at": "2026-10-15T10:00:00Z" + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `notifications:write` scope (`token_scope_denied`), an API key (`sk-...`) or a session from an older client (`credential_not_allowed`), or the key that lives inside a workspace (`workspace_key_not_allowed`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `notifications:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"notifications:write\" 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" + } + } + }, + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", + "value": { + "error": { + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"notifications:write\" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "credential_not_allowed" + } + } + }, + "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 notification channel with that id among yours (another member's is also `404`, and so is a malformed id), or notifications are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "unavailable": { + "summary": "Notifications or the write API are not available for your account.", + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + }, + "not_found": { + "summary": "No channel with that id among yours.", + "value": { + "error": { + "message": "notification channel not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + }, + "409": { + "description": "Not an `https` channel (`signing_not_supported`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "only https channels have a signing secret", + "type": "invalid_request_error", + "param": null, + "code": "signing_not_supported" + } + } + } + } + }, + "429": { + "description": "Too many channel changes (`rate_limited`): every create, change, delete, subscriptions replacement and rotation of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal and the CLI. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many notification channel changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + }, + "503": { + "description": "Notifications are temporarily unavailable (`notifications_unavailable`). Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "notifications are temporarily unavailable; try again", + "type": "server_error", + "param": null, + "code": "notifications_unavailable" + } + } + } + } + } + } + } + } + }, + "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, Secrets (`/v1/secrets...`), Notifications (`/v1/notification-channels...`) and the two Workspaces endpoints, and only where it carries the scope the endpoint names: `runs`, `workspaces:read`, `automations:read`, `automations:run`, `automations:write`, `secrets:read`, `secrets:write`, `notifications:read` or `notifications:write`. A missing scope answers `403 token_scope_denied`. New tokens get `runs` and `workspaces:read` by default; a token with an `automations:`, `secrets:` or `notifications:` 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, and it changes automations, secrets or notification channels only with `automations:write`, `secrets:write` or `notifications:write`. 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." + }, + "cliSession": { + "type": "http", + "scheme": "bearer", + "description": "The session the [NaN CLI](https://nan.builders/docs/nan-cli) saves when you sign in with `nan auth login`, sent as `Authorization: Bearer `. It manages your secrets, automations and notification channels where the write API is available for your account, and it can do everything you can do signed in, except approve. Sign in on your own machine: a session saved inside a workspace can be read by the agents that run there." + } + }, + "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" ], @@ -8814,811 +10743,1159 @@ } } }, - "RunEventDataToolCall": { + "RunEventDataToolCall": { + "type": "object", + "description": "`data` of a `tool_call` event. The agent called a tool.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "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" + }, + "name": { + "type": "string" + }, + "is_error": { + "type": "boolean" + }, + "output_preview": { + "type": "string", + "description": "Output, up to 4 KiB." + } + } + }, + "RunEventDataUsage": { + "type": "object", + "description": "`data` of a `usage` event. Tokens used so far, as the agent reports them (cumulative).", + "properties": { + "model": { + "type": [ + "string", + "null" + ] + }, + "prompt_tokens": { + "type": "integer" + }, + "completion_tokens": { + "type": "integer" + } + } + }, + "RunEventDataError": { + "type": "object", + "description": "`data` of an `error` event. Something went wrong.", + "properties": { + "code": { + "type": "string", + "enum": [ + "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" + ] + }, + "message": { + "type": "string", + "description": "Up to 2 KiB." + } + } + }, + "RunEventDataTruncated": { + "type": "object", + "description": "`data` of a `truncated` event. The run hit its event limit (20000 events) or size limit (16 MiB): later events are dropped, except `artifact`, `error` and `finished`.", + "properties": { + "reason": { + "type": "string", + "enum": [ + "event_cap", + "size_cap" + ] + } + } + }, + "RunEventDataFinished": { + "type": "object", + "description": "`data` of a `finished` event. The run ended. Always the last event.", + "properties": { + "status": { + "type": "string", + "enum": [ + "succeeded", + "failed", + "cancelled", + "timed_out" + ] + }, + "exit_code": { + "type": "integer" + }, + "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 + ] + }, + "summary": { + "type": "string" + } + } + }, + "RunEvent": { + "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 `***`.", + "properties": { + "seq": { + "type": "integer", + "description": "1, 2, 3... in order, per run. Use it to resume (`after`, `Last-Event-ID`)." + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "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." + }, + "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 ./...\"}" + } + } + }, + "RunEventPage": { "type": "object", - "description": "`data` of a `tool_call` event. The agent called a tool.", "properties": { - "id": { - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunEvent" + } }, - "name": { - "type": "string" + "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." }, - "args_preview": { - "type": "string", - "description": "Arguments, up to 2 KiB." + "done": { + "type": "boolean", + "description": "`true` when the run is over and every event has been returned." } } }, - "RunEventDataToolResult": { + "WorkspaceSlot": { "type": "object", - "description": "`data` of a `tool_result` event. What a tool answered. `id` matches its `tool_call`.", + "description": "The purchased slot paying for the workspace.", "properties": { "id": { + "type": "integer" + }, + "tier": { "type": "string" }, - "name": { + "status": { "type": "string" }, - "is_error": { + "cancel_at_period_end": { "type": "boolean" }, - "output_preview": { - "type": "string", - "description": "Output, up to 4 KiB." + "current_period_end": { + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, - "RunEventDataUsage": { + "Workspace": { "type": "object", - "description": "`data` of a `usage` event. Tokens used so far, as the agent reports them (cumulative).", + "description": "One of your workspaces. Never carries keys, environment values or agent configuration.", "properties": { - "model": { + "workspace_uuid": { + "type": "string", + "format": "uuid", + "description": "The workspace id. Accepted by `workspace` in `POST /v1/runs`." + }, + "name": { + "type": "string", + "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." + }, + "run_agents": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "pi", + "pi_gentle", + "hermes" + ] + }, + "description": "The agents a run or an automation can use in this workspace, in display order (`pi`, `pi_gentle`, `hermes`). `pi` needs Pi installed, `pi_gentle` needs Pi and gentle-shell, `hermes` needs Hermes chosen for the workspace or installed. A run (from `POST /v1/runs` or from an automation) with an agent that is not listed here is refused with `409 agent_not_installed`. Empty when no agent is installed." + }, + "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" + }, + "disk_gib": { + "type": "integer" + }, + "base_image_ver": { + "type": "string" + }, + "tier": { + "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" + }, + "updated_at": { + "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`." }, - "prompt_tokens": { - "type": "integer" + "grace_until": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When an open grace period ends (the data is deleted then), or `null`." }, - "completion_tokens": { - "type": "integer" + "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" + ] + } + } + }, + "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." + } + } } } }, - "RunEventDataError": { - "type": "object", - "description": "`data` of an `error` event. Something went wrong.", - "properties": { - "code": { - "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" - ] + "WorkspaceDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Workspace" }, - "message": { - "type": "string", - "description": "Up to 2 KiB." + { + "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." + } + } } - } + ] }, - "RunEventDataTruncated": { + "RunInput": { "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`.", + "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": { - "reason": { - "type": "string", + "untrusted": { + "type": "boolean", "enum": [ - "event_cap", - "size_cap" + true ] - } - } - }, - "RunEventDataFinished": { - "type": "object", - "description": "`data` of a `finished` event. The run ended. Always the last event.", - "properties": { - "status": { + }, + "source": { "type": "string", "enum": [ - "succeeded", - "failed", - "cancelled", - "timed_out" + "api", + "webhook:github", + "webhook:stripe", + "webhook:nan", + "schedule" ] }, - "exit_code": { - "type": "integer" + "event": { + "type": "string", + "description": "The event name, for a webhook." }, - "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 - ] + "delivery": { + "type": "string", + "description": "The delivery id, for a webhook." }, - "summary": { - "type": "string" + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The input itself: a JSON object." } } }, - "RunEvent": { + "ProposalAction": { "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": "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": { - "seq": { - "type": "integer", - "description": "1, 2, 3... in order, per run. Use it to resume (`after`, `Last-Event-ID`)." + "kind": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] }, - "ts": { + "repo": { "type": "string", - "format": "date-time" + "description": "`owner/name`: always the automation's `github_repo`." }, - "type": { + "base": { "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." + "description": "`git_push`, `github_create_pr`: the base branch." }, - "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 ./...\"}" - } - } - }, - "RunEventPage": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RunEvent" - } + "branch": { + "type": "string", + "description": "`git_push`: the branch pushed to." }, - "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." + "commit": { + "type": "string", + "description": "`git_push`: the agent's commit." }, - "done": { + "commit_message": { + "type": "string", + "description": "`git_push`: the message of the pushed commit." + }, + "force": { "type": "boolean", - "description": "`true` when the run is over and every event has been returned." - } - } - }, - "WorkspaceSlot": { - "type": "object", - "description": "The purchased slot paying for the workspace.", - "properties": { - "id": { - "type": "integer" + "description": "`git_push`: a force push." }, - "tier": { - "type": "string" + "head": { + "type": "string", + "description": "`github_create_pr`: the branch to merge." }, - "status": { - "type": "string" + "title": { + "type": "string", + "description": "`github_create_pr`: the title." }, - "cancel_at_period_end": { - "type": "boolean" + "draft": { + "type": "boolean", + "description": "`github_create_pr`: open it as a draft." }, - "current_period_end": { - "type": [ - "string", - "null" + "number": { + "type": "integer", + "description": "The pull request or issue number." + }, + "event": { + "type": "string", + "enum": [ + "COMMENT", + "APPROVE", + "REQUEST_CHANGES" ], - "format": "date-time" + "description": "`github_pr_review`: the review." + }, + "body": { + "type": "string", + "description": "The text of a pull request, comment or review." + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "`github_add_labels`: up to 10." } } }, - "Workspace": { + "Diffstat": { "type": "object", - "description": "One of your workspaces. Never carries keys, environment values or agent configuration.", + "description": "The files a push changes.", "properties": { - "workspace_uuid": { - "type": "string", - "format": "uuid", - "description": "The workspace id. Accepted by `workspace` in `POST /v1/runs`." + "files_changed": { + "type": "integer" }, - "name": { - "type": "string", - "description": "Unique among your workspaces. Accepted by `workspace` in `POST /v1/runs`." + "insertions": { + "type": "integer" }, - "agent_selection": { + "deletions": { + "type": "integer" + }, + "files": { "type": "array", "items": { - "type": "string" - }, - "description": "Agents and tools chosen for the workspace." + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer" + }, + "deleted": { + "type": "integer" + }, + "binary": { + "type": "boolean" + } + } + } }, - "run_agents": { + "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": [ + 1 + ] + }, + "actions": { "type": "array", "items": { - "type": "string", - "enum": [ - "pi", - "pi_gentle", - "hermes" - ] - }, - "description": "The agents a run or an automation can use in this workspace, in display order (`pi`, `pi_gentle`, `hermes`). `pi` needs Pi installed, `pi_gentle` needs Pi and gentle-shell, `hermes` needs Hermes chosen for the workspace or installed. A run (from `POST /v1/runs` or from an automation) with an agent that is not listed here is refused with `409 agent_not_installed`. Empty when no agent is installed." + "$ref": "#/components/schemas/ProposalAction" + } }, - "status": { + "base": { "type": "string", - "enum": [ - "provisioning", - "running", - "stopped", - "crashed", - "unknown" - ], - "description": "Runs can only be created while it is `running`." + "description": "With a push: the base branch." }, - "ssh_command": { + "merge_base": { "type": "string", - "description": "The command to connect over SSH." - }, - "vcpus": { - "type": "integer" - }, - "memory_mib": { - "type": "integer" - }, - "disk_gib": { - "type": "integer" + "description": "With a push: the commit the pushed commit is built on." }, - "base_image_ver": { + "bundle_sha256": { "type": "string" }, - "tier": { + "diff_sha256": { "type": "string", - "enum": [ - "free", - "micro", - "nano", - "basic", - "medium", - "large" - ], - "description": "Size. `free` is the Micro-sized workspace included with Premium." + "description": "With a push: the hash of the diff. What is pushed must match it." }, - "created_at": { + "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", - "format": "date-time" + "format": "uuid" }, - "updated_at": { + "state": { "type": "string", - "format": "date-time" + "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)." }, - "state_reason": { + "approval_from": { "type": [ - "string", + "integer", "null" ], - "enum": [ - "dunning", - "canceled", - "disputed", - "churn", - "banned", - "pending_capacity", - "rescue", - null - ], - "description": "Why the workspace is not running normally, or `null`." + "description": "Index of the first action that needs approval. `null` when none does." }, - "grace_until": { + "expires_at": { "type": [ "string", "null" ], "format": "date-time", - "description": "When an open grace period ends (the data is deleted then), or `null`." + "description": "When a pending approval expires." }, - "slot": { + "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/WorkspaceSlot" + "$ref": "#/components/schemas/Diffstat" }, { "type": "null" } - ], - "description": "`null` for the free workspace." + ] }, - "inference_attached": { + "has_diff": { "type": "boolean", - "description": "Whether the workspace has an inference key (never the key itself). Runs need it." + "description": "A full diff is kept (`GET /v1/runs/{id}/proposal.diff`). Only when your approval covers a push." }, - "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" - ] - } - } + "diff_masked": { + "type": "boolean", + "description": "The kept diff contains the value of one of your secrets, shown as `[masked]`. Approving pushes the real value." }, - "backup": { + "decided_at": { "type": [ - "object", + "string", "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." - } - } + "format": "date-time" + }, + "prefix_apply_run_id": { + "type": [ + "string", + "null" + ], + "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." } } }, - "WorkspaceDetail": { + "ApprovalListItem": { "allOf": [ { - "$ref": "#/components/schemas/Workspace" + "$ref": "#/components/schemas/Approval" }, { "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." + "run_state": { + "type": "string", + "description": "The state of the run." + }, + "workspace_id": { + "type": "string", + "format": "uuid" + }, + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" } } } ] }, - "RunInput": { + "ApprovalList": { "type": "object", - "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": { - "untrusted": { - "type": "boolean", - "enum": [ - true - ] - }, - "source": { + "object": { "type": "string", "enum": [ - "api", - "webhook:github", - "webhook:stripe", - "webhook:nan", - "schedule" + "list" ] }, - "event": { - "type": "string", - "description": "The event name, for a webhook." - }, - "delivery": { - "type": "string", - "description": "The delivery id, for a webhook." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApprovalListItem" + }, + "description": "Newest first." }, - "payload": { - "type": "object", - "additionalProperties": true, - "description": "The input itself: a JSON object." + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass it as `cursor` for the next page. `null` on the last page." } } }, - "ProposalAction": { + "Automation": { "type": "object", - "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" - ], + "description": "A saved agent task. Created and edited in the portal. Never carries a secret value.", "properties": { - "kind": { + "id": { + "type": "string", + "format": "uuid" + }, + "object": { "type": "string", "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" + "automation" ] }, - "repo": { + "name": { "type": "string", - "description": "`owner/name`: always the automation's `github_repo`." + "description": "Up to 64 characters, unique among yours." }, - "base": { - "type": "string", - "description": "`git_push`, `github_create_pr`: the base branch." + "description": { + "type": "string" }, - "branch": { + "template": { "type": "string", - "description": "`git_push`: the branch pushed to." + "enum": [ + "custom", + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" + ] }, - "commit": { + "workspace": { "type": "string", - "description": "`git_push`: the agent's commit." + "format": "uuid", + "description": "The workspace id." }, - "commit_message": { + "agent": { "type": "string", - "description": "`git_push`: the message of the pushed commit." + "enum": [ + "pi", + "pi_gentle", + "hermes" + ], + "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). Creating an automation, moving it to another workspace, changing its agent or switching it back on needs the agent installed in the workspace (`409 agent_not_installed`). If the agent is removed from the workspace later, the automation keeps it and its runs are refused with `agent_not_installed`." }, - "force": { - "type": "boolean", - "description": "`git_push`: a force push." + "model": { + "type": [ + "string", + "null" + ], + "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." }, - "head": { + "instructions": { "type": "string", - "description": "`github_create_pr`: the branch to merge." + "description": "Up to 16 KiB. Trusted: the agent follows them." }, - "title": { - "type": "string", - "description": "`github_create_pr`: the title." + "cwd": { + "type": "string" }, - "draft": { - "type": "boolean", - "description": "`github_create_pr`: open it as a draft." + "git_isolation": { + "type": [ + "boolean", + "null" + ], + "description": "Separate branch: `true` on, `false` off, `null` auto." }, - "number": { + "timeout_seconds": { "type": "integer", - "description": "The pull request or issue number." + "minimum": 60, + "maximum": 7200 }, - "event": { + "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." + }, + "approval_ttl_seconds": { + "type": "integer", + "minimum": 3600, + "maximum": 604800, + "description": "How long a proposal waits for you. 259200 (72 hours) by default." + }, + "inference": { + "$ref": "#/components/schemas/RunInference" + }, + "github_repo": { + "type": [ + "string", + "null" + ], + "description": "`owner/name`: the repository the actions act on." + }, + "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." + } + } + } + }, + "max_runs_per_hour": { + "type": "integer", + "minimum": 1, + "maximum": 120 + }, + "concurrency_policy": { "type": "string", "enum": [ - "COMMENT", - "APPROVE", - "REQUEST_CHANGES" + "queue", + "skip_if_active", + "supersede" ], - "description": "`github_pr_review`: the review." + "description": "What happens when a new event arrives for the same pull request, issue, branch or schedule while a run for it is active, including one awaiting approval. `queue`: a new run is queued. `skip_if_active`: the new event is skipped. `supersede`: the new event starts a run and cancels the active runs of this automation for the same pull request, issue, branch or Stripe object (they end `cancelled`, with `superseded_by` set); events without such a subject (schedules, manual runs, NaN senders) behave as `queue`." }, - "body": { + "max_trigger_depth": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "enabled": { + "type": "boolean" + }, + "disabled_reason": { + "type": [ + "string", + "null" + ], + "enum": [ + "member", + "workspace_deleted", + "secret_deleted", + null + ] + }, + "created_at": { "type": "string", - "description": "The text of a pull request, comment or review." + "format": "date-time" }, - "labels": { - "type": "array", - "items": { - "type": "string" - }, - "description": "`github_add_labels`: up to 10." + "updated_at": { + "type": "string", + "format": "date-time" + }, + "checkout": { + "type": "string", + "enum": [ + "workspace", + "fresh" + ], + "default": "workspace", + "description": "Where the agent works. `workspace`: in `cwd`, in your workspace, as any run. `fresh`: NaN clones `github_repo` for each run into a folder of its own, at the event's exact commit (the pull request's head commit for a `pull_request` event on that repository, the run's head commit for a `workflow_run` event on that repository, the latest commit of the default branch otherwise), and deletes it when the run ends, whatever the outcome; `cwd` is not used and `git_isolation` must be `null` or `false`. A `git_push` is refused in `fresh` mode. Needs `github_repo`." + }, + "sticky_pr_comment": { + "type": "boolean", + "default": false, + "description": "`true`: the automation keeps one comment per pull request. Each run's `github_pr_comment` edits the comment NaN posted before for this automation instead of adding a new one. NaN only edits a comment it posted itself and marked for this automation; issue comments are never edited." + }, + "checkout_untrusted_heads": { + "type": "boolean", + "default": false, + "description": "Only with `checkout: \"fresh\"`. `false` (the default): pull requests from forks, pull requests whose author has no write access to the repository (not an owner, member or collaborator) and `workflow_run` events from forks are ignored (delivery outcome `ignored`), because the agent and the tests it runs would execute that code with the automation's agent secrets in your workspace. `true`: they run too. Your decision." + }, + "updated_by": { + "type": "string", + "enum": [ + "browser_session", + "cli_session", + "platform_token" + ], + "description": "The kind of credential that made the last change: `browser_session` (the portal), `cli_session` (the NaN CLI after `nan auth login`) or `platform_token` (a `nan_pat_...` token). Objects last changed before the write API read `browser_session`." } } }, - "Diffstat": { + "AutomationList": { "type": "object", - "description": "The files a push changes.", "properties": { - "files_changed": { - "type": "integer" - }, - "insertions": { - "type": "integer" - }, - "deletions": { - "type": "integer" + "object": { + "type": "string", + "enum": [ + "list" + ] }, - "files": { + "data": { "type": "array", "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "added": { - "type": "integer" - }, - "deleted": { - "type": "integer" - }, - "binary": { - "type": "boolean" - } - } + "$ref": "#/components/schemas/Automation" } - }, - "truncated": { - "type": "boolean", - "description": "Only the first files are listed." } } }, - "Proposal": { + "AutomationTemplate": { "type": "object", - "description": "What an automation run proposed: up to 10 actions. Immutable once stored.", + "description": "A template: the defaults the portal fills in when you create an automation from it.", "properties": { - "v": { - "type": "integer", + "id": { + "type": "string", "enum": [ - 1 + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" ] }, - "actions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProposalAction" - } - }, - "base": { - "type": "string", - "description": "With a push: the base branch." - }, - "merge_base": { - "type": "string", - "description": "With a push: the commit the pushed commit is built on." + "name": { + "type": "string" }, - "bundle_sha256": { + "description": { "type": "string" }, - "diff_sha256": { - "type": "string", - "description": "With a push: the hash of the diff. What is pushed must match it." + "instructions": { + "type": "string" }, - "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", - "format": "uuid" + "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" + ] + } }, - "state": { + "trigger_kind": { "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)." - }, - "approval_from": { - "type": [ - "integer", - "null" - ], - "description": "Index of the first action that needs approval. `null` when none does." + "webhook", + "schedule" + ] }, - "expires_at": { + "signature_scheme": { "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." + "enum": [ + "github", + "stripe", + "nan", + null + ] }, - "diffstat": { + "event_filter": { "oneOf": [ { - "$ref": "#/components/schemas/Diffstat" + "$ref": "#/components/schemas/EventFilter" }, { "type": "null" } ] }, - "has_diff": { - "type": "boolean", - "description": "A full diff is kept (`GET /v1/runs/{id}/proposal.diff`). Only when your approval covers a push." - }, - "diff_masked": { - "type": "boolean", - "description": "The kept diff contains the value of one of your secrets, shown as `[masked]`. Approving pushes the real value." - }, - "decided_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "secret_slots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "env": { + "type": "string" + }, + "exposure": { + "type": "string", + "enum": [ + "agent", + "actions" + ] + }, + "optional": { + "type": "boolean" + }, + "help": { + "type": "string" + } + } + } }, - "prefix_apply_run_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The `apply` run of the actions before `approval_from`." + "requires_github_repo": { + "type": "boolean" }, - "apply_run_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The `apply` run of the approved actions." + "suggested_concurrency": { + "type": "string", + "enum": [ + "queue", + "skip_if_active" + ] } } }, - "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": { + "EventFilter": { "type": "object", + "description": "Which webhook deliveries start a run. Every list you fill must match; empty lists match everything.", "properties": { - "object": { - "type": "string", - "enum": [ - "list" - ] + "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." + }, + "actions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "GitHub `action`." + }, + "exclude_drafts": { + "type": "boolean", + "description": "Ignore draft pull requests." }, - "data": { + "conclusions": { "type": "array", "items": { - "$ref": "#/components/schemas/ApprovalListItem" + "type": "string" }, - "description": "Newest first." - }, - "next_cursor": { - "type": [ - "string", - "null" - ], - "description": "Pass it as `cursor` for the next page. `null` on the last page." + "description": "GitHub `workflow_run` conclusion." } } }, - "Automation": { + "Trigger": { "type": "object", - "description": "A saved agent task. Created and edited in the portal. Never carries a secret value.", + "description": "What starts an automation: a webhook or a schedule. The webhook URL and its signing secret are returned only once, in the answer to creating or rotating the trigger, and never afterwards.", "properties": { "id": { "type": "string", @@ -9627,153 +11904,126 @@ "object": { "type": "string", "enum": [ - "automation" + "trigger" ] }, - "name": { + "automation_id": { "type": "string", - "description": "Up to 64 characters, unique among yours." - }, - "description": { - "type": "string" + "format": "uuid" }, - "template": { + "kind": { "type": "string", "enum": [ - "custom", - "pr_review", - "issue_triage", - "ci_failure", - "generic_webhook" + "webhook", + "schedule" ] }, - "workspace": { - "type": "string", - "format": "uuid", - "description": "The workspace id." + "enabled": { + "type": "boolean", + "description": "`false`: paused." }, - "agent": { - "type": "string", - "enum": [ - "pi", - "pi_gentle", - "hermes" + "token_prefix": { + "type": [ + "string", + "null" ], - "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). Creating an automation, moving it to another workspace, changing its agent or switching it back on needs the agent installed in the workspace (`409 agent_not_installed`). If the agent is removed from the workspace later, the automation keeps it and its runs are refused with `agent_not_installed`." + "description": "Webhook: the start of the URL token, to recognise it." }, - "model": { + "signature_scheme": { "type": [ "string", "null" ], - "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." - }, - "instructions": { - "type": "string", - "description": "Up to 16 KiB. Trusted: the agent follows them." + "enum": [ + "github", + "stripe", + "nan", + null + ] }, - "cwd": { - "type": "string" + "event_filter": { + "oneOf": [ + { + "$ref": "#/components/schemas/EventFilter" + }, + { + "type": "null" + } + ] }, - "git_isolation": { + "actor_filter": { "type": [ - "boolean", + "object", "null" ], - "description": "Separate branch: `true` on, `false` off, `null` auto." - }, - "timeout_seconds": { - "type": "integer", - "minimum": 60, - "maximum": 7200 - }, - "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." - }, - "approval_ttl_seconds": { - "type": "integer", - "minimum": 3600, - "maximum": 604800, - "description": "How long a proposal waits for you. 259200 (72 hours) by default." - }, - "inference": { - "$ref": "#/components/schemas/RunInference" + "description": "GitHub only. `null`: every sender.", + "properties": { + "logins": { + "type": "array", + "items": { + "type": "string" + } + }, + "author_associations": { + "type": "array", + "items": { + "type": "string" + } + }, + "exclude_bots": { + "type": "boolean" + } + } }, - "github_repo": { + "prev_secret_until": { "type": [ "string", "null" ], - "description": "`owner/name`: the repository the actions act on." + "format": "date-time", + "description": "After a rotation with grace: until when the previous signing secret still works." }, - "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." - } - } - } + "cron": { + "type": [ + "string", + "null" + ] }, - "max_runs_per_hour": { - "type": "integer", - "minimum": 1, - "maximum": 120 + "timezone": { + "type": [ + "string", + "null" + ] }, - "concurrency_policy": { - "type": "string", - "enum": [ - "queue", - "skip_if_active", - "supersede" + "next_fire_at": { + "type": [ + "string", + "null" ], - "description": "What happens when a new event arrives for the same pull request, issue, branch or schedule while a run for it is active, including one awaiting approval. `queue`: a new run is queued. `skip_if_active`: the new event is skipped. `supersede`: the new event starts a run and cancels the active runs of this automation for the same pull request, issue, branch or Stripe object (they end `cancelled`, with `superseded_by` set); events without such a subject (schedules, manual runs, NaN senders) behave as `queue`." - }, - "max_trigger_depth": { - "type": "integer", - "minimum": 0, - "maximum": 5 + "format": "date-time" }, - "enabled": { - "type": "boolean" + "last_fired_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" }, - "disabled_reason": { + "last_outcome": { "type": [ "string", "null" ], "enum": [ - "member", + "run_created", + "skipped_active", + "not_accepted", + "missed", + "rate_limited", + "queue_full", + "automation_disabled", "workspace_deleted", - "secret_deleted", + "error", null ] }, @@ -9785,37 +12035,71 @@ "type": "string", "format": "date-time" }, - "checkout": { + "updated_by": { + "type": "string", + "enum": [ + "browser_session", + "cli_session", + "platform_token" + ], + "description": "The kind of credential that made the last change: `browser_session` (the portal), `cli_session` (the NaN CLI after `nan auth login`) or `platform_token` (a `nan_pat_...` token). Objects last changed before the write API read `browser_session`." + } + } + }, + "TriggerList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Trigger" + } + } + } + }, + "WebhookDelivery": { + "type": "object", + "description": "One webhook delivery. No payload and no headers are kept.", + "properties": { + "trigger_id": { + "type": "string", + "format": "uuid" + }, + "delivery_id": { + "type": "string", + "description": "The sender's delivery id (GitHub `X-GitHub-Delivery`, Stripe event id, NaN `X-Nan-Delivery`)." + }, + "received_at": { + "type": "string", + "format": "date-time" + }, + "outcome": { "type": "string", "enum": [ - "workspace", - "fresh" - ], - "default": "workspace", - "description": "Where the agent works. `workspace`: in `cwd`, in your workspace, as any run. `fresh`: NaN clones `github_repo` for each run into a folder of its own, at the event's exact commit (the pull request's head commit for a `pull_request` event on that repository, the run's head commit for a `workflow_run` event on that repository, the latest commit of the default branch otherwise), and deletes it when the run ends, whatever the outcome; `cwd` is not used and `git_isolation` must be `null` or `false`. A `git_push` is refused in `fresh` mode. Needs `github_repo`." - }, - "sticky_pr_comment": { - "type": "boolean", - "default": false, - "description": "`true`: the automation keeps one comment per pull request. Each run's `github_pr_comment` edits the comment NaN posted before for this automation instead of adding a new one. NaN only edits a comment it posted itself and marked for this automation; issue comments are never edited." - }, - "checkout_untrusted_heads": { - "type": "boolean", - "default": false, - "description": "Only with `checkout: \"fresh\"`. `false` (the default): pull requests from forks, pull requests whose author has no write access to the repository (not an owner, member or collaborator) and `workflow_run` events from forks are ignored (delivery outcome `ignored`), because the agent and the tests it runs would execute that code with the automation's agent secrets in your workspace. `true`: they run too. Your decision." + "run_created", + "ignored", + "loop_suppressed", + "skipped_active", + "not_accepted", + "too_large" + ] }, - "updated_by": { - "type": "string", - "enum": [ - "browser_session", - "cli_session", - "platform_token" + "run_id": { + "type": [ + "string", + "null" ], - "description": "The kind of credential that made the last change: `browser_session` (the portal), `cli_session` (the NaN CLI after `nan auth login`) or `platform_token` (a `nan_pat_...` token). Objects last changed before the write API read `browser_session`." + "format": "uuid" } } }, - "AutomationList": { + "WebhookDeliveryList": { "type": "object", "properties": { "object": { @@ -9827,34 +12111,36 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Automation" - } + "$ref": "#/components/schemas/WebhookDelivery" + }, + "description": "Newest first." } } }, - "AutomationTemplate": { + "RunAutomationRequest": { "type": "object", - "description": "A template: the defaults the portal fills in when you create an automation from it.", + "additionalProperties": false, "properties": { - "id": { - "type": "string", - "enum": [ - "pr_review", - "issue_triage", - "ci_failure", - "generic_webhook" - ] - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "instructions": { + "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" }, - "approval_kinds": { + "kinds": { "type": "array", "items": { "type": "string", @@ -9866,107 +12152,179 @@ "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" }, - "trigger_kind": { + "kind": { "type": "string", "enum": [ - "webhook", - "schedule" + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" ] }, - "signature_scheme": { + "status": { + "type": "string", + "enum": [ + "started", + "ok", + "failed" + ] + }, + "message": { + "type": "string", + "description": "Plain text, masked." + } + } + }, + "RunCheckout": { + "type": "object", + "description": "The fresh checkout of a run whose automation uses `checkout: \"fresh\"`.", + "properties": { + "mode": { + "type": "string", + "enum": [ + "fresh" + ] + }, + "sha": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$", + "description": "The commit checked out. `null` until the run has resolved the default branch." + }, + "pr_number": { + "type": [ + "integer", + "null" + ], + "description": "The pull request, when the event was about one." + }, + "base_ref": { "type": [ "string", "null" ], + "description": "The pull request's base branch, when there is one." + }, + "label": { + "type": "string", + "description": "For display, for example `fresh @ 0123456789ab` or `fresh @ default branch`." + } + } + }, + "RunInference": { + "type": "object", + "description": "Where the model calls of a run or an automation go. `{\"source\":\"nan\"}`: NaN inference, through the workspace's inference key, counted against your plan. `{\"source\":\"byok\"}`: your own provider key (bring your own key): a secret of kind `provider_key` you saved in the portal (Settings > Secrets > Provider key). Its provider bills the run and NaN does not meter it. The key itself never appears here. Guide: [Use your own provider key](https://nan.builders/docs/runs#use-your-own-provider-key-byok).", + "required": [ + "source" + ], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", "enum": [ - "github", - "stripe", "nan", - null + "byok" ] }, - "event_filter": { - "oneOf": [ - { - "$ref": "#/components/schemas/EventFilter" - }, - { - "type": "null" + "provider": { + "type": "string", + "enum": [ + "openai_compatible", + "anthropic", + "openai", + "gemini" + ], + "description": "Only with `byok`: the provider of the key (`openai_compatible` is any endpoint that speaks the OpenAI API, at the base URL saved with the key)." + }, + "secret": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$", + "description": "Only with `byok`: the name of the provider key secret. On a run it is the name when the run was created, and it stays after the secret is deleted." + }, + "model": { + "type": "string", + "description": "Only with `byok`: the model id, as your provider names it." + } + } + }, + "CreateRunInference": { + "description": "The inference of the run. Leave it out (or send `{\"source\":\"nan\"}`) for NaN inference. `{\"source\":\"byok\",\"secret\":\"\",\"model\":\"\"}` uses your own provider key: `secret` is the name of a secret of kind `provider_key` (created in the portal, with `nan secrets set` or through [Secrets](#tag/secrets)) and `model` the model id your provider serves. With `byok`, leave the top-level `model` out. A run with `byok` needs a signed-in session: an API key or a platform token gets `403 session_required` (they can run an automation that uses your key). Guide: [Use your own provider key](https://nan.builders/docs/runs#use-your-own-provider-key-byok).", + "oneOf": [ + { + "type": "object", + "title": "NaN inference", + "required": [ + "source" + ], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "enum": [ + "nan" + ] } - ] + } }, - "secret_slots": { - "type": "array", - "items": { - "type": "object", - "properties": { - "env": { - "type": "string" - }, - "exposure": { - "type": "string", - "enum": [ - "agent", - "actions" - ] - }, - "optional": { - "type": "boolean" - }, - "help": { - "type": "string" - } + { + "type": "object", + "title": "Your own provider key", + "required": [ + "source", + "secret", + "model" + ], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "enum": [ + "byok" + ] + }, + "secret": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$", + "description": "Name of one of your secrets of kind `provider_key`.", + "example": "TOGETHER_KEY" + }, + "model": { + "type": "string", + "pattern": "^[A-Za-z0-9._:/-]{1,128}$", + "description": "The model id, as your provider names it. It cannot start with `-`.", + "example": "meta-llama/Llama-3.3-70B-Instruct" } } - }, - "requires_github_repo": { - "type": "boolean" - }, - "suggested_concurrency": { - "type": "string", - "enum": [ - "queue", - "skip_if_active" - ] - } - } - }, - "EventFilter": { - "type": "object", - "description": "Which webhook deliveries start a run. Every list you fill must match; empty lists match everything.", - "properties": { - "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." - }, - "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": { + "Secret": { "type": "object", - "description": "What starts an automation: a webhook or a schedule. The webhook URL and its signing secret are returned only once, in the answer to creating or rotating the trigger, and never afterwards.", + "additionalProperties": false, + "description": "A secret's metadata. No field ever carries the value: once saved, nobody can read it back. Guide: [Secrets](https://nan.builders/docs/secrets).", "properties": { "id": { "type": "string", @@ -9975,128 +12333,76 @@ "object": { "type": "string", "enum": [ - "trigger" + "secret" ] }, - "automation_id": { + "name": { "type": "string", - "format": "uuid" + "pattern": "^[A-Z][A-Z0-9_]{0,63}$", + "description": "Unique among your secrets. The variable name automations see by default." }, "kind": { "type": "string", "enum": [ - "webhook", - "schedule" - ] - }, - "enabled": { - "type": "boolean", - "description": "`false`: paused." - }, - "token_prefix": { - "type": [ - "string", - "null" + "generic", + "provider_key" ], - "description": "Webhook: the start of the URL token, to recognise it." + "description": "`generic`: a token or key your automations receive. `provider_key`: your own key with an AI provider, used as the inference of runs and automations ([BYOK](https://nan.builders/docs/runs#use-your-own-provider-key-byok))." }, - "signature_scheme": { + "provider": { "type": [ "string", "null" ], "enum": [ - "github", - "stripe", - "nan", + "openai_compatible", + "anthropic", + "openai", + "gemini", null - ] - }, - "event_filter": { - "oneOf": [ - { - "$ref": "#/components/schemas/EventFilter" - }, - { - "type": "null" - } - ] - }, - "actor_filter": { - "type": [ - "object", - "null" ], - "description": "GitHub only. `null`: every sender.", - "properties": { - "logins": { - "type": "array", - "items": { - "type": "string" - } - }, - "author_associations": { - "type": "array", - "items": { - "type": "string" - } - }, - "exclude_bots": { - "type": "boolean" - } - } + "description": "A provider key's provider; `null` for a generic secret." }, - "prev_secret_until": { + "base_url": { "type": [ "string", "null" ], - "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" - ] + "description": "An `openai_compatible` provider key's base URL; `null` otherwise. It is not a secret: it is shown as it is." }, - "next_fire_at": { + "hint": { "type": [ "string", "null" ], - "format": "date-time" + "description": "The last 4 characters of the value, after `…`, when the value is at least 20 bytes and they are letters, digits, `_` or `-`; `null` otherwise." }, - "last_fired_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "value_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 16384, + "description": "The size of the value, in bytes." }, - "last_outcome": { - "type": [ - "string", - "null" - ], - "enum": [ - "run_created", - "skipped_active", - "not_accepted", - "missed", - "rate_limited", - "queue_full", - "automation_disabled", - "workspace_deleted", - "error", - null - ] + "description": { + "type": "string", + "description": "Up to 256 characters; `\"\"` when there is none." + }, + "used_by": { + "type": "array", + "description": "The automations that use it, as one of their secrets or as their provider key.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + } }, "created_at": { "type": "string", @@ -10106,6 +12412,14 @@ "type": "string", "format": "date-time" }, + "last_used_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When a run last received it (updated at most once a minute)." + }, "updated_by": { "type": "string", "enum": [ @@ -10115,10 +12429,32 @@ ], "description": "The kind of credential that made the last change: `browser_session` (the portal), `cli_session` (the NaN CLI after `nan auth login`) or `platform_token` (a `nan_pat_...` token). Objects last changed before the write API read `browser_session`." } + }, + "example": { + "id": "5b7d9f1a-3c5e-4a7b-9d1f-2e4a6c8e0b13", + "object": "secret", + "name": "GITHUB_TOKEN", + "kind": "generic", + "provider": null, + "base_url": null, + "hint": "…a1b2", + "value_bytes": 93, + "description": "fine-grained, repo acme/api", + "used_by": [ + { + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "name": "PR review api" + } + ], + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-14T10:30:00Z", + "last_used_at": "2026-10-14T11:02:00Z", + "updated_by": "cli_session" } }, - "TriggerList": { + "SecretList": { "type": "object", + "additionalProperties": false, "properties": { "object": { "type": "string", @@ -10129,89 +12465,212 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Trigger" + "$ref": "#/components/schemas/Secret" } } } }, - "WebhookDelivery": { + "CreateSecretRequest": { "type": "object", - "description": "One webhook delivery. No payload and no headers are kept.", + "additionalProperties": false, + "required": [ + "name", + "value" + ], "properties": { - "trigger_id": { + "name": { "type": "string", - "format": "uuid" + "pattern": "^[A-Z][A-Z0-9_]{0,63}$", + "description": "Capitals, digits and `_`, starting with a letter, up to 64 characters. Unique among your secrets (`409 name_taken`)." }, - "delivery_id": { + "value": { "type": "string", - "description": "The sender's delivery id (GitHub `X-GitHub-Delivery`, Stripe event id, NaN `X-Nan-Delivery`)." + "description": "The value, sent once and never returned. A `generic` secret: 1 to 16384 bytes of UTF-8 text, without control characters other than tab and line breaks, and it must not start or end with a line break (values are never trimmed). A `provider_key`: 8 to 4096 printable ASCII characters without spaces or line breaks. Errors name the field (`param: value`), never the value." }, - "received_at": { + "kind": { "type": "string", - "format": "date-time" + "enum": [ + "generic", + "provider_key" + ], + "default": "generic", + "description": "Fixed once created. `provider_key` needs your own provider keys to be available for your account." }, - "outcome": { - "type": "string", + "provider": { + "type": [ + "string", + "null" + ], "enum": [ - "run_created", - "ignored", - "loop_suppressed", - "skipped_active", - "not_accepted", - "too_large" - ] + "openai_compatible", + "anthropic", + "openai", + "gemini", + null + ], + "description": "Required for a `provider_key`, `null` or absent for a `generic` secret. Fixed once created." }, - "run_id": { + "base_url": { "type": [ "string", "null" ], - "format": "uuid" + "description": "Required for an `openai_compatible` provider key, and only for it: an `https` URL with a public host, without `user:password@`, query string or fragment. See [the base URL rules](https://nan.builders/docs/runs#save-your-key-as-a-provider-key)." + }, + "description": { + "type": "string", + "description": "Optional, up to 256 characters without control characters." } } }, - "WebhookDeliveryList": { + "UpdateSecretRequest": { "type": "object", + "additionalProperties": false, + "minProperties": 1, + "description": "At least one field. The whole change applies, or none of it.", "properties": { - "object": { + "description": { "type": "string", - "enum": [ - "list" - ] + "description": "Up to 256 characters without control characters. `\"\"` clears it." }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookDelivery" - }, - "description": "Newest first." + "base_url": { + "type": "string", + "description": "Only for an `openai_compatible` provider key (`400` on any other secret), with the rules of create. `null` is refused." } } }, - "RunAutomationRequest": { + "ReplaceSecretValueRequest": { "type": "object", "additionalProperties": false, + "required": [ + "value" + ], "properties": { - "input": { + "value": { + "type": "string", + "description": "The value, sent once and never returned. A `generic` secret: 1 to 16384 bytes of UTF-8 text, without control characters other than tab and line breaks, and it must not start or end with a line break (values are never trimmed). A `provider_key`: 8 to 4096 printable ASCII characters without spaces or line breaks. Errors name the field (`param: value`), never the value." + } + } + }, + "SecretConflictError": { + "type": "object", + "description": "The OpenAI error envelope, with the automations involved inside `error` (`secret_in_use`, `secrets_too_large`).", + "properties": { + "error": { "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." + "properties": { + "message": { + "type": "string" + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "param": { + "type": [ + "string", + "null" + ] + }, + "code": { + "type": [ + "string", + "null" + ] + }, + "automations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + } + } + } } } }, - "RunEventDataApprovalRequired": { + "CreateAutomationRequest": { "type": "object", - "description": "`data` of an `approval_required` event. The proposal needs your approval.", + "additionalProperties": false, "required": [ - "proposal_sha256", - "kinds" + "name", + "workspace", + "agent" ], - "additionalProperties": false, + "description": "The automation to create. Fields left out take their defaults. Unknown fields are refused (`400`).", "properties": { - "proposal_sha256": { - "type": "string" + "name": { + "type": "string", + "description": "1 to 64 characters, unique among your automations regardless of case (`409 name_taken`)." }, - "kinds": { + "description": { + "type": "string", + "description": "Optional, up to 1024 characters." + }, + "template": { + "type": "string", + "enum": [ + "custom", + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" + ], + "description": "Default `custom`. A template fills in its instructions, approval settings and concurrency when you leave them out. Fixed once created." + }, + "workspace": { + "type": "string", + "description": "The workspace, by id or by name." + }, + "agent": { + "type": "string", + "enum": [ + "pi", + "pi_gentle", + "hermes" + ], + "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). Creating an automation, moving it to another workspace, changing its agent or switching it back on needs the agent installed in the workspace (`409 agent_not_installed`). If the agent is removed from the workspace later, the automation keeps it and its runs are refused with `agent_not_installed`." + }, + "model": { + "type": [ + "string", + "null" + ], + "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." + }, + "instructions": { + "type": "string", + "description": "What the agent does on every run, 1 to 16384 bytes. Optional with a template, which brings its own." + }, + "cwd": { + "type": "string", + "description": "The folder the agent works in: `/home/nan` (the default) or a folder inside it." + }, + "git_isolation": { + "type": [ + "boolean", + "null" + ], + "description": "Separate branch: `true` on, `false` off, `null` auto." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 60, + "maximum": 7200, + "description": "Default 1800." + }, + "approval_kinds": { "type": "array", "items": { "type": "string", @@ -10224,999 +12683,1082 @@ "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" + "description": "The kinds of action that wait for your approval. Absent: every kind. `[]`: none, every action applies without asking." + }, + "approval_ttl_seconds": { + "type": "integer", + "minimum": 3600, + "maximum": 604800, + "description": "How long a proposal waits for you. Default 259200 (72 hours)." + }, + "inference": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRunInference" + } + ], + "description": "The inference of its runs: absent or `{\"source\":\"nan\"}` for NaN inference, `{\"source\":\"byok\",\"secret\",\"model\"}` for your own provider key." + }, + "github_repo": { + "type": [ + "string", + "null" + ], + "description": "`owner/name`: the repository the actions act on." + }, + "secrets": { + "type": "array", + "description": "The secrets its runs receive, up to 20: each one by name, the variable name the run sees and who receives it. The secrets of one run add up to 48 KiB at most (`409 secrets_too_large`).", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "secret", + "env", + "exposure" + ], + "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." + } + } + } + }, + "max_runs_per_hour": { + "type": "integer", + "minimum": 1, + "maximum": 120, + "description": "Default 30." }, - "kind": { + "concurrency_policy": { "type": "string", "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ] + "queue", + "skip_if_active", + "supersede" + ], + "description": "What happens when a new event arrives for the same pull request, issue, branch or schedule while a run for it is active, including one awaiting approval. `queue`: a new run is queued. `skip_if_active`: the new event is skipped. `supersede`: the new event starts a run and cancels the active runs of this automation for the same pull request, issue, branch or Stripe object (they end `cancelled`, with `superseded_by` set); events without such a subject (schedules, manual runs, NaN senders) behave as `queue`." }, - "status": { + "max_trigger_depth": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "Default 2." + }, + "enabled": { + "type": "boolean", + "description": "Default `true`. `false` creates it switched off." + }, + "checkout": { "type": "string", "enum": [ - "started", - "ok", - "failed" - ] + "workspace", + "fresh" + ], + "default": "workspace", + "description": "Where the agent works. `workspace`: in `cwd`, in your workspace, as any run. `fresh`: NaN clones `github_repo` for each run into a folder of its own, at the event's exact commit (the pull request's head commit for a `pull_request` event on that repository, the run's head commit for a `workflow_run` event on that repository, the latest commit of the default branch otherwise), and deletes it when the run ends, whatever the outcome; `cwd` is not used and `git_isolation` must be `null` or `false`. A `git_push` is refused in `fresh` mode. Needs `github_repo`." }, - "message": { - "type": "string", - "description": "Plain text, masked." + "sticky_pr_comment": { + "type": "boolean", + "default": false, + "description": "`true`: the automation keeps one comment per pull request. Each run's `github_pr_comment` edits the comment NaN posted before for this automation instead of adding a new one. NaN only edits a comment it posted itself and marked for this automation; issue comments are never edited." + }, + "checkout_untrusted_heads": { + "type": "boolean", + "default": false, + "description": "Only with `checkout: \"fresh\"`. `false` (the default): pull requests from forks, pull requests whose author has no write access to the repository (not an owner, member or collaborator) and `workflow_run` events from forks are ignored (delivery outcome `ignored`), because the agent and the tests it runs would execute that code with the automation's agent secrets in your workspace. `true`: they run too. Your decision." } } }, - "RunCheckout": { + "UpdateAutomationRequest": { "type": "object", - "description": "The fresh checkout of a run whose automation uses `checkout: \"fresh\"`.", + "additionalProperties": false, + "description": "Only the fields you send change. `null` clears `model`, `git_isolation` and `github_repo`; `inference` cannot be `null`. The template cannot change.", "properties": { - "mode": { + "name": { "type": "string", - "enum": [ - "fresh" - ] + "description": "1 to 64 characters, unique among your automations regardless of case." }, - "sha": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9a-f]{40}$", - "description": "The commit checked out. `null` until the run has resolved the default branch." + "description": { + "type": "string", + "description": "Optional, up to 1024 characters." }, - "pr_number": { - "type": [ - "integer", - "null" + "workspace": { + "type": "string", + "description": "The workspace, by id or by name." + }, + "agent": { + "type": "string", + "enum": [ + "pi", + "pi_gentle", + "hermes" ], - "description": "The pull request, when the event was about one." + "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). Creating an automation, moving it to another workspace, changing its agent or switching it back on needs the agent installed in the workspace (`409 agent_not_installed`). If the agent is removed from the workspace later, the automation keeps it and its runs are refused with `agent_not_installed`." }, - "base_ref": { + "model": { "type": [ "string", "null" ], - "description": "The pull request's base branch, when there is one." + "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." }, - "label": { - "type": "string", - "description": "For display, for example `fresh @ 0123456789ab` or `fresh @ default branch`." - } - } - }, - "RunInference": { - "type": "object", - "description": "Where the model calls of a run or an automation go. `{\"source\":\"nan\"}`: NaN inference, through the workspace's inference key, counted against your plan. `{\"source\":\"byok\"}`: your own provider key (bring your own key): a secret of kind `provider_key` you saved in the portal (Settings > Secrets > Provider key). Its provider bills the run and NaN does not meter it. The key itself never appears here. Guide: [Use your own provider key](https://nan.builders/docs/runs#use-your-own-provider-key-byok).", - "required": [ - "source" - ], - "additionalProperties": false, - "properties": { - "source": { + "instructions": { "type": "string", - "enum": [ - "nan", - "byok" - ] + "description": "1 to 16384 bytes." }, - "provider": { + "cwd": { "type": "string", - "enum": [ - "openai_compatible", - "anthropic", - "openai", - "gemini" + "description": "`/home/nan` or a folder inside it." + }, + "git_isolation": { + "type": [ + "boolean", + "null" ], - "description": "Only with `byok`: the provider of the key (`openai_compatible` is any endpoint that speaks the OpenAI API, at the base URL saved with the key)." + "description": "Separate branch: `true` on, `false` off, `null` auto." }, - "secret": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]{0,63}$", - "description": "Only with `byok`: the name of the provider key secret. On a run it is the name when the run was created, and it stays after the secret is deleted." + "timeout_seconds": { + "type": "integer", + "minimum": 60, + "maximum": 7200 }, - "model": { - "type": "string", - "description": "Only with `byok`: the model id, as your provider names it." - } - } - }, - "CreateRunInference": { - "description": "The inference of the run. Leave it out (or send `{\"source\":\"nan\"}`) for NaN inference. `{\"source\":\"byok\",\"secret\":\"\",\"model\":\"\"}` uses your own provider key: `secret` is the name of a secret of kind `provider_key` (created in the portal, with `nan secrets set` or through [Secrets](#tag/secrets)) and `model` the model id your provider serves. With `byok`, leave the top-level `model` out. A run with `byok` needs a signed-in session: an API key or a platform token gets `403 session_required` (they can run an automation that uses your key). Guide: [Use your own provider key](https://nan.builders/docs/runs#use-your-own-provider-key-byok).", - "oneOf": [ - { - "type": "object", - "title": "NaN inference", - "required": [ - "source" - ], - "additionalProperties": false, - "properties": { - "source": { - "type": "string", - "enum": [ - "nan" - ] + "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": "Replaces the list. `[]`: nothing waits for your approval, every action applies without asking." + }, + "approval_ttl_seconds": { + "type": "integer", + "minimum": 3600, + "maximum": 604800 + }, + "inference": { + "oneOf": [ + { + "$ref": "#/components/schemas/CreateRunInference" } - } + ], + "description": "Replaces the inference. `null` is refused." }, - { - "type": "object", - "title": "Your own provider key", - "required": [ - "source", - "secret", - "model" + "github_repo": { + "type": [ + "string", + "null" ], - "additionalProperties": false, - "properties": { - "source": { - "type": "string", - "enum": [ - "byok" - ] - }, - "secret": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]{0,63}$", - "description": "Name of one of your secrets of kind `provider_key`.", - "example": "TOGETHER_KEY" - }, - "model": { - "type": "string", - "pattern": "^[A-Za-z0-9._:/-]{1,128}$", - "description": "The model id, as your provider names it. It cannot start with `-`.", - "example": "meta-llama/Llama-3.3-70B-Instruct" + "description": "`owner/name`: the repository the actions act on." + }, + "secrets": { + "type": "array", + "description": "Replaces every assignment with this list (`[]` removes them all). Up to 20, 48 KiB per run.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "secret", + "env", + "exposure" + ], + "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." + } } } + }, + "max_runs_per_hour": { + "type": "integer", + "minimum": 1, + "maximum": 120 + }, + "concurrency_policy": { + "type": "string", + "enum": [ + "queue", + "skip_if_active", + "supersede" + ], + "description": "What happens when a new event arrives for the same pull request, issue, branch or schedule while a run for it is active, including one awaiting approval. `queue`: a new run is queued. `skip_if_active`: the new event is skipped. `supersede`: the new event starts a run and cancels the active runs of this automation for the same pull request, issue, branch or Stripe object (they end `cancelled`, with `superseded_by` set); events without such a subject (schedules, manual runs, NaN senders) behave as `queue`." + }, + "max_trigger_depth": { + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "enabled": { + "type": "boolean", + "description": "`false` switches it off (its triggers stop starting runs), `true` switches it back on." + }, + "checkout": { + "type": "string", + "enum": [ + "workspace", + "fresh" + ], + "default": "workspace", + "description": "Where the agent works. `workspace`: in `cwd`, in your workspace, as any run. `fresh`: NaN clones `github_repo` for each run into a folder of its own, at the event's exact commit (the pull request's head commit for a `pull_request` event on that repository, the run's head commit for a `workflow_run` event on that repository, the latest commit of the default branch otherwise), and deletes it when the run ends, whatever the outcome; `cwd` is not used and `git_isolation` must be `null` or `false`. A `git_push` is refused in `fresh` mode. Needs `github_repo`." + }, + "sticky_pr_comment": { + "type": "boolean", + "default": false, + "description": "`true`: the automation keeps one comment per pull request. Each run's `github_pr_comment` edits the comment NaN posted before for this automation instead of adding a new one. NaN only edits a comment it posted itself and marked for this automation; issue comments are never edited." + }, + "checkout_untrusted_heads": { + "type": "boolean", + "default": false, + "description": "Only with `checkout: \"fresh\"`. `false` (the default): pull requests from forks, pull requests whose author has no write access to the repository (not an owner, member or collaborator) and `workflow_run` events from forks are ignored (delivery outcome `ignored`), because the agent and the tests it runs would execute that code with the automation's agent secrets in your workspace. `true`: they run too. Your decision." } - ] + } }, - "Secret": { + "CreateTriggerRequest": { "type": "object", "additionalProperties": false, - "description": "A secret's metadata. No field ever carries the value: once saved, nobody can read it back. Guide: [Secrets](https://nan.builders/docs/secrets).", + "required": [ + "kind" + ], + "description": "A webhook (`kind: webhook`, with `signature_scheme`) or a schedule (`kind: schedule`, with `cron` and `timezone`). Up to 5 triggers per automation (`409 too_many_triggers`) and 10 schedules per member (`409 too_many_schedules`).", "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "object": { + "kind": { "type": "string", "enum": [ - "secret" + "webhook", + "schedule" ] }, - "name": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]{0,63}$", - "description": "Unique among your secrets. The variable name automations see by default." - }, - "kind": { + "signature_scheme": { "type": "string", "enum": [ - "generic", - "provider_key" + "github", + "stripe", + "nan" ], - "description": "`generic`: a token or key your automations receive. `provider_key`: your own key with an AI provider, used as the inference of runs and automations ([BYOK](https://nan.builders/docs/runs#use-your-own-provider-key-byok))." + "description": "Webhook: how the sender signs. See [Webhooks and schedules](https://nan.builders/docs/webhooks)." }, - "provider": { + "signing_secret": { "type": [ "string", "null" ], - "enum": [ - "openai_compatible", - "anthropic", - "openai", - "gemini", - null - ], - "description": "A provider key's provider; `null` for a generic secret." + "description": "Only for `stripe`: the endpoint's signing secret from the Stripe dashboard (`whsec_...`). For `github` and `nan`, NaN generates it and returns it once." }, - "base_url": { - "type": [ - "string", - "null" - ], - "description": "An `openai_compatible` provider key's base URL; `null` otherwise. It is not a secret: it is shown as it is." + "event_filter": { + "oneOf": [ + { + "$ref": "#/components/schemas/EventFilter" + }, + { + "type": "null" + } + ] }, - "hint": { + "actor_filter": { "type": [ - "string", + "object", "null" ], - "description": "The last 4 characters of the value, after `…`, when the value is at least 20 bytes and they are letters, digits, `_` or `-`; `null` otherwise." - }, - "value_bytes": { - "type": "integer", - "minimum": 1, - "maximum": 16384, - "description": "The size of the value, in bytes." - }, - "description": { - "type": "string", - "description": "Up to 256 characters; `\"\"` when there is none." - }, - "used_by": { - "type": "array", - "description": "The automations that use it, as one of their secrets or as their provider key.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { + "description": "GitHub only. `null`: every sender.", + "properties": { + "logins": { + "type": "array", + "items": { "type": "string" } + }, + "author_associations": { + "type": "array", + "items": { + "type": "string" + } + }, + "exclude_bots": { + "type": "boolean" } } }, - "created_at": { + "cron": { "type": "string", - "format": "date-time" + "description": "Schedule: a five-field cron expression, for example `0 9 * * 1-5`." }, - "updated_at": { + "timezone": { "type": "string", - "format": "date-time" + "description": "Schedule: an IANA time zone, for example `Europe/Madrid`." }, - "last_used_at": { + "enabled": { + "type": "boolean", + "description": "Default `true`." + } + } + }, + "UpdateTriggerRequest": { + "type": "object", + "additionalProperties": false, + "description": "Only the fields you send change. The kind and the signature scheme cannot change. `null` clears the event or actor filter.", + "properties": { + "event_filter": { + "oneOf": [ + { + "$ref": "#/components/schemas/EventFilter" + }, + { + "type": "null" + } + ] + }, + "actor_filter": { "type": [ - "string", + "object", "null" ], - "format": "date-time", - "description": "When a run last received it (updated at most once a minute)." + "description": "GitHub only. `null`: every sender.", + "properties": { + "logins": { + "type": "array", + "items": { + "type": "string" + } + }, + "author_associations": { + "type": "array", + "items": { + "type": "string" + } + }, + "exclude_bots": { + "type": "boolean" + } + } }, - "updated_by": { - "type": "string", - "enum": [ - "browser_session", - "cli_session", - "platform_token" - ], - "description": "The kind of credential that made the last change: `browser_session` (the portal), `cli_session` (the NaN CLI after `nan auth login`) or `platform_token` (a `nan_pat_...` token). Objects last changed before the write API read `browser_session`." + "cron": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "description": "`false` pauses it, `true` resumes it." } - }, - "example": { - "id": "5b7d9f1a-3c5e-4a7b-9d1f-2e4a6c8e0b13", - "object": "secret", - "name": "GITHUB_TOKEN", - "kind": "generic", - "provider": null, - "base_url": null, - "hint": "…a1b2", - "value_bytes": 93, - "description": "fine-grained, repo acme/api", - "used_by": [ - { - "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", - "name": "PR review api" - } - ], - "created_at": "2026-10-11T09:00:00Z", - "updated_at": "2026-10-14T10:30:00Z", - "last_used_at": "2026-10-14T11:02:00Z", - "updated_by": "cli_session" } }, - "SecretList": { + "RotateTriggerRequest": { "type": "object", "additionalProperties": false, + "required": [ + "rotate" + ], + "description": "Webhooks only.", "properties": { - "object": { - "type": "string", - "enum": [ - "list" - ] - }, - "data": { + "rotate": { "type": "array", + "minItems": 1, "items": { - "$ref": "#/components/schemas/Secret" - } + "type": "string", + "enum": [ + "url", + "secret" + ] + }, + "description": "`url`: a new webhook URL; the current one stops working at once. `secret`: a new signing secret." + }, + "grace": { + "type": "boolean", + "default": true, + "description": "With `secret`: the old signing secret keeps working for 24 hours (`true`), or stops at once (`false`, for a leaked secret)." + }, + "signing_secret": { + "type": [ + "string", + "null" + ], + "description": "Only for `stripe`, with `secret`: the new `whsec_...` you rolled in Stripe." } } }, - "CreateSecretRequest": { + "TriggerCreated": { "type": "object", "additionalProperties": false, - "required": [ - "name", - "value" - ], + "description": "The new trigger, and for a webhook its full URL and signing secret. This is the only time they are returned: store them now.", "properties": { - "name": { - "type": "string", - "pattern": "^[A-Z][A-Z0-9_]{0,63}$", - "description": "Capitals, digits and `_`, starting with a letter, up to 64 characters. Unique among your secrets (`409 name_taken`)." - }, - "value": { - "type": "string", - "description": "The value, sent once and never returned. A `generic` secret: 1 to 16384 bytes of UTF-8 text, without control characters other than tab and line breaks, and it must not start or end with a line break (values are never trimmed). A `provider_key`: 8 to 4096 printable ASCII characters without spaces or line breaks. Errors name the field (`param: value`), never the value." - }, - "kind": { - "type": "string", - "enum": [ - "generic", - "provider_key" - ], - "default": "generic", - "description": "Fixed once created. `provider_key` needs your own provider keys to be available for your account." + "trigger": { + "$ref": "#/components/schemas/Trigger" }, - "provider": { + "webhook_url": { "type": [ "string", "null" ], - "enum": [ - "openai_compatible", - "anthropic", - "openai", - "gemini", - null - ], - "description": "Required for a `provider_key`, `null` or absent for a `generic` secret. Fixed once created." + "description": "Webhook: the full URL, `https://api.nan.builders/hooks/nan_whk_...`. `null` for a schedule." }, - "base_url": { + "signing_secret": { "type": [ "string", "null" ], - "description": "Required for an `openai_compatible` provider key, and only for it: an `https` URL with a public host, without `user:password@`, query string or fragment. See [the base URL rules](https://nan.builders/docs/runs#save-your-key-as-a-provider-key)." - }, - "description": { - "type": "string", - "description": "Optional, up to 256 characters without control characters." + "description": "Webhook with `github` or `nan`: the signing secret NaN generated (`nan_whsec_...`). `null` for a schedule, and for `stripe` (you sent it)." } } }, - "UpdateSecretRequest": { + "TriggerRotated": { "type": "object", "additionalProperties": false, - "minProperties": 1, - "description": "At least one field. The whole change applies, or none of it.", + "description": "What the rotation created, returned only this once: the new URL when `rotate` had `url`, the new signing secret when it had `secret` (except for `stripe`, whose secret you sent).", "properties": { - "description": { + "webhook_url": { "type": "string", - "description": "Up to 256 characters without control characters. `\"\"` clears it." + "description": "The new full webhook URL." }, - "base_url": { + "signing_secret": { "type": "string", - "description": "Only for an `openai_compatible` provider key (`400` on any other secret), with the rules of create. `null` is refused." + "description": "The new signing secret NaN generated (`nan_whsec_...`). Absent for `stripe`, whose new secret you sent." } } }, - "ReplaceSecretValueRequest": { + "NotificationSubscription": { "type": "object", "additionalProperties": false, "required": [ - "value" + "event", + "automation_id" ], "properties": { - "value": { + "event": { "type": "string", - "description": "The value, sent once and never returned. A `generic` secret: 1 to 16384 bytes of UTF-8 text, without control characters other than tab and line breaks, and it must not start or end with a line break (values are never trimmed). A `provider_key`: 8 to 4096 printable ASCII characters without spaces or line breaks. Errors name the field (`param: value`), never the value." - } - } - }, - "SecretConflictError": { - "type": "object", - "description": "The OpenAI error envelope, with the automations involved inside `error` (`secret_in_use`, `secrets_too_large`).", - "properties": { - "error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "type": { - "type": [ - "string", - "null" - ] - }, - "param": { - "type": [ - "string", - "null" - ] - }, - "code": { - "type": [ - "string", - "null" - ] - }, - "automations": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - } - } - } - } - } + "enum": [ + "approval.pending", + "run.finished", + "run.failed", + "automation.disabled", + "trigger.failing" + ] + }, + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "`null`: every automation of yours, including later ones. Else one of your automations (the subscription is deleted with it)." } } }, - "CreateAutomationRequest": { + "NotificationChannel": { "type": "object", "additionalProperties": false, + "description": "A notification channel. No field ever carries its URL or signing secret. Guide: [Notifications](https://nan.builders/docs/notifications).", "required": [ + "id", + "object", "name", - "workspace", - "agent" + "kind", + "url_hint", + "enabled", + "disabled_reason", + "consecutive_failures", + "last_delivery_at", + "last_delivery_status", + "subscriptions", + "signing", + "updated_by", + "created_at", + "updated_at" ], - "description": "The automation to create. Fields left out take their defaults. Unknown fields are refused (`400`).", "properties": { - "name": { - "type": "string", - "description": "1 to 64 characters, unique among your automations regardless of case (`409 name_taken`)." - }, - "description": { + "id": { "type": "string", - "description": "Optional, up to 1024 characters." + "format": "uuid" }, - "template": { + "object": { "type": "string", "enum": [ - "custom", - "pr_review", - "issue_triage", - "ci_failure", - "generic_webhook" - ], - "description": "Default `custom`. A template fills in its instructions, approval settings and concurrency when you leave them out. Fixed once created." + "notification_channel" + ] }, - "workspace": { + "name": { "type": "string", - "description": "The workspace, by id or by name." + "minLength": 1, + "maxLength": 64, + "description": "Unique among your channels, without regard to case." }, - "agent": { + "kind": { "type": "string", "enum": [ - "pi", - "pi_gentle", - "hermes" + "slack", + "teams", + "discord", + "https" ], - "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). Creating an automation, moving it to another workspace, changing its agent or switching it back on needs the agent installed in the workspace (`409 agent_not_installed`). If the agent is removed from the workspace later, the automation keeps it and its runs are refused with `agent_not_installed`." - }, - "model": { - "type": [ - "string", - "null" - ], - "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." + "description": "Fixed at create." }, - "instructions": { + "url_hint": { "type": "string", - "description": "What the agent does on every run, 1 to 16384 bytes. Optional with a template, which brings its own." + "description": "The URL's host, then `/…/` and the last 4 characters of its path when the path is at least 20 bytes and they are letters, digits, `_` or `-`; the host alone otherwise. Never the URL." }, - "cwd": { - "type": "string", - "description": "The folder the agent works in: `/home/nan` (the default) or a folder inside it." + "enabled": { + "type": "boolean" }, - "git_isolation": { + "disabled_reason": { "type": [ - "boolean", + "string", "null" ], - "description": "Separate branch: `true` on, `false` off, `null` auto." - }, - "timeout_seconds": { - "type": "integer", - "minimum": 60, - "maximum": 7200, - "description": "Default 1800." - }, - "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 kinds of action that wait for your approval. Absent: every kind. `[]`: none, every action applies without asking." + "enum": [ + "member", + "failing", + "invalid_url", + null + ], + "description": "`member`: you disabled it. `failing`: 20 failed attempts in a row. `invalid_url`: the URL resolved to a refused address when sending. `null` while enabled." }, - "approval_ttl_seconds": { + "consecutive_failures": { "type": "integer", - "minimum": 3600, - "maximum": 604800, - "description": "How long a proposal waits for you. Default 259200 (72 hours)." - }, - "inference": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateRunInference" - } - ], - "description": "The inference of its runs: absent or `{\"source\":\"nan\"}` for NaN inference, `{\"source\":\"byok\",\"secret\",\"model\"}` for your own provider key." + "minimum": 0 }, - "github_repo": { + "last_delivery_at": { "type": [ "string", "null" ], - "description": "`owner/name`: the repository the actions act on." - }, - "secrets": { - "type": "array", - "description": "The secrets its runs receive, up to 20: each one by name, the variable name the run sees and who receives it. The secrets of one run add up to 48 KiB at most (`409 secrets_too_large`).", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "secret", - "env", - "exposure" - ], - "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." - } - } - } - }, - "max_runs_per_hour": { - "type": "integer", - "minimum": 1, - "maximum": 120, - "description": "Default 30." + "format": "date-time" }, - "concurrency_policy": { - "type": "string", - "enum": [ - "queue", - "skip_if_active", - "supersede" + "last_delivery_status": { + "type": [ + "string", + "null" ], - "description": "What happens when a new event arrives for the same pull request, issue, branch or schedule while a run for it is active, including one awaiting approval. `queue`: a new run is queued. `skip_if_active`: the new event is skipped. `supersede`: the new event starts a run and cancels the active runs of this automation for the same pull request, issue, branch or Stripe object (they end `cancelled`, with `superseded_by` set); events without such a subject (schedules, manual runs, NaN senders) behave as `queue`." + "enum": [ + "delivered", + "failed", + null + ] }, - "max_trigger_depth": { - "type": "integer", - "minimum": 0, - "maximum": 5, - "description": "Default 2." + "subscriptions": { + "type": "array", + "maxItems": 50, + "items": { + "$ref": "#/components/schemas/NotificationSubscription" + } }, - "enabled": { - "type": "boolean", - "description": "Default `true`. `false` creates it switched off." + "signing": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "previous_expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "description": "On `https` channels only (`null` otherwise): when the previous signing secret stops signing after a rotation (`null` when there is none)." }, - "checkout": { + "updated_by": { "type": "string", "enum": [ - "workspace", - "fresh" + "browser_session", + "cli_session", + "platform_token" ], - "default": "workspace", - "description": "Where the agent works. `workspace`: in `cwd`, in your workspace, as any run. `fresh`: NaN clones `github_repo` for each run into a folder of its own, at the event's exact commit (the pull request's head commit for a `pull_request` event on that repository, the run's head commit for a `workflow_run` event on that repository, the latest commit of the default branch otherwise), and deletes it when the run ends, whatever the outcome; `cwd` is not used and `git_isolation` must be `null` or `false`. A `git_push` is refused in `fresh` mode. Needs `github_repo`." + "description": "The credential of the last change." }, - "sticky_pr_comment": { - "type": "boolean", - "default": false, - "description": "`true`: the automation keeps one comment per pull request. Each run's `github_pr_comment` edits the comment NaN posted before for this automation instead of adding a new one. NaN only edits a comment it posted itself and marked for this automation; issue comments are never edited." + "created_at": { + "type": "string", + "format": "date-time" }, - "checkout_untrusted_heads": { - "type": "boolean", - "default": false, - "description": "Only with `checkout: \"fresh\"`. `false` (the default): pull requests from forks, pull requests whose author has no write access to the repository (not an owner, member or collaborator) and `workflow_run` events from forks are ignored (delivery outcome `ignored`), because the agent and the tests it runs would execute that code with the automation's agent secrets in your workspace. `true`: they run too. Your decision." + "updated_at": { + "type": "string", + "format": "date-time" } } }, - "UpdateAutomationRequest": { + "NotificationChannelList": { "type": "object", "additionalProperties": false, - "description": "Only the fields you send change. `null` clears `model`, `git_isolation` and `github_repo`; `inference` cannot be `null`. The template cannot change.", "properties": { - "name": { - "type": "string", - "description": "1 to 64 characters, unique among your automations regardless of case." - }, - "description": { + "object": { "type": "string", - "description": "Optional, up to 1024 characters." + "enum": [ + "list" + ] }, - "workspace": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationChannel" + } + } + } + }, + "CreateNotificationChannelRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "kind", + "url" + ], + "properties": { + "name": { "type": "string", - "description": "The workspace, by id or by name." + "minLength": 1, + "maxLength": 64 }, - "agent": { + "kind": { "type": "string", "enum": [ - "pi", - "pi_gentle", - "hermes" - ], - "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). Creating an automation, moving it to another workspace, changing its agent or switching it back on needs the agent installed in the workspace (`409 agent_not_installed`). If the agent is removed from the workspace later, the automation keeps it and its runs are refused with `agent_not_installed`." - }, - "model": { - "type": [ - "string", - "null" - ], - "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." - }, - "instructions": { - "type": "string", - "description": "1 to 16384 bytes." + "slack", + "teams", + "discord", + "https" + ] }, - "cwd": { + "url": { "type": "string", - "description": "`/home/nan` or a folder inside it." - }, - "git_isolation": { - "type": [ - "boolean", - "null" - ], - "description": "Separate branch: `true` on, `false` off, `null` auto." + "maxLength": 2048, + "writeOnly": true, + "description": "A credential: write-only. `https` on port 443, a public DNS name (no IP address, no credentials, no fragment), not a NaN or local host, every resolved address public. `slack`: `hooks.slack.com/services/...`. `teams`: a subdomain of `webhook.office.com`, `logic.azure.com` or `powerplatform.com`. `discord`: `discord.com/api/webhooks/...` (or `discordapp.com`, `canary.discord.com`, `ptb.discord.com`)." }, - "timeout_seconds": { - "type": "integer", - "minimum": 60, - "maximum": 7200 - }, - "approval_kinds": { + "events": { "type": "array", "items": { "type": "string", "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" + "approval.pending", + "run.finished", + "run.failed", + "automation.disabled", + "trigger.failing" ] }, - "description": "Replaces the list. `[]`: nothing waits for your approval, every action applies without asking." - }, - "approval_ttl_seconds": { - "type": "integer", - "minimum": 3600, - "maximum": 604800 - }, - "inference": { - "oneOf": [ - { - "$ref": "#/components/schemas/CreateRunInference" - } - ], - "description": "Replaces the inference. `null` is refused." + "description": "Default `approval.pending`, `run.failed`, `automation.disabled`, `trigger.failing`. `[]` subscribes to nothing." }, - "github_repo": { + "automation_id": { "type": [ "string", "null" ], - "description": "`owner/name`: the repository the actions act on." + "format": "uuid", + "description": "Scope `events` to this automation of yours. Omitted or `null`: every automation." + } + } + }, + "UpdateNotificationChannelRequest": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 }, - "secrets": { + "enabled": { + "type": "boolean" + }, + "url": { + "type": "string", + "maxLength": 2048, + "writeOnly": true, + "description": "Replaces the URL, with the rules of create." + } + } + }, + "ReplaceNotificationSubscriptionsRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "subscriptions" + ], + "properties": { + "subscriptions": { "type": "array", - "description": "Replaces every assignment with this list (`[]` removes them all). Up to 20, 48 KiB per run.", + "maxItems": 50, "items": { "type": "object", "additionalProperties": false, "required": [ - "secret", - "env", - "exposure" + "event" ], "properties": { - "secret": { - "type": "string", - "description": "The name of your secret." - }, - "env": { - "type": "string", - "description": "The variable name the run sees." - }, - "exposure": { + "event": { "type": "string", "enum": [ - "agent", - "actions" + "approval.pending", + "run.finished", + "run.failed", + "automation.disabled", + "trigger.failing" + ] + }, + "automation_id": { + "type": [ + "string", + "null" ], - "description": "`agent`: the agent receives it. `actions`: only the step that applies actions." + "format": "uuid", + "description": "Omitted or `null`: every automation." } } } + } + } + }, + "NotificationTestResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "delivered", + "status", + "http_status", + "error" + ], + "properties": { + "delivered": { + "type": "boolean" }, - "max_runs_per_hour": { - "type": "integer", - "minimum": 1, - "maximum": 120 - }, - "concurrency_policy": { + "status": { "type": "string", "enum": [ - "queue", - "skip_if_active", - "supersede" + "delivered", + "failed", + "blocked", + "rate_limited" ], - "description": "What happens when a new event arrives for the same pull request, issue, branch or schedule while a run for it is active, including one awaiting approval. `queue`: a new run is queued. `skip_if_active`: the new event is skipped. `supersede`: the new event starts a run and cancels the active runs of this automation for the same pull request, issue, branch or Stripe object (they end `cancelled`, with `superseded_by` set); events without such a subject (schedules, manual runs, NaN senders) behave as `queue`." - }, - "max_trigger_depth": { - "type": "integer", - "minimum": 0, - "maximum": 5 + "description": "`blocked`: the URL resolved to a refused address. `rate_limited`: the channel's delivery budget is spent." }, - "enabled": { - "type": "boolean", - "description": "`false` switches it off (its triggers stop starting runs), `true` switches it back on." + "http_status": { + "type": [ + "integer", + "null" + ] }, - "checkout": { - "type": "string", - "enum": [ - "workspace", - "fresh" + "error": { + "type": [ + "string", + "null" ], - "default": "workspace", - "description": "Where the agent works. `workspace`: in `cwd`, in your workspace, as any run. `fresh`: NaN clones `github_repo` for each run into a folder of its own, at the event's exact commit (the pull request's head commit for a `pull_request` event on that repository, the run's head commit for a `workflow_run` event on that repository, the latest commit of the default branch otherwise), and deletes it when the run ends, whatever the outcome; `cwd` is not used and `git_isolation` must be `null` or `false`. A `git_push` is refused in `fresh` mode. Needs `github_repo`." - }, - "sticky_pr_comment": { - "type": "boolean", - "default": false, - "description": "`true`: the automation keeps one comment per pull request. Each run's `github_pr_comment` edits the comment NaN posted before for this automation instead of adding a new one. NaN only edits a comment it posted itself and marked for this automation; issue comments are never edited." + "enum": [ + "timeout", + "connect", + "tls", + "dns", + "blocked_address", + "invalid_url", + "redirect", + "http_4xx", + "http_5xx", + "channel_disabled", + "rate_limited", + null + ] + } + } + }, + "NotificationSigningSecretRotated": { + "type": "object", + "additionalProperties": false, + "required": [ + "signing_secret", + "previous_expires_at" + ], + "properties": { + "signing_secret": { + "type": "string", + "pattern": "^nan_nsec_", + "description": "`nan_nsec_` and 43 base64url characters. The new secret, shown only here." }, - "checkout_untrusted_heads": { - "type": "boolean", - "default": false, - "description": "Only with `checkout: \"fresh\"`. `false` (the default): pull requests from forks, pull requests whose author has no write access to the repository (not an owner, member or collaborator) and `workflow_run` events from forks are ignored (delivery outcome `ignored`), because the agent and the tests it runs would execute that code with the automation's agent secrets in your workspace. `true`: they run too. Your decision." + "previous_expires_at": { + "type": "string", + "format": "date-time", + "description": "Until then requests also carry a signature with the previous secret." } } }, - "CreateTriggerRequest": { + "NotificationDelivery": { "type": "object", "additionalProperties": false, - "required": [ - "kind" - ], - "description": "A webhook (`kind: webhook`, with `signature_scheme`) or a schedule (`kind: schedule`, with `cron` and `timezone`). Up to 5 triggers per automation (`409 too_many_triggers`) and 10 schedules per member (`409 too_many_schedules`).", "properties": { - "kind": { + "id": { + "type": "string", + "format": "uuid" + }, + "object": { "type": "string", "enum": [ - "webhook", - "schedule" + "notification_delivery" ] }, - "signature_scheme": { + "event": { "type": "string", "enum": [ - "github", - "stripe", - "nan" + "approval.pending", + "run.finished", + "run.failed", + "automation.disabled", + "trigger.failing", + "test", + "digest" + ] + }, + "items": { + "type": "integer", + "minimum": 0, + "description": "Notifications in this send: 1, the size of a digest, or 0 for a test." + }, + "run_id": { + "type": [ + "string", + "null" ], - "description": "Webhook: how the sender signs. See [Webhooks and schedules](https://nan.builders/docs/webhooks)." + "format": "uuid" }, - "signing_secret": { + "automation_id": { "type": [ "string", "null" ], - "description": "Only for `stripe`: the endpoint's signing secret from the Stripe dashboard (`whsec_...`). For `github` and `nan`, NaN generates it and returns it once." + "format": "uuid" }, - "event_filter": { - "oneOf": [ - { - "$ref": "#/components/schemas/EventFilter" - }, - { - "type": "null" - } + "attempt": { + "type": "integer", + "minimum": 0, + "description": "The attempt number of this send (0 for a drop)." + }, + "status": { + "type": "string", + "enum": [ + "delivered", + "retrying", + "failed", + "blocked", + "rate_limited", + "dropped" ] }, - "actor_filter": { + "http_status": { "type": [ - "object", + "integer", "null" - ], - "description": "GitHub only. `null`: every sender.", - "properties": { - "logins": { - "type": "array", - "items": { - "type": "string" - } - }, - "author_associations": { - "type": "array", - "items": { - "type": "string" - } - }, - "exclude_bots": { - "type": "boolean" - } - } + ] }, - "cron": { - "type": "string", - "description": "Schedule: a five-field cron expression, for example `0 9 * * 1-5`." + "error": { + "type": [ + "string", + "null" + ], + "enum": [ + "timeout", + "connect", + "tls", + "dns", + "blocked_address", + "invalid_url", + "redirect", + "http_4xx", + "http_5xx", + "channel_disabled", + "rate_limited", + null + ] }, - "timezone": { + "created_at": { "type": "string", - "description": "Schedule: an IANA time zone, for example `Europe/Madrid`." - }, - "enabled": { - "type": "boolean", - "description": "Default `true`." + "format": "date-time" } } }, - "UpdateTriggerRequest": { + "NotificationDeliveryList": { "type": "object", "additionalProperties": false, - "description": "Only the fields you send change. The kind and the signature scheme cannot change. `null` clears the event or actor filter.", "properties": { - "event_filter": { - "oneOf": [ - { - "$ref": "#/components/schemas/EventFilter" - }, - { - "type": "null" - } + "object": { + "type": "string", + "enum": [ + "list" ] }, - "actor_filter": { - "type": [ - "object", - "null" - ], - "description": "GitHub only. `null`: every sender.", - "properties": { - "logins": { - "type": "array", - "items": { - "type": "string" - } - }, - "author_associations": { - "type": "array", - "items": { - "type": "string" - } - }, - "exclude_bots": { - "type": "boolean" - } + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationDelivery" } - }, - "cron": { - "type": "string" - }, - "timezone": { - "type": "string" - }, - "enabled": { - "type": "boolean", - "description": "`false` pauses it, `true` resumes it." } } }, - "RotateTriggerRequest": { + "NotificationChannelCreated": { "type": "object", "additionalProperties": false, + "description": "The new channel, plus its signing secret for `https` channels: returned only here and by the rotation. Store it now.", "required": [ - "rotate" + "id", + "object", + "name", + "kind", + "url_hint", + "enabled", + "disabled_reason", + "consecutive_failures", + "last_delivery_at", + "last_delivery_status", + "subscriptions", + "signing", + "updated_by", + "created_at", + "updated_at" ], - "description": "Webhooks only.", "properties": { - "rotate": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "enum": [ - "url", - "secret" - ] - }, - "description": "`url`: a new webhook URL; the current one stops working at once. `secret`: a new signing secret." + "id": { + "type": "string", + "format": "uuid" }, - "grace": { - "type": "boolean", - "default": true, - "description": "With `secret`: the old signing secret keeps working for 24 hours (`true`), or stops at once (`false`, for a leaked secret)." + "object": { + "type": "string", + "enum": [ + "notification_channel" + ] }, - "signing_secret": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Unique among your channels, without regard to case." + }, + "kind": { + "type": "string", + "enum": [ + "slack", + "teams", + "discord", + "https" + ], + "description": "Fixed at create." + }, + "url_hint": { + "type": "string", + "description": "The URL's host, then `/…/` and the last 4 characters of its path when the path is at least 20 bytes and they are letters, digits, `_` or `-`; the host alone otherwise. Never the URL." + }, + "enabled": { + "type": "boolean" + }, + "disabled_reason": { "type": [ "string", "null" ], - "description": "Only for `stripe`, with `secret`: the new `whsec_...` you rolled in Stripe." - } - } - }, - "TriggerCreated": { - "type": "object", - "additionalProperties": false, - "description": "The new trigger, and for a webhook its full URL and signing secret. This is the only time they are returned: store them now.", - "properties": { - "trigger": { - "$ref": "#/components/schemas/Trigger" + "enum": [ + "member", + "failing", + "invalid_url", + null + ], + "description": "`member`: you disabled it. `failing`: 20 failed attempts in a row. `invalid_url`: the URL resolved to a refused address when sending. `null` while enabled." }, - "webhook_url": { + "consecutive_failures": { + "type": "integer", + "minimum": 0 + }, + "last_delivery_at": { "type": [ "string", "null" ], - "description": "Webhook: the full URL, `https://api.nan.builders/hooks/nan_whk_...`. `null` for a schedule." + "format": "date-time" }, - "signing_secret": { + "last_delivery_status": { "type": [ "string", "null" ], - "description": "Webhook with `github` or `nan`: the signing secret NaN generated (`nan_whsec_...`). `null` for a schedule, and for `stripe` (you sent it)." - } - } - }, - "TriggerRotated": { - "type": "object", - "additionalProperties": false, - "description": "What the rotation created, returned only this once: the new URL when `rotate` had `url`, the new signing secret when it had `secret` (except for `stripe`, whose secret you sent).", - "properties": { - "webhook_url": { + "enum": [ + "delivered", + "failed", + null + ] + }, + "subscriptions": { + "type": "array", + "maxItems": 50, + "items": { + "$ref": "#/components/schemas/NotificationSubscription" + } + }, + "signing": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "previous_expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + }, + "description": "On `https` channels only (`null` otherwise): when the previous signing secret stops signing after a rotation (`null` when there is none)." + }, + "updated_by": { "type": "string", - "description": "The new full webhook URL." + "enum": [ + "browser_session", + "cli_session", + "platform_token" + ], + "description": "The credential of the last change." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" }, "signing_secret": { "type": "string", - "description": "The new signing secret NaN generated (`nan_whsec_...`). Absent for `stripe`, whose new secret you sent." + "pattern": "^nan_nsec_", + "description": "`nan_nsec_` and 43 base64url characters. Only for `https` channels (absent otherwise). Never shown again." } } } diff --git a/src/lib/openapiAutomations.test.ts b/src/lib/openapiAutomations.test.ts index 2385c273..e93ef79e 100644 --- a/src/lib/openapiAutomations.test.ts +++ b/src/lib/openapiAutomations.test.ts @@ -50,7 +50,7 @@ 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 + 5)).toEqual(['Runs', 'Automations', 'Secrets', 'Approvals', 'Workspaces']); + expect(names.slice(at, at + 6)).toEqual(['Runs', 'Automations', 'Secrets', 'Notifications', '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'); @@ -252,10 +252,10 @@ describe('openapi.json: credentials for the platform API', () => { 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. + // devops edge Worker TOKEN_NOT_VALID_FOR_INFERENCE (origin/main 6e4f7588, cloudflare/api-edge-normaliser/src/index.js), 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).', + '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, /v1/automations, /v1/approvals, /v1/secrets, /v1/notification-channels and GET /api/workspaces).', type: 'invalid_request_error', param: null, code: 'token_not_valid_for_inference', @@ -280,7 +280,7 @@ describe('openapi.json: credentials for the platform API', () => { 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, and it changes automations or secrets only with `automations:write` or `secrets:write`/); + expect(d).toMatch(/can never approve a run, and it changes automations, secrets or notification channels only with `automations:write`, `secrets:write` or `notifications:write`/); }); }); diff --git a/src/lib/openapiByok.test.ts b/src/lib/openapiByok.test.ts index baaba506..f32485b6 100644 --- a/src/lib/openapiByok.test.ts +++ b/src/lib/openapiByok.test.ts @@ -96,7 +96,7 @@ describe('openapi.json: BYOK inference', () => { }); it('secrets are only under /v1/secrets (the write API), never a value', () => { - expect(Object.keys(s.paths).filter((p) => p.includes('secret'))).toEqual(['/secrets', '/secrets/{id}', '/secrets/{id}/value']); + expect(Object.keys(s.paths).filter((p) => p.startsWith('/secret'))).toEqual(['/secrets', '/secrets/{id}', '/secrets/{id}/value']); expect(Object.keys(s.components.schemas.Secret.properties)).not.toContain('value'); }); diff --git a/src/lib/openapiNotifications.test.ts b/src/lib/openapiNotifications.test.ts new file mode 100644 index 00000000..5c005928 --- /dev/null +++ b/src/lib/openapiNotifications.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'vitest'; +import spec from '../data/openapi.json'; +import { openapiToText } from './openapiToText'; +import { validate, type Schema } from '../tests/helpers/openapiValidate'; + +/** + * Notification channels in the API reference (devops + * AGENT-AUTOMATIONS-PHASE3-NOTIFICATIONS.md §2 and §4, cloud-api PR #349, + * 0.8.262). + * + * Shapes come from cloud-api origin/main 48ea25e: + * handlers/notification_channels.go (MountV1NotificationChannelsRoutes, + * channelView, deliveryView, the request types, failStatus, the write + * bucket), notifications/service.go (codes, messages, limits) and + * middleware/pat_scopes.go (the route -> scope table). + */ + +const s = spec as any; +const op = (path: string, method: string) => s.paths[path][method]; +const ref = (name: string): Schema => ({ $ref: `#/components/schemas/${name}` }); + +/** Every notification operation, the scope it needs and whether it spends the write bucket. */ +const OPS: Array<[string, string, string, string, 'read' | 'write' | 'test']> = [ + ['/notification-channels', 'get', 'listNotificationChannels', 'notifications:read', 'read'], + ['/notification-channels', 'post', 'createNotificationChannel', 'notifications:write', 'write'], + ['/notification-channels/{id}', 'get', 'getNotificationChannel', 'notifications:read', 'read'], + ['/notification-channels/{id}', 'patch', 'updateNotificationChannel', 'notifications:write', 'write'], + ['/notification-channels/{id}', 'delete', 'deleteNotificationChannel', 'notifications:write', 'write'], + ['/notification-channels/{id}/deliveries', 'get', 'listNotificationDeliveries', 'notifications:read', 'read'], + ['/notification-channels/{id}/subscriptions', 'put', 'replaceNotificationSubscriptions', 'notifications:write', 'write'], + ['/notification-channels/{id}/test', 'post', 'testNotificationChannel', 'notifications:write', 'test'], + ['/notification-channels/{id}/rotate-signing-secret', 'post', 'rotateNotificationSigningSecret', 'notifications:write', 'write'], +]; + +/** middleware/write_credential.go credentialNotAllowedMessage. */ +const credentialNotAllowed = (scope: string) => + `This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the "${scope}" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.`; + +/** channelView (handlers/notification_channels.go), in order. */ +const CHANNEL_FIELDS = [ + 'id', 'object', 'name', 'kind', 'url_hint', 'enabled', 'disabled_reason', 'consecutive_failures', 'last_delivery_at', + 'last_delivery_status', 'subscriptions', 'signing', 'updated_by', 'created_at', 'updated_at', +]; +/** deliveryView, in order. */ +const DELIVERY_FIELDS = ['id', 'object', 'event', 'items', 'run_id', 'automation_id', 'attempt', 'status', 'http_status', 'error', 'created_at']; +const EVENTS = ['approval.pending', 'run.finished', 'run.failed', 'automation.disabled', 'trigger.failing']; + +/** Every example of an operation's responses and request bodies, with its schema. */ +function examples(o: any): Array<[string, unknown, Schema]> { + const out: Array<[string, unknown, Schema]> = []; + const add = (where: string, content: any) => { + const c = content?.['application/json']; + if (!c) return; + if (c.example !== undefined) out.push([where, c.example, c.schema]); + for (const [k, v] of Object.entries(c.examples ?? {})) out.push([`${where}/${k}`, (v as any).value, c.schema]); + }; + for (const [status, r] of Object.entries(o.responses)) add(status, (r as any).content); + add('request', o.requestBody?.content); + return out; +} + +describe('openapi.json: the Notifications tag', () => { + it('sits right after Secrets and explains who may use it', () => { + const names = s.tags.map((t: any) => t.name); + expect(names[names.indexOf('Secrets') + 1]).toBe('Notifications'); + const tag = s.tags.find((t: any) => t.name === 'Notifications').description as string; + expect(tag).toContain('https://nan.builders/docs/notifications'); + for (const e of EVENTS) expect(tag).toContain(`\`${e}\``); + expect(tag).toContain('`notifications:read`'); + expect(tag).toContain('`notifications:write`'); + expect(tag).toContain('`404`'); + expect(tag).toMatch(/never returned/); + }); + + it('every /notification-channels path is in OPS, and nothing else is tagged Notifications', () => { + const tagged: string[] = []; + for (const [path, item] of Object.entries(s.paths)) { + for (const [method, o] of Object.entries(item as any)) if ((o as any).tags?.includes('Notifications')) tagged.push(`${method} ${path}`); + } + expect(tagged.sort()).toEqual(OPS.map(([p, m]) => `${m} ${p}`).sort()); + }); + + it('never documents the portal mount', () => { + expect(JSON.stringify(s)).not.toMatch(/api\/account\/notification-channels/); + }); +}); + +describe('openapi.json: the notification operations', () => { + it.each(OPS)('%s %s (%s) names the %s scope and refuses API keys', (path, method, id, scope) => { + const o = op(path, method); + expect(o.operationId).toBe(id); + expect(o.tags).toEqual(['Notifications']); + expect(o.security).toEqual([{ cliSession: [] }, { platformToken: [] }]); + expect(o.description).toContain( + `a CLI session (\`nan auth login\`) or a platform token (\`nan_pat_...\`) with the \`${scope}\` scope`, + ); + expect(o.description).toMatch(/API key \(`sk-\.\.\.`\)/); + }); + + it.each(OPS)('%s %s: 401, and 403 for a missing scope, an API key and a workspace key', (path, method, _id, 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.credential_not_allowed.value.error).toEqual({ + message: credentialNotAllowed(scope), + type: 'permission_error', + param: null, + code: 'credential_not_allowed', + }); + expect(ex.workspace_key_not_allowed.value.error).toEqual({ + message: 'workspace keys cannot manage runs', + type: 'invalid_request_error', + param: null, + code: 'workspace_key_not_allowed', + }); + }); + + it.each(OPS)('%s %s: 404 when the feature is off (writeRunsNotFound)', (path, method) => { + const r404 = op(path, method).responses['404'].content['application/json'].examples; + expect(r404.unavailable.value.error).toEqual({ message: 'Not found.', type: 'invalid_request_error', param: null, code: 'not_found' }); + if (path.includes('{id}')) { + expect(r404.not_found.value.error).toEqual({ message: 'notification channel not found', type: 'invalid_request_error', param: null, code: 'not_found' }); + } + }); + + it.each(OPS)('%s %s: 429 on writes (30 then 120/h) and tests (5 then 1/min), never on reads', (path, method, _id, _scope, kind) => { + const r = op(path, method).responses; + if (kind === 'read') { + expect(r['429']).toBeUndefined(); + return; + } + const e = r['429'].content['application/json'].example.error; + expect(e).toMatchObject({ type: 'rate_limit_exceeded', param: null, code: 'rate_limited' }); + if (kind === 'write') { + expect(r['429'].description).toMatch(/30 at once, then 120 per hour/); + expect(e.message).toBe('Too many notification channel changes recently. Try again later.'); + } else { + expect(r['429'].description).toMatch(/5 at once, then 1 a minute/); + expect(e.message).toBe('too many notification requests recently; try again later'); + } + }); + + it.each(OPS)('%s %s: every example validates against its schema', (path, method) => { + for (const [where, value, schema] of examples(op(path, method))) { + expect(validate(value, schema), `${method} ${path} ${where}`).toEqual([]); + } + }); + + it.each(OPS)('%s %s: every 200/201 says Cache-Control no-store', (path, method) => { + for (const [status, r] of Object.entries(op(path, method).responses)) { + if (!/^2/.test(status)) continue; + expect((r as any).headers?.['Cache-Control']?.schema?.const, status).toBe('no-store'); + } + }); +}); + +describe('openapi.json: the notification contract', () => { + const sc = s.components.schemas; + + it('the channel object: the fields channelView renders, in order, never a URL or a secret', () => { + expect(Object.keys(sc.NotificationChannel.properties)).toEqual(CHANNEL_FIELDS); + expect(sc.NotificationChannel.required).toEqual(CHANNEL_FIELDS); + expect(sc.NotificationChannel.properties.kind.enum).toEqual(['slack', 'teams', 'discord', 'https']); + expect(sc.NotificationChannel.properties.disabled_reason.enum).toEqual(['member', 'failing', 'invalid_url', null]); + expect(sc.NotificationChannel.properties.last_delivery_status.enum).toEqual(['delivered', 'failed', null]); + expect(sc.NotificationChannel.properties.updated_by.enum).toEqual(['browser_session', 'cli_session', 'platform_token']); + expect(sc.NotificationChannel.properties.subscriptions.maxItems).toBe(50); + expect(sc.NotificationSubscription.properties.event.enum).toEqual(EVENTS); + expect(JSON.stringify(sc.NotificationChannel)).not.toMatch(/"url":|signing_secret/); + }); + + it('create answers 201 with signing_secret once; rotate returns it with previous_expires_at', () => { + const created = op('/notification-channels', 'post').responses['201']; + expect(created.content['application/json'].schema).toEqual(ref('NotificationChannelCreated')); + expect(Object.keys(sc.NotificationChannelCreated.properties)).toEqual([...CHANNEL_FIELDS, 'signing_secret']); + expect(created.content['application/json'].example.signing_secret).toMatch(/^nan_nsec_/); + const rot = op('/notification-channels/{id}/rotate-signing-secret', 'post').responses; + expect(Object.keys(sc.NotificationSigningSecretRotated.properties)).toEqual(['signing_secret', 'previous_expires_at']); + expect(rot['409'].content['application/json'].example.error).toMatchObject({ code: 'signing_not_supported', message: 'only https channels have a signing secret' }); + expect(op('/notification-channels/{id}/rotate-signing-secret', 'post').description).toMatch(/24 hours/); + }); + + it('requests: write-only URL, strict fields, defaults, at least one PATCH field, up to 50 subscriptions', () => { + const c = sc.CreateNotificationChannelRequest; + expect(c.required).toEqual(['name', 'kind', 'url']); + expect(Object.keys(c.properties)).toEqual(['name', 'kind', 'url', 'events', 'automation_id']); + expect(c.properties.url.writeOnly).toBe(true); + expect(c.properties.url.maxLength).toBe(2048); + expect(c.properties.name.maxLength).toBe(64); + expect(c.properties.events.description).toContain('Default `approval.pending`, `run.failed`, `automation.disabled`, `trigger.failing`'); + expect(Object.keys(sc.UpdateNotificationChannelRequest.properties)).toEqual(['name', 'enabled', 'url']); + expect(sc.UpdateNotificationChannelRequest.minProperties).toBe(1); + expect(sc.ReplaceNotificationSubscriptionsRequest.required).toEqual(['subscriptions']); + expect(sc.ReplaceNotificationSubscriptionsRequest.properties.subscriptions.maxItems).toBe(50); + for (const name of ['CreateNotificationChannelRequest', 'UpdateNotificationChannelRequest', 'ReplaceNotificationSubscriptionsRequest']) { + expect(sc[name].additionalProperties, name).toBe(false); + } + expect(validate({ name: 'x', kind: 'slack', url: 'https://hooks.slack.com/services/x', extra: 1 }, ref('CreateNotificationChannelRequest')).length).toBeGreaterThan(0); + expect(validate({ name: 'x', kind: 'email', url: 'https://x.example' }, ref('CreateNotificationChannelRequest')).length).toBeGreaterThan(0); + }); + + it('errors: the service codes with their real messages, statuses and params', () => { + const post = op('/notification-channels', 'post').responses; + const ex400 = post['400'].content['application/json'].examples; + expect(ex400.invalid_url.value.error).toMatchObject({ code: 'invalid_url', param: 'url' }); + expect(ex400.invalid_url_address.value.error.message).toBe("the URL's host must resolve only to public addresses"); + expect(ex400.kind.value.error).toMatchObject({ code: 'invalid_request', param: 'kind', message: 'kind must be slack, teams, discord or https' }); + const ex409 = post['409'].content['application/json'].examples; + expect(ex409.name_taken.value.error).toMatchObject({ code: 'name_taken', param: 'name', message: 'a notification channel with this name already exists' }); + expect(ex409.too_many_channels.value.error.message).toBe('a member can have at most 10 notification channels'); + expect(post['404'].content['application/json'].examples.automation_not_found.value.error).toMatchObject({ code: 'automation_not_found', param: 'automation_id' }); + expect(post['503'].content['application/json'].example.error).toEqual({ + message: 'notifications are temporarily unavailable; try again', type: 'server_error', param: null, code: 'notifications_unavailable', + }); + const test409 = op('/notification-channels/{id}/test', 'post').responses['409'].content['application/json'].example.error; + expect(test409).toMatchObject({ code: 'channel_disabled', message: 'the channel is disabled; enable it first' }); + const lim = op('/notification-channels/{id}/deliveries', 'get').responses['400'].content['application/json'].examples.limit.value.error; + expect(lim).toMatchObject({ code: 'invalid_request', param: 'limit', message: 'limit must be 1 to 100' }); + expect(op('/notification-channels/{id}', 'delete').responses['204']).toBeDefined(); + }); + + it('test result and deliveries: the closed sets of §4.1 and §4.4', () => { + expect(Object.keys(sc.NotificationTestResult.properties)).toEqual(['delivered', 'status', 'http_status', 'error']); + expect(sc.NotificationTestResult.properties.status.enum).toEqual(['delivered', 'failed', 'blocked', 'rate_limited']); + expect(Object.keys(sc.NotificationDelivery.properties)).toEqual(DELIVERY_FIELDS); + expect(sc.NotificationDelivery.properties.status.enum).toEqual(['delivered', 'retrying', 'failed', 'blocked', 'rate_limited', 'dropped']); + expect(sc.NotificationDelivery.properties.event.enum).toEqual([...EVENTS, 'test', 'digest']); + expect(sc.NotificationDelivery.properties.error.enum).toEqual([ + 'timeout', 'connect', 'tls', 'dns', 'blocked_address', 'invalid_url', 'redirect', 'http_4xx', 'http_5xx', 'channel_disabled', 'rate_limited', null, + ]); + const limit = op('/notification-channels/{id}/deliveries', 'get').parameters.find((p: any) => p.name === 'limit'); + expect(limit.schema).toEqual({ type: 'integer', minimum: 1, maximum: 100, default: 50 }); + }); +}); + +describe('openapi.json: notification scopes in the overview and the platform token', () => { + const desc = s.info.description as string; + const pt = s.components.securitySchemes.platformToken.description as string; + + it('the overview lists both scopes, independent, browser-only to mint', () => { + expect(desc).toContain('`notifications:read` (read notification channels and their deliveries, never their URLs)'); + expect(desc).toContain('`notifications:write` does not include `notifications:read`'); + expect(desc).toContain('[Notifications](#tag/notifications)'); + expect(desc).toContain('an `automations:`, `secrets:` or `notifications:` scope'); + }); + + it('the platform token scheme names the route and the scopes', () => { + expect(pt).toContain('Notifications (`/v1/notification-channels...`)'); + expect(pt).toContain('`notifications:read` or `notifications:write`'); + }); + + it('the generated text reference carries the new routes', () => { + const text = openapiToText(s); + for (const [path, method] of OPS) expect(text, `${method} ${path}`).toContain(`### ${method.toUpperCase()} ${path}:`); + }); +}); diff --git a/src/lib/openapiRuns.test.ts b/src/lib/openapiRuns.test.ts index 0c9efe19..3acdc01f 100644 --- a/src/lib/openapiRuns.test.ts +++ b/src/lib/openapiRuns.test.ts @@ -64,7 +64,7 @@ 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 (/^\/(runs|automations|approvals|secrets|api)(\/|$)/.test(path)) continue; + if (/^\/(runs|automations|approvals|secrets|notification-channels|api)(\/|$)/.test(path)) continue; for (const method of ['get', 'post']) if (item[method]) expect(item[method].security, path).toBeUndefined(); } }); diff --git a/src/lib/openapiSpec.test.ts b/src/lib/openapiSpec.test.ts index 644d9edc..dbc69923 100644 --- a/src/lib/openapiSpec.test.ts +++ b/src/lib/openapiSpec.test.ts @@ -64,6 +64,15 @@ const PUBLIC_SURFACE: Array<[string, string]> = [ ['/secrets/{id}', 'patch'], ['/secrets/{id}', 'delete'], ['/secrets/{id}/value', 'put'], + ['/notification-channels', 'get'], + ['/notification-channels', 'post'], + ['/notification-channels/{id}', 'get'], + ['/notification-channels/{id}', 'patch'], + ['/notification-channels/{id}', 'delete'], + ['/notification-channels/{id}/deliveries', 'get'], + ['/notification-channels/{id}/subscriptions', 'put'], + ['/notification-channels/{id}/test', 'post'], + ['/notification-channels/{id}/rotate-signing-secret', 'post'], ['/approvals', 'get'], ['/runs/{id}/proposal.diff', 'get'], ['/runs/{id}/reject', 'post'], diff --git a/src/lib/openapiWriteApi.test.ts b/src/lib/openapiWriteApi.test.ts index 967515a5..f1ae43fb 100644 --- a/src/lib/openapiWriteApi.test.ts +++ b/src/lib/openapiWriteApi.test.ts @@ -53,10 +53,10 @@ const credentialNotAllowed = (scope: string) => `This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the "${scope}" scope. API keys (sk-), workspace keys and sessions from older clients are not accepted here: sign in again with the CLI or at cloud.nan.builders.`; describe('openapi.json: the write API operations', () => { - it('the Secrets tag sits between Automations and Approvals and explains who may use it', () => { + it('the Secrets tag sits between Automations and Notifications, then Approvals, and explains who may use it', () => { const names = s.tags.map((t: any) => t.name); const at = names.indexOf('Runs'); - expect(names.slice(at, at + 5)).toEqual(['Runs', 'Automations', 'Secrets', 'Approvals', 'Workspaces']); + expect(names.slice(at, at + 6)).toEqual(['Runs', 'Automations', 'Secrets', 'Notifications', 'Approvals', 'Workspaces']); const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description])); expect(tags.Secrets).toContain('https://nan.builders/docs/secrets'); expect(tags.Secrets).toMatch(/never returns a value/i); @@ -303,7 +303,7 @@ describe('openapi.json: credentials and scopes', () => { }); it('the overview says API keys never reach secrets or automation changes', () => { - expect(desc).toContain('never on Secrets or on changes to automations'); + expect(desc).toContain('never on Secrets, Notifications or changes to automations'); expect(desc).not.toContain('An API key works on both inference and these endpoints.'); }); diff --git a/src/tests/fixtures/notifications/discord_run_failed.json b/src/tests/fixtures/notifications/discord_run_failed.json new file mode 100644 index 00000000..71e677e2 --- /dev/null +++ b/src/tests/fixtures/notifications/discord_run_failed.json @@ -0,0 +1,32 @@ +{ + "allowed_mentions": { + "parse": [] + }, + "content": null, + "embeds": [ + { + "color": 15158332, + "description": "Reviewed PR \\#42\\: two issues found in pkg/foo.", + "fields": [ + { + "inline": true, + "name": "Event", + "value": "run.failed" + }, + { + "inline": true, + "name": "State", + "value": "failed" + }, + { + "inline": true, + "name": "Error", + "value": "agent_failed" + } + ], + "timestamp": "2026-10-14T10:00:00Z", + "title": "Run failed: PR review (agent_failed)", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ] +} diff --git a/src/tests/fixtures/notifications/https_approval_pending.json b/src/tests/fixtures/notifications/https_approval_pending.json new file mode 100644 index 00000000..aabff7ea --- /dev/null +++ b/src/tests/fixtures/notifications/https_approval_pending.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "approval.pending", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "awaiting_approval", + "error_code": null, + "phase": "propose", + "summary": null + }, + "text": "Approval needed: PR review", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_automation_disabled.json b/src/tests/fixtures/notifications/https_automation_disabled.json new file mode 100644 index 00000000..1440fd56 --- /dev/null +++ b/src/tests/fixtures/notifications/https_automation_disabled.json @@ -0,0 +1,15 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "automation.disabled", + "id": "44444444-4444-4444-8444-444444444444", + "reason": "workspace_deleted", + "run": null, + "text": "Automation disabled: PR review (its workspace was deleted)", + "trigger": null, + "url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_digest.json b/src/tests/fixtures/notifications/https_digest.json new file mode 100644 index 00000000..b6ea2bf8 --- /dev/null +++ b/src/tests/fixtures/notifications/https_digest.json @@ -0,0 +1,60 @@ +{ + "count": 3, + "created_at": "2026-10-14T10:00:00Z", + "event": "digest", + "events": { + "approval.pending": 1, + "run.failed": 2 + }, + "id": "44444444-4444-4444-8444-444444444444", + "items": [ + { + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "event": "run.failed", + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + }, + { + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "event": "run.failed", + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "timed_out", + "error_code": "timed_out", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + }, + { + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "event": "approval.pending", + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "awaiting_approval", + "error_code": null, + "phase": "propose", + "summary": null + }, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ], + "text": "3 notifications: 1 approval needed, 2 runs failed", + "url": "https://cloud.nan.builders/automations", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_run_failed.json b/src/tests/fixtures/notifications/https_run_failed.json new file mode 100644 index 00000000..b1f89e11 --- /dev/null +++ b/src/tests/fixtures/notifications/https_run_failed.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "run.failed", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "failed", + "error_code": "agent_failed", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "text": "Run failed: PR review (agent_failed)", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_run_finished.json b/src/tests/fixtures/notifications/https_run_finished.json new file mode 100644 index 00000000..9f23c6ee --- /dev/null +++ b/src/tests/fixtures/notifications/https_run_finished.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "run.finished", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "succeeded", + "error_code": null, + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "text": "Run succeeded: PR review", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_run_timed_out.json b/src/tests/fixtures/notifications/https_run_timed_out.json new file mode 100644 index 00000000..f27dcb62 --- /dev/null +++ b/src/tests/fixtures/notifications/https_run_timed_out.json @@ -0,0 +1,21 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "run.failed", + "id": "44444444-4444-4444-8444-444444444444", + "reason": null, + "run": { + "id": "22222222-2222-4222-8222-222222222222", + "state": "timed_out", + "error_code": "timed_out", + "phase": "propose", + "summary": "Reviewed PR #42: two issues found in pkg/foo." + }, + "text": "Run timed out: PR review", + "trigger": null, + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_test.json b/src/tests/fixtures/notifications/https_test.json new file mode 100644 index 00000000..4a993402 --- /dev/null +++ b/src/tests/fixtures/notifications/https_test.json @@ -0,0 +1,8 @@ +{ + "created_at": "2026-10-14T10:00:00Z", + "event": "test", + "id": "44444444-4444-4444-8444-444444444444", + "text": "Test notification from NaN", + "url": "https://cloud.nan.builders/settings#notifications", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/https_trigger_failing.json b/src/tests/fixtures/notifications/https_trigger_failing.json new file mode 100644 index 00000000..ac68f037 --- /dev/null +++ b/src/tests/fixtures/notifications/https_trigger_failing.json @@ -0,0 +1,18 @@ +{ + "automation": { + "id": "11111111-1111-4111-8111-111111111111", + "name": "PR review" + }, + "created_at": "2026-10-14T10:00:00Z", + "event": "trigger.failing", + "id": "44444444-4444-4444-8444-444444444444", + "reason": "bad_signature", + "run": null, + "text": "Webhook trigger failing: PR review (signature checks keep failing)", + "trigger": { + "id": "55555555-5555-4555-8555-555555555555", + "kind": "webhook" + }, + "url": "https://cloud.nan.builders/automations/11111111-1111-4111-8111-111111111111", + "version": 1 +} diff --git a/src/tests/fixtures/notifications/slack_run_failed.json b/src/tests/fixtures/notifications/slack_run_failed.json new file mode 100644 index 00000000..67adb7f5 --- /dev/null +++ b/src/tests/fixtures/notifications/slack_run_failed.json @@ -0,0 +1,37 @@ +{ + "blocks": [ + { + "text": { + "text": "*\u003chttps://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222|Run failed: PR review (agent_failed)\u003e*", + "type": "mrkdwn" + }, + "type": "section" + }, + { + "elements": [ + { + "text": "`run.failed`", + "type": "mrkdwn" + }, + { + "text": "state: failed", + "type": "mrkdwn" + }, + { + "text": "error: agent_failed", + "type": "mrkdwn" + } + ], + "type": "context" + }, + { + "text": { + "emoji": false, + "text": "Reviewed PR #42: two issues found in pkg/foo.", + "type": "plain_text" + }, + "type": "section" + } + ], + "text": "Run failed: PR review (agent_failed)" +} diff --git a/src/tests/fixtures/notifications/teams_run_failed.json b/src/tests/fixtures/notifications/teams_run_failed.json new file mode 100644 index 00000000..7c7ec606 --- /dev/null +++ b/src/tests/fixtures/notifications/teams_run_failed.json @@ -0,0 +1,60 @@ +{ + "attachments": [ + { + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "actions": [ + { + "title": "Open in NaN", + "type": "Action.OpenUrl", + "url": "https://cloud.nan.builders/workspaces/33333333-3333-4333-8333-333333333333/runs/22222222-2222-4222-8222-222222222222" + } + ], + "body": [ + { + "inlines": [ + { + "size": "Medium", + "text": "Run failed: PR review (agent_failed)", + "type": "TextRun", + "weight": "Bolder" + } + ], + "type": "RichTextBlock" + }, + { + "facts": [ + { + "title": "Event", + "value": "run.failed" + }, + { + "title": "State", + "value": "failed" + }, + { + "title": "Error", + "value": "agent_failed" + } + ], + "type": "FactSet" + }, + { + "inlines": [ + { + "text": "Reviewed PR #42: two issues found in pkg/foo.", + "type": "TextRun" + } + ], + "type": "RichTextBlock" + } + ], + "type": "AdaptiveCard", + "version": "1.4" + }, + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": null + } + ], + "type": "message" +} diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts index 5f199725..e7b2da1e 100644 --- a/src/tests/layouts/automationsDocs.test.ts +++ b/src/tests/layouts/automationsDocs.test.ts @@ -96,10 +96,10 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) expect(a.group).toBe(page.group); expect(h.group).toBe(page.group); expect(a.order).toBe(runs.order + 1); - // The PR gatekeeper guide and the Secrets page are nested under - // Automations (frontmatter `parent`), so they take the two slots right - // after it and Webhooks follows. - expect(h.order).toBe(a.order + 3); + // The PR gatekeeper guide, the Secrets page and the Notifications page + // are nested under Automations (frontmatter `parent`), so they take the + // three slots right after it and Webhooks follows. + expect(h.order).toBe(a.order + 4); 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); diff --git a/src/tests/layouts/docsNavNesting.render.test.ts b/src/tests/layouts/docsNavNesting.render.test.ts index 6bbd5434..015b146f 100644 --- a/src/tests/layouts/docsNavNesting.render.test.ts +++ b/src/tests/layouts/docsNavNesting.render.test.ts @@ -111,12 +111,33 @@ for (const lang of ['en', 'es'] as const) { expect(html).toMatch( new RegExp( ``, + `]*>\\s*]*>[^<]*\\s*`, ), ); expect(html.match(new RegExp(`href="${pfx}/docs/secrets"`, 'g'))).toHaveLength(1); }); + // Owner's ask (2026-10-14): Notifications is the third sub-item of + // Automations, right after Secrets, closing the nested list. + it('renders Notifications last in the same nested list, right after Secrets', async () => { + const html = await sidebar(lang, `${pfx}/docs`); + expect(html).toMatch( + new RegExp( + ``, + ), + ); + expect(html.match(new RegExp(`href="${pfx}/docs/notifications"`, 'g'))).toHaveLength(1); + }); + + it('marks Notifications as the current page and Automations as the trail when it is open', async () => { + const html = await sidebar(lang, `${pfx}/docs/notifications`); + expect(html).toMatch(new RegExp(` { const html = await sidebar(lang, `${pfx}/docs/secrets`); expect(html).toMatch(new RegExp(`(); /* @@ -46,11 +46,11 @@ const between = (html: string, start: RegExp, end: string) => { }; const LOCALES = [ - { lang: 'en', pfx: '', automations: 'Automations', webhooks: 'Webhooks and schedules', secrets: 'Secrets' }, - { lang: 'es', pfx: '/es', automations: 'Automatizaciones', webhooks: 'Webhooks y programaciones', secrets: 'Secretos' }, + { lang: 'en', pfx: '', automations: 'Automations', webhooks: 'Webhooks and schedules', secrets: 'Secrets', notifications: 'Notifications' }, + { lang: 'es', pfx: '/es', automations: 'Automatizaciones', webhooks: 'Webhooks y programaciones', secrets: 'Secretos', notifications: 'Notificaciones' }, ] as const; -for (const { lang, pfx, automations, webhooks, secrets } of LOCALES) { +for (const { lang, pfx, automations, webhooks, secrets, notifications } of LOCALES) { describe(`nested docs page (${lang})`, () => { it('breadcrumb: Docs / parent / current page', async () => { const body = await page(`${pfx}/docs/pr-gatekeeper`); @@ -71,10 +71,17 @@ for (const { lang, pfx, automations, webhooks, secrets } of LOCALES) { expect(pn).toMatch(new RegExp(`.*?${secrets}`)); }); - it('prev/next of the last child: its previous sibling, then the parent\'s next sibling', async () => { + it('prev/next of a middle child: its previous sibling, then its next sibling', async () => { const body = await page(`${pfx}/docs/secrets`); const pn = between(body, /