From 16b3264333cf9f956bf5c64b768af59a5bdb6b96 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 15:56:03 +0200 Subject: [PATCH 1/7] test(api-reference): pin the write API contract (secrets, automation writes, scopes) Co-Authored-By: Claude Opus 5.5 --- src/lib/openapiAutomations.test.ts | 29 +- src/lib/openapiByok.test.ts | 7 +- src/lib/openapiRuns.test.ts | 2 +- src/lib/openapiSpec.test.ts | 14 + src/lib/openapiWriteApi.test.ts | 359 ++++++++++++++++++ src/tests/fixtures/phase2/automation_get.json | 1 + .../phase2/automation_get_gatekeeper.json | 1 + .../fixtures/phase2/automation_triggers.json | 3 +- .../fixtures/phase2/automations_list.json | 1 + src/tests/fixtures/phase2/trigger_create.json | 3 +- src/tests/fixtures/phase2/trigger_rotate.json | 3 + 11 files changed, 401 insertions(+), 22 deletions(-) create mode 100644 src/lib/openapiWriteApi.test.ts create mode 100644 src/tests/fixtures/phase2/trigger_rotate.json diff --git a/src/lib/openapiAutomations.test.ts b/src/lib/openapiAutomations.test.ts index 19759b11..2385c273 100644 --- a/src/lib/openapiAutomations.test.ts +++ b/src/lib/openapiAutomations.test.ts @@ -50,16 +50,16 @@ describe('openapi.json: Automations and Approvals tags', () => { it('sit right after Runs, before Workspaces, and link their guides', () => { const names = s.tags.map((t: any) => t.name); const at = names.indexOf('Runs'); - expect(names.slice(at, at + 4)).toEqual(['Runs', 'Automations', 'Approvals', 'Workspaces']); + expect(names.slice(at, at + 5)).toEqual(['Runs', 'Automations', 'Secrets', 'Approvals', 'Workspaces']); const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description])); expect(tags.Automations).toContain('https://nan.builders/docs/automations'); expect(tags.Automations).toContain('https://nan.builders/docs/webhooks'); expect(tags.Approvals).toContain('https://nan.builders/docs/automations#approve-or-reject-a-proposal'); }); - it('say the writes are portal-only (browser_session_required)', () => { + it('say who may write (write API) and that approving stays in the portal', () => { const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description])); - expect(tags.Automations).toMatch(/Creating, editing and deleting automations and their triggers is done in the portal/); + expect(tags.Automations).toMatch(/Creating, changing and deleting automations and their triggers needs a CLI session \(`nan auth login`\) or a platform token with `automations:write`/); expect(tags.Automations).toContain('`403 browser_session_required`'); expect(tags.Approvals).toContain('`POST /v1/runs/{id}/approve`'); expect(tags.Approvals).toContain('`403 browser_session_required`'); @@ -88,14 +88,9 @@ describe('openapi.json: Automations and Approvals tags', () => { expect(ex.workspace_key_not_allowed.value.error.code).toBe('workspace_key_not_allowed'); }); - it('documents no write that only a browser session can make, and no secrets route', () => { - // POST/PATCH/DELETE /v1/automations*, trigger writes and approve are portal-only. - expect(s.paths['/automations'].post).toBeUndefined(); - expect(s.paths['/automations/{id}'].patch).toBeUndefined(); - expect(s.paths['/automations/{id}'].delete).toBeUndefined(); - expect(s.paths['/automations/{id}/triggers'].post).toBeUndefined(); + it('documents no approve route and no portal route (the writes are in openapiWriteApi.test.ts)', () => { + // Approving stays browser-only; the write API (Phase 3) documents the rest. expect(s.paths['/runs/{id}/approve']).toBeUndefined(); - expect(Object.keys(s.paths).filter((p) => /trigger.*\/|rotate/.test(p))).toEqual([]); expect(JSON.stringify(s.paths)).not.toContain('/api/account'); expect(JSON.stringify(s)).not.toMatch(/api\/account\/secrets/); }); @@ -112,6 +107,8 @@ describe('openapi.json: the Automations contract (cloud-api)', () => { 'created_at', 'updated_at', // Migration 110 (PR gatekeeper), same order as automationJSON. 'checkout', 'sticky_pr_comment', 'checkout_untrusted_heads', + // Phase 3 write API (migration 114). + 'updated_by', ]); expect(sc.Automation.properties.template.enum).toEqual(['custom', 'pr_review', 'issue_triage', 'ci_failure', 'generic_webhook']); expect(sc.Automation.properties.approval_kinds.items.enum).toEqual(ACTION_KINDS); @@ -146,7 +143,7 @@ describe('openapi.json: the Automations contract (cloud-api)', () => { expect(Object.keys(sc.Trigger.properties)).toEqual([ 'id', 'object', 'automation_id', 'kind', 'enabled', 'token_prefix', 'signature_scheme', 'event_filter', 'actor_filter', 'prev_secret_until', 'cron', 'timezone', 'next_fire_at', 'last_fired_at', 'last_outcome', - 'created_at', 'updated_at', + 'created_at', 'updated_at', 'updated_by', ]); expect(sc.Trigger.properties.signature_scheme.enum).toEqual(['github', 'stripe', 'nan', null]); // automations/api.go ScheduleOutcomes, in CHECK order. @@ -166,7 +163,7 @@ describe('openapi.json: the Automations contract (cloud-api)', () => { it('the trigger list never returns the webhook URL or the signing secret', () => { const props = Object.keys(sc.Trigger.properties); for (const secret of ['webhook_url', 'signing_secret', 'token', 'token_hash']) expect(props).not.toContain(secret); - expect(op('/automations/{id}/triggers', 'get').description).toMatch(/shown once, in the portal/); + expect(op('/automations/{id}/triggers', 'get').description).toMatch(/shown once, when they are created or rotated/); }); it('deliveries: limit 1 to 100, default 50, 30 days, and what is never stored', () => { @@ -266,7 +263,7 @@ describe('openapi.json: credentials for the platform API', () => { // cloud-api middleware/auth.go browserSessionRequiredMessage, OpenAI envelope under /v1. expect(byCode.browser_session_required).toEqual({ message: - 'This action needs a browser session: sign in at cloud.nan.builders in your browser and try again from there. CLI sessions, API keys and platform tokens cannot approve runs, change automations or secrets, or create tokens with automations scopes.', + '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.', type: 'permission_error', param: null, code: 'browser_session_required', @@ -276,14 +273,14 @@ describe('openapi.json: credentials for the platform API', () => { it('the errors table lists the new codes', () => { expect(desc).toMatch(/\| `401` \|[^\n]*`token_not_valid_for_inference` \|/); - expect(desc).toMatch(/\| `403` \|[^\n]*`token_scope_denied` · `browser_session_required` \|/); + expect(desc).toMatch(/\| `403` \|[^\n]*`token_scope_denied` · `credential_not_allowed` · `browser_session_required` \|/); }); it('the platform token scheme names its scopes and the inference refusal', () => { const d = s.components.securitySchemes.platformToken.description; expect(d).toContain('`401 token_not_valid_for_inference`'); expect(d).toContain('`403 token_scope_denied`'); - expect(d).toMatch(/can never approve a run or change automations or secrets/); + expect(d).toMatch(/can never approve a run, and it changes automations or secrets only with `automations:write` or `secrets:write`/); }); }); @@ -292,7 +289,7 @@ describe('cloud-api golden responses validate against the published schemas', () expect(readdirSync(fixtures).sort()).toEqual([ 'approvals_list.json', 'automation_deliveries.json', 'automation_get.json', 'automation_templates.json', 'automation_triggers.json', 'automations_list.json', 'run_get_adhoc.json', 'run_get_propose.json', - 'run_list_propose.json', 'trigger_create.json', + 'run_list_propose.json', 'trigger_create.json', 'trigger_rotate.json', // cloud-api PR #343: a fresh-checkout, sticky, supersede automation and a superseded run. 'automation_get_gatekeeper.json', 'run_get_gatekeeper.json', ].sort()); diff --git a/src/lib/openapiByok.test.ts b/src/lib/openapiByok.test.ts index 7ea33460..baaba506 100644 --- a/src/lib/openapiByok.test.ts +++ b/src/lib/openapiByok.test.ts @@ -56,7 +56,7 @@ describe('openapi.json: BYOK inference', () => { for (const v of bad) expect(validate(v, req).length, JSON.stringify(v)).toBeGreaterThan(0); const desc = s.components.schemas.CreateRunInference.description; expect(desc).toContain('`403 session_required`'); - expect(desc).toContain('portal-only'); + expect(desc).toContain('#tag/secrets'); expect(s.components.schemas.CreateRunRequest.properties.model.description).toContain('inference.model'); }); @@ -95,8 +95,9 @@ describe('openapi.json: BYOK inference', () => { expect(run.responses['403'].description).toMatch(/own key/); }); - it('secrets stay out of the public API (they are written in the portal only)', () => { - expect(Object.keys(s.paths).filter((p) => p.includes('secret'))).toEqual([]); + 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.components.schemas.Secret.properties)).not.toContain('value'); }); it('every request and response example of POST /runs matches its schema', () => { diff --git a/src/lib/openapiRuns.test.ts b/src/lib/openapiRuns.test.ts index 6e10055b..0c9efe19 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|api)(\/|$)/.test(path)) continue; + if (/^\/(runs|automations|approvals|secrets|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 577755d1..644d9edc 100644 --- a/src/lib/openapiSpec.test.ts +++ b/src/lib/openapiSpec.test.ts @@ -50,6 +50,20 @@ const PUBLIC_SURFACE: Array<[string, string]> = [ ['/automations/{id}/triggers', 'get'], ['/automations/{id}/deliveries', 'get'], ['/automations/{id}/runs', 'post'], + // The write API (cloud-api #348): secrets and automation writes. + ['/automations', 'post'], + ['/automations/{id}', 'patch'], + ['/automations/{id}', 'delete'], + ['/automations/{id}/triggers', 'post'], + ['/automations/{id}/triggers/{trigger_id}', 'patch'], + ['/automations/{id}/triggers/{trigger_id}', 'delete'], + ['/automations/{id}/triggers/{trigger_id}/rotate', 'post'], + ['/secrets', 'get'], + ['/secrets', 'post'], + ['/secrets/{id}', 'get'], + ['/secrets/{id}', 'patch'], + ['/secrets/{id}', 'delete'], + ['/secrets/{id}/value', 'put'], ['/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 new file mode 100644 index 00000000..a33c9d98 --- /dev/null +++ b/src/lib/openapiWriteApi.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import spec from '../data/openapi.json'; +import { resolveSpec } from './apiDoc'; +import { DEFAULT_RATE_LIMITS } from './rateLimits'; +import { openapiToText } from './openapiToText'; +import { validate, type Schema } from '../tests/helpers/openapiValidate'; + +/** + * The write API in the API reference: secrets and automations managed from + * the API and the CLI (devops AGENT-AUTOMATIONS-PHASE3-WRITE-API.md, cloud-api + * PR #348, 0.8.261). + * + * Shapes come from cloud-api origin/main 95414e4: handlers/account_secrets.go + * (MountV1SecretsRoutes, secretView, the request types, fail/failStatus), + * secrets/api.go (codes, limits), handlers/automations.go and + * automation_triggers.go (bodies, statuses, writer), middleware/ + * write_credential.go (credential_not_allowed), middleware/pat_scopes.go (the + * route -> scope table) and middleware/auth.go (browser_session_required). + * The fixtures in src/tests/fixtures/phase2 are cloud-api's golden files. + */ + +const s = spec as any; +const op = (path: string, method: string) => s.paths[path][method]; +const ref = (name: string): Schema => ({ $ref: `#/components/schemas/${name}` }); +const here = dirname(fileURLToPath(import.meta.url)); +const golden = (name: string) => + JSON.parse(readFileSync(resolve(here, '../tests/fixtures/phase2', `${name}.json`), 'utf-8')); + +/** Every write-API operation, its tag, the token scope it needs (pat_scopes.go) and whether it is a write (rate limited). */ +const OPS: Array<[string, string, string, string, string, boolean]> = [ + ['/secrets', 'get', 'listSecrets', 'Secrets', 'secrets:read', false], + ['/secrets', 'post', 'createSecret', 'Secrets', 'secrets:write', true], + ['/secrets/{id}', 'get', 'getSecret', 'Secrets', 'secrets:read', false], + ['/secrets/{id}', 'patch', 'updateSecret', 'Secrets', 'secrets:write', true], + ['/secrets/{id}', 'delete', 'deleteSecret', 'Secrets', 'secrets:write', true], + ['/secrets/{id}/value', 'put', 'replaceSecretValue', 'Secrets', 'secrets:write', true], + ['/automations', 'post', 'createAutomation', 'Automations', 'automations:write', true], + ['/automations/{id}', 'patch', 'updateAutomation', 'Automations', 'automations:write', true], + ['/automations/{id}', 'delete', 'deleteAutomation', 'Automations', 'automations:write', true], + ['/automations/{id}/triggers', 'post', 'createAutomationTrigger', 'Automations', 'automations:write', true], + ['/automations/{id}/triggers/{trigger_id}', 'patch', 'updateAutomationTrigger', 'Automations', 'automations:write', true], + ['/automations/{id}/triggers/{trigger_id}', 'delete', 'deleteAutomationTrigger', 'Automations', 'automations:write', true], + ['/automations/{id}/triggers/{trigger_id}/rotate', 'post', 'rotateAutomationTrigger', 'Automations', 'automations:write', true], +]; + +const UPDATED_BY = ['browser_session', 'cli_session', 'platform_token']; + +/** 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.`; + +describe('openapi.json: the write API operations', () => { + it('the Secrets tag sits between Automations and 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']); + const tags = Object.fromEntries(s.tags.map((t: any) => [t.name, t.description])); + expect(tags.Secrets).toContain('https://nan.builders/docs/automations#secrets'); + expect(tags.Secrets).toMatch(/never returns a value/i); + expect(tags.Secrets).toContain('`secrets:read`'); + expect(tags.Secrets).toContain('`secrets:write`'); + expect(tags.Secrets).toContain('`404`'); + expect(tags.Automations).toContain('`automations:write`'); + expect(tags.Automations).toMatch(/Approving stays in the portal/); + }); + + it.each(OPS)('%s %s (%s) is tagged %s and names the %s scope', (path, method, id, tag, scope) => { + const o = op(path, method); + expect(o.operationId).toBe(id); + expect(o.tags).toEqual([tag]); + expect(o.security).toEqual([{ 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, _tag, scope) => { + const r = op(path, method).responses; + expect(r['401']).toEqual({ $ref: '#/components/responses/RunsUnauthorized' }); + const ex = r['403'].content['application/json'].examples; + expect(ex.token_scope_denied.value.error).toEqual({ + message: `This platform token does not have the "${scope}" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.`, + type: 'permission_error', + param: null, + code: 'token_scope_denied', + }); + expect(ex.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.code).toBe('workspace_key_not_allowed'); + }); + + it.each(OPS)('%s %s: 429 rate_limited on every write, never on a read', (path, method, _id, _tag, _scope, write) => { + const r = op(path, method).responses; + if (!write) { + expect(r['429']).toBeUndefined(); + return; + } + const err = r['429'].content['application/json'].example.error; + expect(err).toMatchObject({ type: 'rate_limit_exceeded', param: null, code: 'rate_limited' }); + expect(r['429'].description).toMatch(path.startsWith('/secrets') ? /30 at once, then 120 per hour/ : /30 at once, then 300 per hour/); + }); + + it('approving is still not part of the API, nor the portal secrets route', () => { + expect(s.paths['/runs/{id}/approve']).toBeUndefined(); + expect(JSON.stringify(s)).not.toMatch(/api\/account\/secrets/); + }); +}); + +describe('openapi.json: the secrets contract', () => { + const sc = s.components.schemas; + + it('the Secret object: the fields secretView renders, in order, and never a value', () => { + expect(Object.keys(sc.Secret.properties)).toEqual([ + 'id', 'object', 'name', 'kind', 'provider', 'base_url', 'hint', 'value_bytes', 'description', 'used_by', + 'created_at', 'updated_at', 'last_used_at', 'updated_by', + ]); + expect(sc.Secret.additionalProperties).toBe(false); + expect(sc.Secret.properties.kind.enum).toEqual(['generic', 'provider_key']); + expect(sc.Secret.properties.provider.enum).toEqual(['openai_compatible', 'anthropic', 'openai', 'gemini', null]); + expect(sc.Secret.properties.updated_by.enum).toEqual(UPDATED_BY); + expect(sc.Secret.properties.name.pattern).toBe('^[A-Z][A-Z0-9_]{0,63}$'); + expect(Object.keys(sc.Secret.properties.used_by.items.properties)).toEqual(['id', 'name']); + for (const f of ['value', 'ciphertext', 'secret']) expect(Object.keys(sc.Secret.properties)).not.toContain(f); + }); + + it('the requests: create, PATCH and the new value, with known fields only', () => { + expect(sc.CreateSecretRequest.additionalProperties).toBe(false); + expect(Object.keys(sc.CreateSecretRequest.properties)).toEqual(['name', 'value', 'kind', 'provider', 'base_url', 'description']); + expect(sc.CreateSecretRequest.required).toEqual(['name', 'value']); + expect(sc.CreateSecretRequest.properties.value.description).toMatch(/1 to 16384 bytes/); + expect(sc.CreateSecretRequest.properties.value.description).toMatch(/not start or end with a line break/); + expect(sc.CreateSecretRequest.properties.value.description).toMatch(/8 to 4096/); + expect(sc.UpdateSecretRequest.additionalProperties).toBe(false); + expect(Object.keys(sc.UpdateSecretRequest.properties)).toEqual(['description', 'base_url']); + expect(sc.UpdateSecretRequest.minProperties).toBe(1); + expect(sc.ReplaceSecretValueRequest.additionalProperties).toBe(false); + expect(sc.ReplaceSecretValueRequest.required).toEqual(['value']); + const ok = { name: 'GITHUB_TOKEN', value: 'ghp_x' }; + expect(validate(ok, ref('CreateSecretRequest'))).toEqual([]); + expect(validate({ ...ok, secret_value: 'x' }, ref('CreateSecretRequest')).length).toBeGreaterThan(0); + expect(validate({ name: 'lower', value: 'v' }, ref('CreateSecretRequest')).length).toBeGreaterThan(0); + }); + + it('create: Idempotency-Key, 201, 200 on a replay, and every 409', () => { + const o = op('/secrets', 'post'); + const idem = o.parameters.find((p: any) => p.name === 'Idempotency-Key'); + expect(idem).toMatchObject({ in: 'header', required: false }); + expect(idem.schema.pattern).toBe('^[A-Za-z0-9._:-]{1,128}$'); + expect(idem.description).toMatch(/value of the retry is ignored/); + expect(Object.keys(o.responses).sort()).toEqual(['200', '201', '400', '401', '403', '404', '409', '429', '503']); + const conflicts = o.responses['409'].content['application/json'].examples; + expect(Object.keys(conflicts).sort()).toEqual(['idempotency_key_reused', 'name_taken', 'too_many_secrets']); + expect(conflicts.too_many_secrets.value.error.message).toBe('you have reached the maximum of 100 secrets'); + expect(JSON.stringify(o.responses['403'])).toContain('platform_access_required'); + expect(JSON.stringify(o.responses['503'])).toContain('secrets_unavailable'); + }); + + it('delete: 204, and 409 secret_in_use names the automations that use it', () => { + const o = op('/secrets/{id}', 'delete'); + expect(o.responses['204']).toBeDefined(); + const err = o.responses['409'].content['application/json'].example.error; + expect(err.code).toBe('secret_in_use'); + expect(Object.keys(err.automations[0])).toEqual(['id', 'name']); + expect(validate(o.responses['409'].content['application/json'].example, o.responses['409'].content['application/json'].schema)).toEqual([]); + }); + + it('replace the value: 409 secrets_too_large names the automations', () => { + const o = op('/secrets/{id}/value', 'put'); + expect(JSON.stringify(o.responses['409'])).toContain('secrets_too_large'); + expect(o.description).toMatch(/every run that starts from then on/); + }); + + it('every secret answer is no-store', () => { + for (const [path, method] of OPS.filter(([p]) => p.startsWith('/secrets'))) { + const r = op(path, method).responses; + const ok = r['200'] ?? r['201']; + if (ok) expect(ok.headers?.['Cache-Control']?.schema?.const, `${method} ${path}`).toBe('no-store'); + } + }); +}); + +describe('openapi.json: the automation writes', () => { + const sc = s.components.schemas; + + it('updated_by on the automation and trigger objects', () => { + expect(Object.keys(sc.Automation.properties).at(-1)).toBe('updated_by'); + expect(sc.Automation.properties.updated_by.enum).toEqual(UPDATED_BY); + expect(Object.keys(sc.Trigger.properties).at(-1)).toBe('updated_by'); + expect(sc.Trigger.properties.updated_by.enum).toEqual(UPDATED_BY); + }); + + it('create and PATCH bodies have the handler fields', () => { + expect(sc.CreateAutomationRequest.additionalProperties).toBe(false); + expect(Object.keys(sc.CreateAutomationRequest.properties)).toEqual([ + 'name', 'description', 'template', 'workspace', 'agent', 'model', 'instructions', 'cwd', 'git_isolation', + 'timeout_seconds', 'approval_kinds', 'approval_ttl_seconds', 'inference', 'github_repo', 'secrets', + 'max_runs_per_hour', 'concurrency_policy', 'max_trigger_depth', 'enabled', 'checkout', 'sticky_pr_comment', + 'checkout_untrusted_heads', + ]); + expect(sc.CreateAutomationRequest.required).toEqual(['name', 'workspace', 'agent']); + expect(sc.UpdateAutomationRequest.additionalProperties).toBe(false); + expect(Object.keys(sc.UpdateAutomationRequest.properties)).toEqual([ + 'name', 'description', 'workspace', 'agent', 'model', 'instructions', 'cwd', 'git_isolation', 'timeout_seconds', + 'approval_kinds', 'approval_ttl_seconds', 'inference', 'github_repo', 'secrets', 'max_runs_per_hour', + 'concurrency_policy', 'max_trigger_depth', 'enabled', 'checkout', 'sticky_pr_comment', 'checkout_untrusted_heads', + ]); + expect(sc.UpdateAutomationRequest.properties.secrets.description).toMatch(/Replaces every assignment/); + expect(sc.UpdateAutomationRequest.properties.approval_kinds.description).toMatch(/`\[\]`/); + }); + + it('trigger bodies: create, PATCH, rotate', () => { + expect(Object.keys(sc.CreateTriggerRequest.properties)).toEqual([ + 'kind', 'signature_scheme', 'signing_secret', 'event_filter', 'actor_filter', 'cron', 'timezone', 'enabled', + ]); + expect(sc.CreateTriggerRequest.required).toEqual(['kind']); + expect(Object.keys(sc.UpdateTriggerRequest.properties)).toEqual(['event_filter', 'actor_filter', 'cron', 'timezone', 'enabled']); + expect(Object.keys(sc.RotateTriggerRequest.properties)).toEqual(['rotate', 'grace', 'signing_secret']); + expect(sc.RotateTriggerRequest.properties.rotate.items.enum).toEqual(['url', 'secret']); + expect(sc.RotateTriggerRequest.properties.grace.default).toBe(true); + for (const n of ['CreateTriggerRequest', 'UpdateTriggerRequest', 'RotateTriggerRequest']) expect(sc[n].additionalProperties).toBe(false); + }); + + it('the webhook URL and signing secret come once, no-store', () => { + const create = op('/automations/{id}/triggers', 'post').responses['201']; + expect(create.content['application/json'].schema).toEqual(ref('TriggerCreated')); + expect(create.headers['Cache-Control'].schema.const).toBe('no-store'); + expect(create.description).toMatch(/only time/); + const rotate = op('/automations/{id}/triggers/{trigger_id}/rotate', 'post').responses['200']; + expect(rotate.content['application/json'].schema).toEqual(ref('TriggerRotated')); + expect(rotate.headers['Cache-Control'].schema.const).toBe('no-store'); + expect(Object.keys(sc.TriggerCreated.properties)).toEqual(['trigger', 'webhook_url', 'signing_secret']); + expect(Object.keys(sc.TriggerRotated.properties)).toEqual(['webhook_url', 'signing_secret']); + expect(op('/automations/{id}/triggers', 'post').description).toMatch(/not idempotent/); + expect(op('/automations/{id}/triggers', 'get').description).toMatch(/shown once/); + expect(op('/automations/{id}/triggers', 'get').description).not.toMatch(/in the portal, when/); + }); + + it('create automation: 201, 409 name_taken, and the defaults', () => { + const o = op('/automations', 'post'); + expect(Object.keys(o.responses).sort()).toEqual(['201', '400', '401', '403', '404', '409', '429']); + const text = JSON.stringify(o.responses['409']); + for (const code of ['name_taken', 'too_many_automations', 'agent_not_installed']) expect(text, code).toContain(code); + expect(o.description).toMatch(/approval_kinds.*every kind/); + }); + + it('delete: 204 for an automation and a trigger', () => { + expect(op('/automations/{id}', 'delete').responses['204']).toBeDefined(); + expect(op('/automations/{id}/triggers/{trigger_id}', 'delete').responses['204']).toBeDefined(); + }); +}); + +describe('openapi.json: credentials and scopes', () => { + const desc = s.info.description as string; + + it('the overview lists the new scopes and the CLI session', () => { + for (const scope of ['`automations:write`', '`secrets:read`', '`secrets:write`']) expect(desc).toContain(scope); + expect(desc).toContain('### CLI session'); + expect(desc).toMatch(/can change your secrets or automations/); + }); + + it('the CLI session scheme and the platform token scheme name the write scopes', () => { + const cli = s.components.securitySchemes.cliSession; + expect(cli).toMatchObject({ type: 'http', scheme: 'bearer' }); + expect(cli.description).toContain('nan auth login'); + expect(cli.description).toMatch(/inside a workspace/); + const pat = s.components.securitySchemes.platformToken.description; + for (const scope of ['`automations:write`', '`secrets:read`', '`secrets:write`']) expect(pat).toContain(scope); + expect(pat).toMatch(/can never approve a run/); + }); + + it('the overview carries the real credential_not_allowed body', () => { + 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])); + expect(byCode.credential_not_allowed).toEqual({ + message: credentialNotAllowed('secrets:write'), + type: 'permission_error', + param: null, + code: 'credential_not_allowed', + }); + }); +}); + +describe('cloud-api golden responses for the writes', () => { + it.each([ + ['trigger_create', golden('trigger_create'), ref('TriggerCreated')], + ['trigger_rotate', golden('trigger_rotate'), ref('TriggerRotated')], + ['automation_get', golden('automation_get'), ref('Automation')], + ['automation_triggers', golden('automation_triggers'), ref('TriggerList')], + ] as Array<[string, unknown, Schema]>)('%s', (_n, value, schema) => { + expect(validate(value, schema)).toEqual([]); + }); + + it('the goldens carry updated_by', () => { + expect(golden('automation_get').updated_by).toBe('browser_session'); + expect(golden('trigger_create').trigger.updated_by).toBe('browser_session'); + }); +}); + +describe('openapi.json: every write-API example matches its schema', () => { + const cases: Array<[string, unknown, Schema]> = []; + for (const [path, method] of OPS) { + const o = op(path, method); + const req = o.requestBody?.content?.['application/json']; + if (req?.example) cases.push([`${method} ${path} request`, req.example, req.schema]); + for (const [name, ex] of Object.entries(req?.examples ?? {}) as Array<[string, any]>) { + cases.push([`${method} ${path} request ${name}`, ex.value, req.schema]); + } + for (const [status, r] of Object.entries(o.responses) as Array<[string, any]>) { + const json = r.content?.['application/json']; + if (!json) continue; + if (json.example !== undefined) cases.push([`${method} ${path} ${status}`, json.example, json.schema]); + for (const [name, ex] of Object.entries(json.examples ?? {}) as Array<[string, any]>) { + cases.push([`${method} ${path} ${status} ${name}`, ex.value, json.schema]); + } + } + } + + it('finds the examples to check', () => { + expect(cases.length).toBeGreaterThan(60); + }); + + it.each(cases)('%s', (_name, example, schema) => { + expect(validate(example, schema)).toEqual([]); + }); +}); + +describe('the text the docs API serves for the reference', () => { + it('includes the write operations', () => { + const text = openapiToText(resolveSpec(DEFAULT_RATE_LIMITS)); + for (const heading of [ + '## Secrets', + '### POST /secrets: Create a secret', + '### PUT /secrets/{id}/value: Replace a secret value', + '### POST /automations: Create an automation', + '### POST /automations/{id}/triggers/{trigger_id}/rotate: Rotate a webhook URL or signing secret', + ]) { + expect(text, heading).toContain(heading); + } + }); + + it('no em-dashes in what the write API adds', () => { + const added = JSON.stringify([ + s.tags, + ...OPS.map(([p, m]) => op(p, m)), + s.components.securitySchemes, + s.info.description, + Object.entries(s.components.schemas).filter(([k]) => /Secret|Trigger|Automation/.test(k)), + ]); + expect(added).not.toContain('—'); + }); +}); diff --git a/src/tests/fixtures/phase2/automation_get.json b/src/tests/fixtures/phase2/automation_get.json index 0f8a9809..d4a3c4f2 100644 --- a/src/tests/fixtures/phase2/automation_get.json +++ b/src/tests/fixtures/phase2/automation_get.json @@ -35,5 +35,6 @@ "template": "pr_review", "timeout_seconds": 1800, "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session", "workspace": "11111111-2222-4333-8444-555555555555" } diff --git a/src/tests/fixtures/phase2/automation_get_gatekeeper.json b/src/tests/fixtures/phase2/automation_get_gatekeeper.json index ea3fe202..dca34755 100644 --- a/src/tests/fixtures/phase2/automation_get_gatekeeper.json +++ b/src/tests/fixtures/phase2/automation_get_gatekeeper.json @@ -35,5 +35,6 @@ "template": "pr_review", "timeout_seconds": 1800, "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session", "workspace": "11111111-2222-4333-8444-555555555555" } diff --git a/src/tests/fixtures/phase2/automation_triggers.json b/src/tests/fixtures/phase2/automation_triggers.json index b9ce112f..02668f7e 100644 --- a/src/tests/fixtures/phase2/automation_triggers.json +++ b/src/tests/fixtures/phase2/automation_triggers.json @@ -27,7 +27,8 @@ "signature_scheme": "github", "timezone": null, "token_prefix": "nan_whk_AbC1", - "updated_at": "2026-10-11T09:00:00Z" + "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session" } ], "object": "list" diff --git a/src/tests/fixtures/phase2/automations_list.json b/src/tests/fixtures/phase2/automations_list.json index ed4f7326..226709e7 100644 --- a/src/tests/fixtures/phase2/automations_list.json +++ b/src/tests/fixtures/phase2/automations_list.json @@ -37,6 +37,7 @@ "template": "pr_review", "timeout_seconds": 1800, "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session", "workspace": "11111111-2222-4333-8444-555555555555" } ], diff --git a/src/tests/fixtures/phase2/trigger_create.json b/src/tests/fixtures/phase2/trigger_create.json index 83a0a7bd..f0259fd4 100644 --- a/src/tests/fixtures/phase2/trigger_create.json +++ b/src/tests/fixtures/phase2/trigger_create.json @@ -27,7 +27,8 @@ "signature_scheme": "github", "timezone": null, "token_prefix": "nan_whk_AbC1", - "updated_at": "2026-10-11T09:00:00Z" + "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session" }, "webhook_url": "https://api.nan.builders/hooks/nan_whk_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } diff --git a/src/tests/fixtures/phase2/trigger_rotate.json b/src/tests/fixtures/phase2/trigger_rotate.json new file mode 100644 index 00000000..30599cb3 --- /dev/null +++ b/src/tests/fixtures/phase2/trigger_rotate.json @@ -0,0 +1,3 @@ +{ + "signing_secret": "new_secret_shown_once" +} From ac86f811630ac412bfed14d0f3ba44bb9fc971c6 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 15:56:03 +0200 Subject: [PATCH 2/7] docs(api-reference): /v1/secrets, automation and trigger writes, write scopes, CLI session Co-Authored-By: Claude Opus 5.5 --- src/data/openapi.json | 9885 ++++++++++++++++++++++++++++------------- 1 file changed, 6676 insertions(+), 3209 deletions(-) diff --git a/src/data/openapi.json b/src/data/openapi.json index d2e8807c..8406d02c 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) and [Approvals](#tag/approvals), and list your [Workspaces](#tag/workspaces), create a **platform token** (`nan_pat_...`) under \"Tokens\" in your settings, and send it the same way: `Authorization: Bearer $NAN_TOKEN`. An API key works on both inference and these endpoints. Each endpoint lists the credentials it accepts.\n\nA platform token carries **scopes**, and each endpoint names the one it needs: `runs` (runs and approvals), `workspaces:read`, `automations:read` (read automations, templates, triggers and deliveries) and `automations:run` (run an existing automation). New tokens get `runs` and `workspaces:read`. A token without the scope gets `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This platform token does not have the \\\"automations:run\\\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"token_scope_denied\"\n }\n}\n```\n\nA platform token never works for inference: the model endpoints answer `401`:\n\n```json\n{\n \"error\": {\n \"message\": \"Platform tokens (nan_pat_) cannot be used for inference. Use an inference API key (sk-) instead. Platform tokens only work on the platform API (/v1/runs and GET /api/workspaces).\",\n \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": \"token_not_valid_for_inference\"\n }\n}\n```\n\n### Browser sign-in\n\nSome actions need you signed in through your browser at [cloud.nan.builders](https://cloud.nan.builders/), and no API key, platform token or CLI session can do them: approving a run, creating, editing or deleting automations and their triggers, writing secrets, and creating a token with an `automations:` scope. Through the API they answer `403`:\n\n```json\n{\n \"error\": {\n \"message\": \"This action needs a browser session: sign in at cloud.nan.builders in your browser and try again from there. CLI sessions, API keys and platform tokens cannot approve runs, change automations or secrets, or create tokens with automations scopes.\",\n \"type\": \"permission_error\",\n \"param\": null,\n \"code\": \"browser_session_required\"\n }\n}\n```\n\n## Making requests\n\nThe API is OpenAI-compatible, so point an official OpenAI SDK at our base URL and change nothing else:\n\n```python\nfrom openai import OpenAI\n\nclient = OpenAI(\n api_key=\"$NAN_API_KEY\",\n base_url=\"https://api.nan.builders/v1\",\n)\n\nresp = client.chat.completions.create(\n model=\"deepseek-v4-flash\",\n messages=[{\"role\": \"user\", \"content\": \"Hello\"}],\n)\nprint(resp.choices[0].message.content)\n```\n\n## Streaming\n\nChat responses can stream token-by-token. Set `\"stream\": true` on `/chat/completions` and the response arrives as Server-Sent Events: each event is a `data:` line carrying a `chat.completion.chunk`, with the new text in `choices[0].delta.content`. A final `data: [DONE]` line ends the stream. `/chat/completions` streams incrementally on every chat model. `/responses` streams incrementally 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, or an action that needs a [browser sign-in](#description/browser-sign-in). | `tier_restricted` · `token_scope_denied` · `browser_session_required` |\n| `404` | The requested model doesn't exist. | `model_not_found` |\n| `429` | Rate limit hit (`rpm_limit`, `max_parallel_requests`), the rolling 4h token budget of `glm5.3`, or a quota exhausted. | `rate_limit_exceeded` · `insufficient_quota` · `quota_exceeded` |\n| `500` | Something went wrong on our side (includes upstream model errors). | (none) |\n| `524` | Timeout, typical with large audio files on `/audio/transcriptions`. | (none) |\n\nRetry `429` and `5xx` responses with exponential backoff. Don't retry `400`, `401`, `403`, or `404` blindly (a context overflow is not retried automatically either: shorten the input or pick a model with a larger window): they'll fail the same way every time until you change the request. `402` cannot be fixed by repetition either: it clears when that model's quota period resets.\n\n## Model catalog\n\nEvery endpoint takes a `model` id. Capabilities vary by model:\n\n| Model | Use for | Capabilities |\n| --- | --- | --- |\n| `deepseek-v4-flash` | Chat, vision, reasoning | Streaming, tool calling, reasoning, image input, 1M-token context. 3B tokens/month per member |\n| `mimo-v2.6-flash` | Chat, vision, audio | Streaming, tool calling, reasoning, image input, audio input, 1M-token context. 1.0B tokens/month per member |\n| `qwen3.8-flash` | Chat, vision, agents | Streaming, tool calling, reasoning (on by default), vision, 1M-token context. 500M tokens/month per member |\n| `glm5.3-flash` | Chat, vision, agents | Streaming, tool calling, reasoning, vision, 1M-token context. 2B tokens/month per member |\n| `qwen3.6` | Chat, agents | Streaming, tool calling, vision, reasoning (opt-out, returns `reasoning_content`) |\n| `gemma4` | Chat, vision, agents | Streaming, tool calling, vision, reasoning (opt-in) |\n| `glm5.3` | Coding, long-horizon agents | Streaming, tool calling, reasoning trace, image, audio and video input, 1M-token context. Premium tier only |\n| `qwen3-embedding` | Embeddings | 4096-dimension vectors |\n| `rerank` | RAG reranking | Qwen3-Reranker-8B, 100+ languages |\n| `kokoro` | Text-to-speech | Multiple voices and audio formats |\n| `whisper` | Speech-to-text | Transcription with word/segment timestamps |\n| `flux-2-klein` | Image generation | Text-to-image and image-to-image |\n| `qwen-image-2.1` | Image generation (text→image) | 512-1280 px, 1-4 per request, seed 0-2147483647. 100 images/month per member (shared pool with flux-2-klein) |\n\n`glm5.3` is served only to keys on the GLM 5.3 premium tier; every other model is available to any inference member. Call [List models](#tag/Models) for the exact set available to your key.\n\n## Versioning & compatibility\n\nThe API tracks the OpenAI API surface, so OpenAI SDKs and tools work against `https://api.nan.builders/v1` unchanged. This reference documents the stable public `/v1` endpoints, plus the two read-only [Workspaces](#tag/workspaces) endpoints under `/api/workspaces`, and we add capabilities without breaking existing fields.", + "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 both inference and these endpoints. Each endpoint lists the credentials it accepts.\n\nA platform token carries **scopes**, and each endpoint names the one it needs: `runs` (runs and approvals), `workspaces:read`, `automations:read` (read automations, templates, triggers and deliveries), `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.", "contact": { "name": "NaN", "url": "https://nan.builders" @@ -64,7 +64,11 @@ }, { "name": "Automations", - "description": "Read and run the automations you set up in the portal: saved agent tasks that webhooks, schedules or your own calls start. Reading needs a platform token with `automations:read`, running one `automations:run` (or your API key). Creating, editing and deleting automations and their triggers is done in the portal, signed in through your browser: those routes answer `403 browser_session_required` to API keys and platform tokens. Webhooks themselves arrive at `https://api.nan.builders/hooks/`, signed by the sender. Guides: [Automations](https://nan.builders/docs/automations), [Webhooks and schedules](https://nan.builders/docs/webhooks)." + "description": "Your automations: saved agent tasks that webhooks, schedules or your own calls start. Reading needs a platform token with `automations:read`, running one `automations:run` (or your API key). Creating, changing and deleting automations and their triggers needs a CLI session (`nan auth login`) or a platform token with `automations:write`; API keys (`sk-...`) cannot. Where the write API is not yet available for your account, those routes answer `403 browser_session_required` and you make the change in the portal. Approving stays in the portal. Webhooks themselves arrive at `https://api.nan.builders/hooks/`, signed by the sender. Guides: [Automations](https://nan.builders/docs/automations), [Webhooks and schedules](https://nan.builders/docs/webhooks)." + }, + { + "name": "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/automations#secrets)." }, { "name": "Approvals", @@ -1551,16 +1555,6 @@ } } }, - "500": { - "description": "Something failed on our side (`server_error`).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, "429": { "description": "Too many usage queries: 30 requests per minute per member (`rate_limit_exceeded`).", "headers": { @@ -1578,6 +1572,16 @@ } } } + }, + "500": { + "description": "Something failed on our side (`server_error`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } }, "x-codeSamples": [ @@ -1664,8 +1668,8 @@ } }, "responses": { - "201": { - "description": "The run was created (`state: queued`).", + "200": { + "description": "Idempotent replay: the run already created with this `Idempotency-Key` and body.", "content": { "application/json": { "schema": { @@ -1704,8 +1708,8 @@ } } }, - "200": { - "description": "Idempotent replay: the run already created with this `Idempotency-Key` and body.", + "201": { + "description": "The run was created (`state: queued`).", "content": { "application/json": { "schema": { @@ -2450,8 +2454,8 @@ } ], "responses": { - "202": { - "description": "Cancellation accepted (`cancel_requested: true`). Follow the run to see it end.", + "200": { + "description": "The run had already ended: nothing changed.", "content": { "application/json": { "schema": { @@ -2467,31 +2471,38 @@ "agent": "pi", "model": null, "trigger": "api", - "state": "running", + "state": "succeeded", "queue_position": null, "prompt_preview": "add tests for the date parser and make them pass", "cwd": "/home/nan/projects/api", "git_isolation": null, "timeout_seconds": 1800, - "cancel_requested": true, - "exit_code": null, + "cancel_requested": false, + "exit_code": 0, "error_code": null, - "result": null, + "result": { + "status": "succeeded", + "summary": "Added 6 tests for the date parser; all pass.", + "branch": "nan-run/6f1c2a9b", + "commit": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567", + "changed_files": 2, + "artifacts": [] + }, "usage": { - "tokens_in": null, - "tokens_out": null, - "source": null + "tokens_in": 15234, + "tokens_out": 1812, + "source": "litellm" }, - "last_seq": 0, + "last_seq": 42, "created_at": "2026-10-10T12:00:00Z", "started_at": "2026-10-10T12:00:02Z", - "finished_at": null + "finished_at": "2026-10-10T12:03:41Z" } } } }, - "200": { - "description": "The run had already ended: nothing changed.", + "202": { + "description": "Cancellation accepted (`cancel_requested: true`). Follow the run to see it end.", "content": { "application/json": { "schema": { @@ -2507,32 +2518,25 @@ "agent": "pi", "model": null, "trigger": "api", - "state": "succeeded", + "state": "running", "queue_position": null, "prompt_preview": "add tests for the date parser and make them pass", "cwd": "/home/nan/projects/api", "git_isolation": null, "timeout_seconds": 1800, - "cancel_requested": false, - "exit_code": 0, + "cancel_requested": true, + "exit_code": null, "error_code": null, - "result": { - "status": "succeeded", - "summary": "Added 6 tests for the date parser; all pass.", - "branch": "nan-run/6f1c2a9b", - "commit": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567", - "changed_files": 2, - "artifacts": [] - }, + "result": null, "usage": { - "tokens_in": 15234, - "tokens_out": 1812, - "source": "litellm" + "tokens_in": null, + "tokens_out": null, + "source": null }, - "last_seq": 42, + "last_seq": 0, "created_at": "2026-10-10T12:00:00Z", "started_at": "2026-10-10T12:00:02Z", - "finished_at": "2026-10-10T12:03:41Z" + "finished_at": null } } } @@ -2660,7 +2664,8 @@ "updated_at": "2026-10-11T09:00:00Z", "checkout": "workspace", "sticky_pr_comment": false, - "checkout_untrusted_heads": false + "checkout_untrusted_heads": false, + "updated_by": "browser_session" } ] } @@ -2712,94 +2717,135 @@ "source": "curl https://api.nan.builders/v1/automations \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" } ] - } - }, - "/automations/templates": { - "get": { - "operationId": "listAutomationTemplates", - "tags": [ - "Automations" - ], - "summary": "List templates", + }, + "post": { "security": [ { - "apiKey": [] + "cliSession": [] }, { "platformToken": [] } ], - "description": "The templates the portal offers when you create an automation: `pr_review`, `issue_triage`, `ci_failure` and `generic_webhook`, with their defaults.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", + "tags": [ + "Automations" + ], + "operationId": "createAutomation", + "summary": "Create an automation", + "description": "Creates an automation. Leave a field out to take its default: with a template, its instructions and concurrency; `approval_kinds` absent means every kind of action waits for your approval; `enabled` is `true`. The workspace must have the agent installed.\n\nThere is no idempotency key: names are unique, so a retry after a lost answer gets `409 name_taken`; list your automations to find it. Add its triggers with [Create a trigger](#tag/automations/POST/automations/{id}/triggers).\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAutomationRequest" + }, + "example": { + "name": "PR review api", + "template": "pr_review", + "workspace": "dev", + "agent": "pi", + "github_repo": "acme/api", + "secrets": [ + { + "secret": "GITHUB_READ_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "agent" + }, + { + "secret": "GITHUB_WRITE_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "actions" + } + ], + "approval_kinds": [ + "git_push", + "github_create_pr" + ] + } + } + } + }, "responses": { - "200": { - "description": "The templates.", + "201": { + "description": "Created.", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "object": { - "type": "string", - "enum": [ - "list" - ] - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AutomationTemplate" - } - } - } + "$ref": "#/components/schemas/Automation" }, "example": { - "object": "list", - "data": [ + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "object": "automation", + "name": "PR review api", + "description": "Reviews every pull request of acme/api.", + "template": "pr_review", + "workspace": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "agent": "pi", + "model": null, + "instructions": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "approval_ttl_seconds": 259200, + "inference": { + "source": "nan" + }, + "github_repo": "acme/api", + "secrets": [ { - "id": "pr_review", - "name": "Pull request review", - "description": "Reviews every new or updated pull request and proposes a review.", - "instructions": "Review the pull request described in the input for correctness, security and maintainability. ...", - "approval_kinds": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ], - "trigger_kind": "webhook", - "signature_scheme": "github", - "event_filter": { - "events": [ - "pull_request" - ], - "actions": [ - "opened", - "synchronize", - "reopened", - "ready_for_review" - ], - "exclude_drafts": true - }, - "secret_slots": [ - { - "env": "GITHUB_TOKEN", - "exposure": "agent", - "optional": true, - "help": "Read-only token the agent uses to read the repository, pull requests and CI logs. Use a fine-grained token limited to this repository." - }, - { - "env": "GITHUB_TOKEN", - "exposure": "actions", - "optional": false, - "help": "Write token used only by NaN's action executor (never given to the agent) to push, comment, review, label and open pull requests." - } - ], - "requires_github_repo": true, - "suggested_concurrency": "skip_if_active" + "secret": "GITHUB_READ_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "agent" + }, + { + "secret": "GITHUB_WRITE_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "actions" } - ] + ], + "max_runs_per_hour": 30, + "concurrency_policy": "skip_if_active", + "max_trigger_depth": 2, + "enabled": true, + "disabled_reason": null, + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-11T09:00:00Z", + "checkout": "workspace", + "sticky_pr_comment": false, + "checkout_untrusted_heads": false, + "updated_by": "cli_session" + } + } + } + }, + "400": { + "description": "A field that does not pass validation (`param` names it) or an unknown field.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "github_repo": { + "summary": "A GitHub template without a repository.", + "value": { + "error": { + "message": "this template acts on a GitHub repository: set owner/name", + "type": "invalid_request_error", + "param": "github_repo", + "code": "invalid_request" + } + } + } } } } @@ -2808,7 +2854,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "description": "A platform token without the `automations: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": { @@ -2816,24 +2862,246 @@ }, "examples": { "token_scope_denied": { - "summary": "A platform token without the `automations:read` scope.", + "summary": "A platform token without the `automations:write` scope.", "value": { "error": { - "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "message": "This platform token does not have the \"automations: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" } } }, - "workspace_key_not_allowed": { - "summary": "The key that lives inside a workspace.", + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", "value": { "error": { - "message": "workspace keys cannot manage runs", - "type": "invalid_request_error", + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"automations: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": "workspace_key_not_allowed" + "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": "The workspace, a secret or the provider key is not yours (`not_found`), or automations are not available for your account.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "workspace not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The name is taken (`name_taken`), you have 50 automations (`too_many_automations`), the agent is not installed in the workspace (`agent_not_installed`), or the secrets of a run would exceed 48 KiB (`secrets_too_large`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "name_taken": { + "summary": "An automation with this name exists.", + "value": { + "error": { + "message": "an automation with this name already exists", + "type": "invalid_request_error", + "param": null, + "code": "name_taken" + } + } + }, + "too_many_automations": { + "summary": "50 automations already.", + "value": { + "error": { + "message": "you have reached the maximum of 50 automations", + "type": "invalid_request_error", + "param": null, + "code": "too_many_automations" + } + } + }, + "agent_not_installed": { + "summary": "The agent is not in the workspace.", + "value": { + "error": { + "message": "Hermes is not installed in this workspace", + "type": "invalid_request_error", + "param": "agent", + "code": "agent_not_installed" + } + } + } + } + } + } + }, + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + } + }, + "/automations/templates": { + "get": { + "operationId": "listAutomationTemplates", + "tags": [ + "Automations" + ], + "summary": "List templates", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "The templates the portal offers when you create an automation: `pr_review`, `issue_triage`, `ci_failure` and `generic_webhook`, with their defaults.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", + "responses": { + "200": { + "description": "The templates.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AutomationTemplate" + } + } + } + }, + "example": { + "object": "list", + "data": [ + { + "id": "pr_review", + "name": "Pull request review", + "description": "Reviews every new or updated pull request and proposes a review.", + "instructions": "Review the pull request described in the input for correctness, security and maintainability. ...", + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "trigger_kind": "webhook", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + }, + "secret_slots": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent", + "optional": true, + "help": "Read-only token the agent uses to read the repository, pull requests and CI logs. Use a fine-grained token limited to this repository." + }, + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "optional": false, + "help": "Write token used only by NaN's action executor (never given to the agent) to push, comment, review, label and open pull requests." + } + ], + "requires_github_repo": true, + "suggested_concurrency": "skip_if_active" + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `automations:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" } } } @@ -2934,7 +3202,8 @@ "updated_at": "2026-10-11T09:00:00Z", "checkout": "workspace", "sticky_pr_comment": false, - "checkout_untrusted_heads": false + "checkout_untrusted_heads": false, + "updated_by": "browser_session" } } } @@ -3002,24 +3271,22 @@ "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" } ] - } - }, - "/automations/{id}/triggers": { - "get": { - "operationId": "listAutomationTriggers", - "tags": [ - "Automations" - ], - "summary": "List triggers", + }, + "patch": { "security": [ { - "apiKey": [] + "cliSession": [] }, { "platformToken": [] } ], - "description": "The webhooks and schedules of an automation. A webhook shows only the start of its URL: the full URL and the signing secret are shown once, in the portal, when they are created or rotated.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", + "tags": [ + "Automations" + ], + "operationId": "updateAutomation", + "summary": "Change an automation", + "description": "Changes the fields you send, including `enabled` to switch it off or on. A run keeps the settings it started with: a change never affects a run already queued or working.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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", @@ -3032,75 +3299,113 @@ "description": "The automation id. A malformed id answers `404`." } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAutomationRequest" + }, + "examples": { + "disable": { + "summary": "Switch it off.", + "value": { + "enabled": false + } + }, + "approvals": { + "summary": "Let comments apply without asking.", + "value": { + "approval_kinds": [ + "git_push", + "github_create_pr" + ] + } + } + } + } + } + }, "responses": { "200": { - "description": "The triggers.", + "description": "The automation, changed.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TriggerList" + "$ref": "#/components/schemas/Automation" }, "example": { - "object": "list", - "data": [ - { - "id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", - "object": "trigger", - "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", - "kind": "webhook", - "enabled": true, - "token_prefix": "nan_whk_Q3vX", - "signature_scheme": "github", - "event_filter": { - "events": [ - "pull_request" - ], - "actions": [ - "opened", - "synchronize", - "reopened", - "ready_for_review" - ], - "exclude_drafts": true - }, - "actor_filter": { - "logins": [], - "author_associations": [ - "OWNER", - "MEMBER", - "COLLABORATOR" - ], - "exclude_bots": true - }, - "prev_secret_until": null, - "cron": null, - "timezone": null, - "next_fire_at": null, - "last_fired_at": null, - "last_outcome": null, - "created_at": "2026-10-11T09:01:00Z", - "updated_at": "2026-10-11T09:01:00Z" + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "object": "automation", + "name": "PR review api", + "description": "Reviews every pull request of acme/api.", + "template": "pr_review", + "workspace": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "agent": "pi", + "model": null, + "instructions": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "approval_ttl_seconds": 259200, + "inference": { + "source": "nan" + }, + "github_repo": "acme/api", + "secrets": [ + { + "secret": "GITHUB_READ_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "agent" }, { - "id": "4c6e8a0b-2d4f-4a6c-9e0b-1d3f5a7c9e2b", - "object": "trigger", - "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", - "kind": "schedule", - "enabled": true, - "token_prefix": null, - "signature_scheme": null, - "event_filter": null, - "actor_filter": null, - "prev_secret_until": null, - "cron": "0 9 * * 1-5", - "timezone": "Europe/Madrid", - "next_fire_at": "2026-10-12T07:00:21Z", - "last_fired_at": "2026-10-11T07:00:21Z", - "last_outcome": "run_created", - "created_at": "2026-10-11T09:02:00Z", - "updated_at": "2026-10-11T09:02:00Z" + "secret": "GITHUB_WRITE_TOKEN", + "env": "GITHUB_TOKEN", + "exposure": "actions" } - ] + ], + "max_runs_per_hour": 30, + "concurrency_policy": "skip_if_active", + "max_trigger_depth": 2, + "enabled": true, + "disabled_reason": null, + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-11T09:00:00Z", + "checkout": "workspace", + "sticky_pr_comment": false, + "checkout_untrusted_heads": false, + "updated_by": "cli_session" + } + } + } + }, + "400": { + "description": "A field that does not pass validation (`param` names it), an unknown field, or `inference: null`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "timeout": { + "summary": "A timeout out of range.", + "value": { + "error": { + "message": "must be between 60 and 7200", + "type": "invalid_request_error", + "param": "timeout_seconds", + "code": "invalid_request" + } + } + } } } } @@ -3109,7 +3414,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "description": "A platform token without the `automations: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": { @@ -3117,16 +3422,27 @@ }, "examples": { "token_scope_denied": { - "summary": "A platform token without the `automations:read` scope.", + "summary": "A platform token without the `automations:write` scope.", "value": { "error": { - "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "message": "This platform token does not have the \"automations: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 \"automations: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": { @@ -3159,81 +3475,27 @@ } } } - } - }, - "x-codeSamples": [ - { - "lang": "curl", - "label": "cURL", - "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/triggers \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" - } - ] - } - }, - "/automations/{id}/deliveries": { - "get": { - "operationId": "listAutomationDeliveries", - "tags": [ - "Automations" - ], - "summary": "List webhook deliveries", - "security": [ - { - "apiKey": [] - }, - { - "platformToken": [] - } - ], - "description": "The webhook deliveries of an automation in the last 30 days, newest first, and what each one did. Only final outcomes are kept: deliveries refused for their signature are never stored, and throttled ones (`429`, `503`) are not listed because the sender retries them.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - }, - "description": "The automation id. A malformed id answers `404`." }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "description": "Deliveries to return, 1 to 100." - } - ], - "responses": { - "200": { - "description": "The deliveries.", + "409": { + "description": "The name is taken (`name_taken`), the agent is not installed in the workspace (`agent_not_installed`), or the secrets of a run would exceed 48 KiB (`secrets_too_large`).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebhookDeliveryList" + "$ref": "#/components/schemas/Error" }, "example": { - "object": "list", - "data": [ - { - "trigger_id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", - "delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958", - "received_at": "2026-10-11T09:00:00Z", - "outcome": "run_created", - "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c" - } - ] + "error": { + "message": "an automation with this name already exists", + "type": "invalid_request_error", + "param": null, + "code": "name_taken" + } } } } }, - "400": { - "description": "`limit` out of range.", + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", "content": { "application/json": { "schema": { @@ -3241,20 +3503,53 @@ }, "example": { "error": { - "message": "limit must be between 1 and 100", - "type": "invalid_request_error", - "param": "limit", - "code": "invalid_request" + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" } } } } + } + } + }, + "delete": { + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "tags": [ + "Automations" + ], + "operationId": "deleteAutomation", + "summary": "Delete an automation", + "description": "Deletes the automation: its triggers stop at once and its queued runs are cancelled. Runs already working finish, actions already approved or applying automatically still complete, past runs stay in the history, and a proposal waiting for your approval can still be approved or rejected.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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 automation id. A malformed id answers `404`." + } + ], + "responses": { + "204": { + "description": "Deleted." }, "401": { "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "description": "A platform token without the `automations: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": { @@ -3262,16 +3557,27 @@ }, "examples": { "token_scope_denied": { - "summary": "A platform token without the `automations:read` scope.", + "summary": "A platform token without the `automations:write` scope.", "value": { "error": { - "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "message": "This platform token does not have the \"automations: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 \"automations: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": { @@ -3304,24 +3610,35 @@ } } } + }, + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } } - }, - "x-codeSamples": [ - { - "lang": "curl", - "label": "cURL", - "source": "curl \"https://api.nan.builders/v1/automations/$AUTOMATION_ID/deliveries?limit=20\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" - } - ] + } } }, - "/automations/{id}/runs": { - "post": { - "operationId": "runAutomation", - "tags": [ + "/automations/{id}/triggers": { + "get": { + "operationId": "listAutomationTriggers", + "tags": [ "Automations" ], - "summary": "Run an automation", + "summary": "List triggers", "security": [ { "apiKey": [] @@ -3330,7 +3647,7 @@ "platformToken": [] } ], - "description": "Starts a run of the automation now, with its saved instructions, secrets and approval settings: the same run a webhook or a schedule would start. Its agent proposes actions, which apply automatically or wait for your approval as the automation says. Follow it like any run (`GET /v1/runs/{id}/events`). Runs you start this way are never skipped by `skip_if_active` or cancelled by `supersede`, but they count towards the automation's `max_runs_per_hour` and the limits of every run.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:run` scope. Runs on NaN inference need a plan with inference; an automation that uses your own provider key needs an active workspace of yours instead. Any of these credentials may run an automation that uses your key: its instructions are fixed in the portal, and what you send as `input` reaches an agent that holds your key, so treat it as untrusted. Guide: [Use your own provider key](https://nan.builders/docs/runs#use-your-own-provider-key-byok).", + "description": "The webhooks and schedules of an automation. A webhook shows only the start of its URL: the full URL and the signing secret are shown once, when they are created or rotated (in the portal, or in the answer of the API and the CLI).\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", "parameters": [ { "name": "id", @@ -3341,185 +3658,79 @@ "format": "uuid" }, "description": "The automation id. A malformed id answers `404`." - }, - { - "name": "Idempotency-Key", - "in": "header", - "required": false, - "schema": { - "type": "string", - "pattern": "^[A-Za-z0-9._:-]{1,128}$" - }, - "description": "Optional. A retry with the same key and the same input returns the run already created (`200`) instead of a second one. Keys starting with `sch:` or `whk:` are reserved (`400`)." } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RunAutomationRequest" - }, - "example": { - "input": { - "pr": 42, - "note": "only the auth changes" - } - } - } - } - }, "responses": { - "201": { - "description": "The run was created.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Run" - }, - "example": { - "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", - "object": "run", - "workspace": { - "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", - "name": "develop" - }, - "agent": "pi", - "model": null, - "trigger": "api", - "state": "queued", - "queue_position": 1, - "prompt_preview": "Review the pull request described in the input for correctness, security and maintainability.", - "cwd": "/home/nan/projects/api", - "git_isolation": true, - "timeout_seconds": 1800, - "cancel_requested": false, - "exit_code": null, - "error_code": null, - "result": null, - "usage": { - "tokens_in": null, - "tokens_out": null, - "source": null - }, - "last_seq": 0, - "created_at": "2026-10-11T09:00:00Z", - "started_at": null, - "finished_at": null, - "phase": "propose", - "inference": { - "source": "nan" - }, - "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", - "parent_run_id": null, - "apply_run_ids": [], - "approval": null, - "approval_kinds": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ], - "github_repo": "acme/api", - "trigger_depth": 0, - "secrets": [ - { - "env": "GITHUB_TOKEN", - "exposure": "agent" - } - ], - "agent_finished_at": null, - "checkout": null, - "superseded_by": null, - "sticky_pr_comment": false - } - } - } - }, "200": { - "description": "A retry with the same `Idempotency-Key`: the run created the first time.", + "description": "The triggers.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Run" + "$ref": "#/components/schemas/TriggerList" }, "example": { - "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", - "object": "run", - "workspace": { - "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", - "name": "develop" - }, - "agent": "pi", - "model": null, - "trigger": "api", - "state": "running", - "queue_position": null, - "prompt_preview": "Review the pull request described in the input for correctness, security and maintainability.", - "cwd": "/home/nan/projects/api", - "git_isolation": true, - "timeout_seconds": 1800, - "cancel_requested": false, - "exit_code": null, - "error_code": null, - "result": null, - "usage": { - "tokens_in": null, - "tokens_out": null, - "source": null - }, - "last_seq": 0, - "created_at": "2026-10-11T09:00:00Z", - "started_at": "2026-10-11T09:00:02Z", - "finished_at": null, - "phase": "propose", - "inference": { - "source": "nan" - }, - "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", - "parent_run_id": null, - "apply_run_ids": [], - "approval": null, - "approval_kinds": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ], - "github_repo": "acme/api", - "trigger_depth": 0, - "secrets": [ + "object": "list", + "data": [ { - "env": "GITHUB_TOKEN", - "exposure": "agent" + "id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", + "object": "trigger", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "kind": "webhook", + "enabled": true, + "token_prefix": "nan_whk_Q3vX", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + }, + "actor_filter": { + "logins": [], + "author_associations": [ + "OWNER", + "MEMBER", + "COLLABORATOR" + ], + "exclude_bots": true + }, + "prev_secret_until": null, + "cron": null, + "timezone": null, + "next_fire_at": null, + "last_fired_at": null, + "last_outcome": null, + "created_at": "2026-10-11T09:01:00Z", + "updated_at": "2026-10-11T09:01:00Z", + "updated_by": "browser_session" + }, + { + "id": "4c6e8a0b-2d4f-4a6c-9e0b-1d3f5a7c9e2b", + "object": "trigger", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "kind": "schedule", + "enabled": true, + "token_prefix": null, + "signature_scheme": null, + "event_filter": null, + "actor_filter": null, + "prev_secret_until": null, + "cron": "0 9 * * 1-5", + "timezone": "Europe/Madrid", + "next_fire_at": "2026-10-12T07:00:21Z", + "last_fired_at": "2026-10-11T07:00:21Z", + "last_outcome": "run_created", + "created_at": "2026-10-11T09:02:00Z", + "updated_at": "2026-10-11T09:02:00Z", + "updated_by": "browser_session" } - ], - "agent_finished_at": null, - "checkout": null, - "superseded_by": null, - "sticky_pr_comment": false - } - } - } - }, - "400": { - "description": "Invalid body: `input` not a JSON object, too large (48 KiB) or nested too deep (8 levels); unknown fields; a bad `Idempotency-Key`. On an automation that uses your own key, `invalid_request` with `param: \"inference\"` when your own key is not available for your account.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "example": { - "error": { - "message": "must be a JSON object of at most 48 KiB nested at most 8 levels deep", - "type": "invalid_request_error", - "param": "input", - "code": "invalid_request" - } + ] } } } @@ -3528,7 +3739,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations:run` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations. A membership without runs (no plan with inference, or, for an automation on your own key, no active workspace): `tier_restricted`.", + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", "content": { "application/json": { "schema": { @@ -3536,10 +3747,10 @@ }, "examples": { "token_scope_denied": { - "summary": "A platform token without the `automations:run` scope.", + "summary": "A platform token without the `automations:read` scope.", "value": { "error": { - "message": "This platform token does not have the \"automations:run\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", "type": "permission_error", "param": null, "code": "token_scope_denied" @@ -3556,17 +3767,6 @@ "code": "workspace_key_not_allowed" } } - }, - "tier_restricted": { - "summary": "A membership without inference.", - "value": { - "error": { - "message": "your membership does not include agent runs", - "type": "permission_error", - "param": null, - "code": "tier_restricted" - } - } } } } @@ -3589,45 +3789,209 @@ } } } - }, - "409": { - "description": "The run cannot start: the automation is disabled (`automation_disabled`), its workspace is not running (`workspace_not_running`), the automation's agent is not installed in its workspace (`agent_not_installed`, `param: \"agent\"`), the workspace has no inference key (`no_inference_key`), or the `Idempotency-Key` was used with a different input (`idempotency_conflict`).", - "content": { - "application/json": { - "schema": { + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/triggers \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + }, + "post": { + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "tags": [ + "Automations" + ], + "operationId": "createAutomationTrigger", + "summary": "Create a trigger", + "description": "Adds a webhook or a schedule. For a webhook, the answer carries its full URL and, for `github` and `nan`, its signing secret: **the only time they are returned**, with `Cache-Control: no-store`. Store them before anything else; afterwards only the start of the URL is shown. If you lose them, rotate.\n\nCreating a trigger is not idempotent (the URL is stored only as a hash): after a lost answer, list the triggers and rotate or delete the one you did not see.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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 automation id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTriggerRequest" + }, + "examples": { + "github": { + "summary": "A GitHub webhook for pull requests.", + "value": { + "kind": "webhook", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + } + } + }, + "schedule": { + "summary": "Every weekday at 9:00 in Madrid.", + "value": { + "kind": "schedule", + "cron": "0 9 * * 1-5", + "timezone": "Europe/Madrid" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created. The only time the webhook URL and signing secret are returned.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerCreated" + }, + "example": { + "trigger": { + "id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", + "object": "trigger", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "kind": "webhook", + "enabled": true, + "token_prefix": "nan_whk_Q3vX", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + }, + "actor_filter": { + "logins": [], + "author_associations": [ + "OWNER", + "MEMBER", + "COLLABORATOR" + ], + "exclude_bots": true + }, + "prev_secret_until": null, + "cron": null, + "timezone": null, + "next_fire_at": null, + "last_fired_at": null, + "last_outcome": null, + "created_at": "2026-10-14T09:01:00Z", + "updated_at": "2026-10-14T09:01:00Z", + "updated_by": "cli_session" + }, + "webhook_url": "https://api.nan.builders/hooks/nan_whk_Q3vXexampleexampleexampleexampleexample", + "signing_secret": "whsec_exampleexampleexampleexample" + } + } + } + }, + "400": { + "description": "A field that does not pass validation (`param` names it) or an unknown field.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" }, "examples": { - "automation_disabled": { - "summary": "Disabled automation.", + "signing_secret": { + "summary": "A signing secret for a scheme where NaN generates it.", "value": { "error": { - "message": "the automation is disabled", + "message": "NaN generates the signing secret for this scheme", "type": "invalid_request_error", + "param": "signing_secret", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations: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 `automations:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations: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": "automation_disabled" + "code": "token_scope_denied" } } }, - "workspace_not_running": { - "summary": "Stopped workspace.", + "credential_not_allowed": { + "summary": "An API key (`sk-...`), or a session the write API does not accept.", "value": { "error": { - "message": "the workspace is not running", - "type": "invalid_request_error", + "message": "This endpoint needs a browser session, a CLI session (nan auth login) or a platform token with the \"automations: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": "workspace_not_running" + "code": "credential_not_allowed" } } }, - "agent_not_installed": { - "summary": "The automation's agent is not installed in its workspace.", + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", "value": { "error": { - "message": "Hermes is not installed in this workspace", + "message": "workspace keys cannot manage runs", "type": "invalid_request_error", - "param": "agent", - "code": "agent_not_installed" + "param": null, + "code": "workspace_key_not_allowed" } } } @@ -3635,57 +3999,86 @@ } } }, - "429": { - "description": "The automation reached its runs per hour (`automation_rate_limited`), or the limits of every run: the queue (`run_queue_full`) or 60 new runs per hour (`rate_limited`).", + "404": { + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "automation not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The automation has 5 triggers (`too_many_triggers`), or you have 10 schedules (`too_many_schedules`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "examples": { - "automation_rate_limited": { - "summary": "Runs per hour of the automation.", + "too_many_triggers": { + "summary": "5 triggers already.", "value": { "error": { - "message": "the automation reached its run budget for the last hour", + "message": "an automation has at most 5 triggers", "type": "invalid_request_error", "param": null, - "code": "automation_rate_limited" + "code": "too_many_triggers" } } }, - "run_queue_full": { - "summary": "Queue full.", + "too_many_schedules": { + "summary": "10 schedules already.", "value": { "error": { - "message": "too many queued runs", + "message": "you have reached the maximum of 10 schedules", "type": "invalid_request_error", "param": null, - "code": "run_queue_full" + "code": "too_many_schedules" } } } } } } + }, + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } } - }, - "x-codeSamples": [ - { - "lang": "curl", - "label": "cURL", - "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"input\": {\"pr\": 42}}'" - } - ] + } } }, - "/approvals": { + "/automations/{id}/deliveries": { "get": { - "operationId": "listApprovals", + "operationId": "listAutomationDeliveries", "tags": [ - "Approvals" + "Automations" ], - "summary": "List approvals", + "summary": "List webhook deliveries", "security": [ { "apiKey": [] @@ -3694,23 +4087,17 @@ "platformToken": [] } ], - "description": "The proposals of your automation runs, newest first. `?state=pending` lists the ones waiting for you, as the portal's **Needs approval** does.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "description": "The webhook deliveries of an automation in the last 30 days, newest first, and what each one did. Only final outcomes are kept: deliveries refused for their signature are never stored, and throttled ones (`429`, `503`) are not listed because the sender retries them.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:read` scope.", "parameters": [ { - "name": "state", - "in": "query", + "name": "id", + "in": "path", + "required": true, "schema": { "type": "string", - "enum": [ - "auto", - "pending", - "approved", - "rejected", - "expired", - "cancelled" - ] + "format": "uuid" }, - "description": "Only approvals in this state. All of them when omitted." + "description": "The automation id. A malformed id answers `404`." }, { "name": "limit", @@ -3719,66 +4106,36 @@ "type": "integer", "minimum": 1, "maximum": 100, - "default": 20 - }, - "description": "Approvals per page, 1 to 100." - }, - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" + "default": 50 }, - "description": "Opaque: the `next_cursor` of the previous page." + "description": "Deliveries to return, 1 to 100." } ], "responses": { "200": { - "description": "A page of approvals.", + "description": "The deliveries.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApprovalList" + "$ref": "#/components/schemas/WebhookDeliveryList" }, "example": { "object": "list", "data": [ { - "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", - "state": "pending", - "approval_from": 0, - "expires_at": "2026-10-14T09:03:41Z", - "proposal": { - "v": 1, - "actions": [ - { - "kind": "github_pr_review", - "repo": "acme/api", - "number": 42, - "event": "COMMENT", - "body": "The new branch in `login.go` skips the rate limit. Move the check above it.\n\n" - } - ] - }, - "proposal_sha256": "3f1d0c9a8b7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c", - "diffstat": null, - "has_diff": false, - "diff_masked": false, - "decided_at": null, - "prefix_apply_run_id": null, - "apply_run_id": null, - "run_state": "awaiting_approval", - "workspace_id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", - "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69" + "trigger_id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", + "delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958", + "received_at": "2026-10-11T09:00:00Z", + "outcome": "run_created", + "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c" } - ], - "next_cursor": null + ] } } } }, "400": { - "description": "An unknown `state` or `limit` out of range.", + "description": "`limit` out of range.", "content": { "application/json": { "schema": { @@ -3786,9 +4143,9 @@ }, "example": { "error": { - "message": "unknown approval state", + "message": "limit must be between 1 and 100", "type": "invalid_request_error", - "param": "state", + "param": "limit", "code": "invalid_request" } } @@ -3799,94 +4156,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "examples": { - "token_scope_denied": { - "summary": "A platform token without the `runs` scope.", - "value": { - "error": { - "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", - "type": "permission_error", - "param": null, - "code": "token_scope_denied" - } - } - }, - "workspace_key_not_allowed": { - "summary": "The key that lives inside a workspace.", - "value": { - "error": { - "message": "workspace keys cannot manage runs", - "type": "invalid_request_error", - "param": null, - "code": "workspace_key_not_allowed" - } - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "curl", - "label": "cURL", - "source": "curl \"https://api.nan.builders/v1/approvals?state=pending\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" - } - ] - } - }, - "/runs/{id}/proposal.diff": { - "get": { - "operationId": "getRunProposalDiff", - "tags": [ - "Approvals" - ], - "summary": "Download a proposal's diff", - "security": [ - { - "apiKey": [] - }, - { - "platformToken": [] - } - ], - "description": "The full diff of a proposal, as plain text (`Content-Disposition: attachment`). It exists only when your approval covers a push (`has_diff: true`). Secret values in it appear as `[masked]`.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - }, - "description": "The run id. A malformed id answers `404`." - } - ], - "responses": { - "200": { - "description": "The diff.", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "diff --git a/internal/auth/login.go b/internal/auth/login.go\n..." - } - } - }, - "401": { - "$ref": "#/components/responses/RunsUnauthorized" - }, - "403": { - "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "description": "A platform token without the `automations:read` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", "content": { "application/json": { "schema": { @@ -3894,10 +4164,10 @@ }, "examples": { "token_scope_denied": { - "summary": "A platform token without the `runs` scope.", + "summary": "A platform token without the `automations:read` scope.", "value": { "error": { - "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "message": "This platform token does not have the \"automations:read\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", "type": "permission_error", "param": null, "code": "token_scope_denied" @@ -3920,7 +4190,7 @@ } }, "404": { - "description": "No run with that id among yours, or no diff kept for it.", + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", "content": { "application/json": { "schema": { @@ -3928,7 +4198,7 @@ }, "example": { "error": { - "message": "run not found", + "message": "automation not found", "type": "invalid_request_error", "param": null, "code": "not_found" @@ -3942,18 +4212,18 @@ { "lang": "curl", "label": "cURL", - "source": "curl -o proposal.diff https://api.nan.builders/v1/runs/$RUN_ID/proposal.diff \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + "source": "curl \"https://api.nan.builders/v1/automations/$AUTOMATION_ID/deliveries?limit=20\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" } ] } }, - "/runs/{id}/reject": { + "/automations/{id}/runs": { "post": { - "operationId": "rejectRun", + "operationId": "runAutomation", "tags": [ - "Approvals" + "Automations" ], - "summary": "Reject a proposal", + "summary": "Run an automation", "security": [ { "apiKey": [] @@ -3962,7 +4232,7 @@ "platformToken": [] } ], - "description": "Rejects the proposal of a run in `awaiting_approval`: the run ends `cancelled` with `error_code: approval_rejected`, and none of the actions that waited for approval is applied. Actions that applied automatically before it stay applied.\n\nApproving is not available here: it needs you signed in through the browser, in the portal.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "description": "Starts a run of the automation now, with its saved instructions, secrets and approval settings: the same run a webhook or a schedule would start. Its agent proposes actions, which apply automatically or wait for your approval as the automation says. Follow it like any run (`GET /v1/runs/{id}/events`). Runs you start this way are never skipped by `skip_if_active` or cancelled by `supersede`, but they count towards the automation's `max_runs_per_hour` and the limits of every run.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `automations:run` scope. Runs on NaN inference need a plan with inference; an automation that uses your own provider key needs an active workspace of yours instead. Any of these credentials may run an automation that uses your key: its instructions are fixed in the portal, and what you send as `input` reaches an agent that holds your key, so treat it as untrusted. Guide: [Use your own provider key](https://nan.builders/docs/runs#use-your-own-provider-key-byok).", "parameters": [ { "name": "id", @@ -3972,7 +4242,17 @@ "type": "string", "format": "uuid" }, - "description": "The run id. A malformed id answers `404`." + "description": "The automation id. A malformed id answers `404`." + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "description": "Optional. A retry with the same key and the same input returns the run already created (`200`) instead of a second one. Keys starting with `sch:` or `whk:` are reserved (`400`)." } ], "requestBody": { @@ -3980,24 +4260,20 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Optional, for your own record." - } - } + "$ref": "#/components/schemas/RunAutomationRequest" }, "example": { - "reason": "not now" + "input": { + "pr": 42, + "note": "only the auth changes" + } } } } }, "responses": { "200": { - "description": "The run, now `cancelled`.", + "description": "A retry with the same `Idempotency-Key`: the run created the first time.", "content": { "application/json": { "schema": { @@ -4012,8 +4288,8 @@ }, "agent": "pi", "model": null, - "trigger": "webhook", - "state": "cancelled", + "trigger": "api", + "state": "running", "queue_position": null, "prompt_preview": "Review the pull request described in the input for correctness, security and maintainability.", "cwd": "/home/nan/projects/api", @@ -4021,25 +4297,17 @@ "timeout_seconds": 1800, "cancel_requested": false, "exit_code": null, - "error_code": "approval_rejected", - "result": { - "status": "succeeded", - "summary": "Proposed one review on acme/api#42.", - "branch": null, - "commit": null, - "changed_files": 0, - "artifacts": [], - "actions_applied": [] - }, + "error_code": null, + "result": null, "usage": { - "tokens_in": 48210, - "tokens_out": 2304, - "source": "litellm" + "tokens_in": null, + "tokens_out": null, + "source": null }, - "last_seq": 57, + "last_seq": 0, "created_at": "2026-10-11T09:00:00Z", "started_at": "2026-10-11T09:00:02Z", - "finished_at": "2026-10-11T10:12:00Z", + "finished_at": null, "phase": "propose", "inference": { "source": "nan" @@ -4047,31 +4315,7 @@ "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", "parent_run_id": null, "apply_run_ids": [], - "approval": { - "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", - "state": "rejected", - "approval_from": 0, - "expires_at": null, - "proposal": { - "v": 1, - "actions": [ - { - "kind": "github_pr_review", - "repo": "acme/api", - "number": 42, - "event": "COMMENT", - "body": "The new branch in `login.go` skips the rate limit. Move the check above it.\n\n" - } - ] - }, - "proposal_sha256": "3f1d0c9a8b7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c", - "diffstat": null, - "has_diff": false, - "diff_masked": false, - "decided_at": "2026-10-11T10:12:00Z", - "prefix_apply_run_id": null, - "apply_run_id": null - }, + "approval": null, "approval_kinds": [ "git_push", "github_create_pr", @@ -4081,14 +4325,14 @@ "github_add_labels" ], "github_repo": "acme/api", - "trigger_depth": 1, + "trigger_depth": 0, "secrets": [ { "env": "GITHUB_TOKEN", "exposure": "agent" } ], - "agent_finished_at": "2026-10-11T09:03:41Z", + "agent_finished_at": null, "checkout": null, "superseded_by": null, "sticky_pr_comment": false @@ -4096,63 +4340,76 @@ } } }, - "400": { - "description": "`reason` is not text, or unknown fields.", + "201": { + "description": "The run was created.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Run" }, "example": { - "error": { - "message": "unknown field", - "type": "invalid_request_error", - "param": "foo", - "code": "invalid_request" - } - } - } - } - }, - "401": { - "$ref": "#/components/responses/RunsUnauthorized" - }, - "403": { - "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - }, - "examples": { - "token_scope_denied": { - "summary": "A platform token without the `runs` scope.", - "value": { - "error": { - "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", - "type": "permission_error", - "param": null, - "code": "token_scope_denied" - } - } + "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", + "object": "run", + "workspace": { + "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "name": "develop" }, - "workspace_key_not_allowed": { - "summary": "The key that lives inside a workspace.", - "value": { - "error": { - "message": "workspace keys cannot manage runs", - "type": "invalid_request_error", - "param": null, - "code": "workspace_key_not_allowed" - } - } - } - } - } - } - }, - "404": { - "description": "No run with that id among yours (another member's is also `404`, and so is a malformed id).", + "agent": "pi", + "model": null, + "trigger": "api", + "state": "queued", + "queue_position": 1, + "prompt_preview": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "cancel_requested": false, + "exit_code": null, + "error_code": null, + "result": null, + "usage": { + "tokens_in": null, + "tokens_out": null, + "source": null + }, + "last_seq": 0, + "created_at": "2026-10-11T09:00:00Z", + "started_at": null, + "finished_at": null, + "phase": "propose", + "inference": { + "source": "nan" + }, + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "parent_run_id": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "github_repo": "acme/api", + "trigger_depth": 0, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "agent_finished_at": null, + "checkout": null, + "superseded_by": null, + "sticky_pr_comment": false + } + } + } + }, + "400": { + "description": "Invalid body: `input` not a JSON object, too large (48 KiB) or nested too deep (8 levels); unknown fields; a bad `Idempotency-Key`. On an automation that uses your own key, `invalid_request` with `param: \"inference\"` when your own key is not available for your account.", "content": { "application/json": { "schema": { @@ -4160,17 +4417,65 @@ }, "example": { "error": { - "message": "run not found", + "message": "must be a JSON object of at most 48 KiB nested at most 8 levels deep", "type": "invalid_request_error", - "param": null, - "code": "not_found" + "param": "input", + "code": "invalid_request" } } } } }, - "409": { - "description": "The run is not waiting for approval.", + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations:run` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations. A membership without runs (no plan with inference, or, for an automation on your own key, no active workspace): `tier_restricted`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `automations:run` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations:run\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } + } + }, + "tier_restricted": { + "summary": "A membership without inference.", + "value": { + "error": { + "message": "your membership does not include agent runs", + "type": "permission_error", + "param": null, + "code": "tier_restricted" + } + } + } + } + } + } + }, + "404": { + "description": "No automation with that id among yours (another member's is also `404`, and so is a malformed id).", "content": { "application/json": { "schema": { @@ -4178,10 +4483,89 @@ }, "example": { "error": { - "message": "the run is not awaiting approval", + "message": "automation not found", "type": "invalid_request_error", "param": null, - "code": "not_awaiting_approval" + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The run cannot start: the automation is disabled (`automation_disabled`), its workspace is not running (`workspace_not_running`), the automation's agent is not installed in its workspace (`agent_not_installed`, `param: \"agent\"`), the workspace has no inference key (`no_inference_key`), or the `Idempotency-Key` was used with a different input (`idempotency_conflict`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "automation_disabled": { + "summary": "Disabled automation.", + "value": { + "error": { + "message": "the automation is disabled", + "type": "invalid_request_error", + "param": null, + "code": "automation_disabled" + } + } + }, + "workspace_not_running": { + "summary": "Stopped workspace.", + "value": { + "error": { + "message": "the workspace is not running", + "type": "invalid_request_error", + "param": null, + "code": "workspace_not_running" + } + } + }, + "agent_not_installed": { + "summary": "The automation's agent is not installed in its workspace.", + "value": { + "error": { + "message": "Hermes is not installed in this workspace", + "type": "invalid_request_error", + "param": "agent", + "code": "agent_not_installed" + } + } + } + } + } + } + }, + "429": { + "description": "The automation reached its runs per hour (`automation_rate_limited`), or the limits of every run: the queue (`run_queue_full`) or 60 new runs per hour (`rate_limited`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "automation_rate_limited": { + "summary": "Runs per hour of the automation.", + "value": { + "error": { + "message": "the automation reached its run budget for the last hour", + "type": "invalid_request_error", + "param": null, + "code": "automation_rate_limited" + } + } + }, + "run_queue_full": { + "summary": "Queue full.", + "value": { + "error": { + "message": "too many queued runs", + "type": "invalid_request_error", + "param": null, + "code": "run_queue_full" + } + } } } } @@ -4192,24 +4576,18 @@ { "lang": "curl", "label": "cURL", - "source": "curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/reject \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"reason\": \"not now\"}'" + "source": "curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"input\": {\"pr\": 42}}'" } ] } }, - "/api/workspaces": { - "servers": [ - { - "url": "https://api.nan.builders", - "description": "Production. These routes live outside `/v1`." - } - ], + "/approvals": { "get": { - "operationId": "listWorkspaces", + "operationId": "listApprovals", "tags": [ - "Workspaces" + "Approvals" ], - "summary": "List workspaces", + "summary": "List approvals", "security": [ { "apiKey": [] @@ -4218,87 +4596,138 @@ "platformToken": [] } ], - "description": "Your own workspaces, and only yours. No keys, environment values or agent configuration are ever included. Use it to find the `name` or `workspace_uuid` to pass as `workspace` when you create a run.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`). The key that lives inside a workspace is not accepted.\n\nOn `api.nan.builders` this route takes a bearer credential only: cookies are ignored, and there is no browser CORS support (`OPTIONS` answers `404`), so call it from a server or a script, not from a web page.", + "description": "The proposals of your automation runs, newest first. `?state=pending` lists the ones waiting for you, as the portal's **Needs approval** does.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "parameters": [ + { + "name": "state", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "auto", + "pending", + "approved", + "rejected", + "expired", + "cancelled" + ] + }, + "description": "Only approvals in this state. All of them when omitted." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "description": "Approvals per page, 1 to 100." + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque: the `next_cursor` of the previous page." + } + ], "responses": { "200": { - "description": "Your workspaces (a JSON array, empty if you have none).", + "description": "A page of approvals.", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } + "$ref": "#/components/schemas/ApprovalList" }, - "example": [ - { - "workspace_uuid": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", - "name": "develop", - "agent_selection": [ - "pi", - "hermes" - ], - "run_agents": [ - "pi", - "pi_gentle", - "hermes" - ], - "status": "running", - "ssh_command": "ssh 3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15@ssh.nan.builders -p 30222", - "vcpus": 2, - "memory_mib": 4096, - "disk_gib": 20, - "base_image_ver": "2026.10.05-180da44", - "tier": "nano", - "created_at": "2026-10-01T09:30:00Z", - "updated_at": "2026-10-10T08:12:00Z", - "state_reason": null, - "grace_until": null, - "slot": { - "id": 412, - "tier": "nano", - "status": "active", - "cancel_at_period_end": false, - "current_period_end": "2026-11-01T09:30:00Z" - }, - "inference_attached": true, - "rescue": { - "allowed": false, - "active_until": null, - "reason": null, - "rescues_left_today": null - }, - "backup": { - "status": "off", - "stale": false + "example": { + "object": "list", + "data": [ + { + "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", + "state": "pending", + "approval_from": 0, + "expires_at": "2026-10-14T09:03:41Z", + "proposal": { + "v": 1, + "actions": [ + { + "kind": "github_pr_review", + "repo": "acme/api", + "number": 42, + "event": "COMMENT", + "body": "The new branch in `login.go` skips the rate limit. Move the check above it.\n\n" + } + ] + }, + "proposal_sha256": "3f1d0c9a8b7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c", + "diffstat": null, + "has_diff": false, + "diff_masked": false, + "decided_at": null, + "prefix_apply_run_id": null, + "apply_run_id": null, + "run_state": "awaiting_approval", + "workspace_id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69" } + ], + "next_cursor": null + } + } + } + }, + "400": { + "description": "An unknown `state` or `limit` out of range.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "unknown approval state", + "type": "invalid_request_error", + "param": "state", + "code": "invalid_request" } - ] + } } } }, "401": { - "description": "No credentials (`unauthorized`); an unknown, revoked or expired API key or platform token (`invalid session`); or the key that lives inside a workspace (`session_required`).", + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformError" + "$ref": "#/components/schemas/Error" }, "examples": { - "unauthorized": { - "value": { - "error": "unauthorized" - } - }, - "invalid_session": { + "token_scope_denied": { + "summary": "A platform token without the `runs` scope.", "value": { - "error": "invalid session" + "error": { + "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } } }, - "session_required": { + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", "value": { - "error": "session_required", - "message": "This endpoint requires a signed-in session. API keys (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota." + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } } } } @@ -4310,24 +4739,18 @@ { "lang": "curl", "label": "cURL", - "source": "curl https://api.nan.builders/api/workspaces \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + "source": "curl \"https://api.nan.builders/v1/approvals?state=pending\" \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" } ] } }, - "/api/workspaces/{id}": { - "servers": [ - { - "url": "https://api.nan.builders", - "description": "Production. These routes live outside `/v1`." - } - ], + "/runs/{id}/proposal.diff": { "get": { - "operationId": "getWorkspace", + "operationId": "getRunProposalDiff", "tags": [ - "Workspaces" + "Approvals" ], - "summary": "Retrieve a workspace", + "summary": "Download a proposal's diff", "security": [ { "apiKey": [] @@ -4336,7 +4759,7 @@ "platformToken": [] } ], - "description": "One of your own workspaces, plus `env_var_keys`: the names (never the values) of the variables in its environment file. Only your own workspaces are returned: another member's workspace answers `404`, exactly like an id that does not exist.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`). The key that lives inside a workspace is not accepted.\n\nOn `api.nan.builders` this route takes a bearer credential only: cookies are ignored, and there is no browser CORS support (`OPTIONS` answers `404`), so call it from a server or a script, not from a web page.", + "description": "The full diff of a proposal, as plain text (`Content-Disposition: attachment`). It exists only when your approval covers a push (`has_diff: true`). Secret values in it appear as `[masked]`.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", "parameters": [ { "name": "id", @@ -4346,88 +4769,52 @@ "type": "string", "format": "uuid" }, - "description": "The `workspace_uuid`, in lowercase. Anything else is answered `404` before it reaches the API." + "description": "The run id. A malformed id answers `404`." } ], "responses": { "200": { - "description": "The workspace.", + "description": "The diff.", "content": { - "application/json": { + "text/plain": { "schema": { - "$ref": "#/components/schemas/WorkspaceDetail" + "type": "string" }, - "example": { - "workspace_uuid": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", - "name": "develop", - "agent_selection": [ - "pi", - "hermes" - ], - "run_agents": [ - "pi", - "pi_gentle", - "hermes" - ], - "status": "running", - "ssh_command": "ssh 3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15@ssh.nan.builders -p 30222", - "vcpus": 2, - "memory_mib": 4096, - "disk_gib": 20, - "base_image_ver": "2026.10.05-180da44", - "tier": "nano", - "created_at": "2026-10-01T09:30:00Z", - "updated_at": "2026-10-10T08:12:00Z", - "state_reason": null, - "grace_until": null, - "slot": { - "id": 412, - "tier": "nano", - "status": "active", - "cancel_at_period_end": false, - "current_period_end": "2026-11-01T09:30:00Z" - }, - "inference_attached": true, - "rescue": { - "allowed": false, - "active_until": null, - "reason": null, - "rescues_left_today": null - }, - "backup": { - "status": "off", - "stale": false - }, - "env_var_keys": [ - "OPENAI_API_KEY", - "TELEGRAM_BOT_TOKEN" - ] - } + "example": "diff --git a/internal/auth/login.go b/internal/auth/login.go\n..." } } }, "401": { - "description": "No credentials (`unauthorized`); an unknown, revoked or expired API key or platform token (`invalid session`); or the key that lives inside a workspace (`session_required`).", + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PlatformError" + "$ref": "#/components/schemas/Error" }, "examples": { - "unauthorized": { - "value": { - "error": "unauthorized" - } - }, - "invalid_session": { + "token_scope_denied": { + "summary": "A platform token without the `runs` scope.", "value": { - "error": "invalid session" + "error": { + "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } } }, - "session_required": { + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", "value": { - "error": "session_required", - "message": "This endpoint requires a signed-in session. API keys (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota." + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } } } } @@ -4435,30 +4822,545 @@ } }, "404": { - "description": "No workspace of yours with that id: it does not exist, belongs to another member (the answer is identical), or the id is not a lowercase UUID (answered by the edge in the OpenAI error shape).", + "description": "No run with that id among yours, or no diff kept for it.", "content": { "application/json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/PlatformError" - }, - { - "$ref": "#/components/schemas/Error" - } - ] + "$ref": "#/components/schemas/Error" }, - "examples": { - "workspace_not_found": { - "value": { - "error": "workspace not found" - } - }, - "malformed_id": { - "value": { - "error": { - "message": "Not found.", - "type": "invalid_request_error", + "example": { + "error": { + "message": "run not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl -o proposal.diff https://api.nan.builders/v1/runs/$RUN_ID/proposal.diff \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/runs/{id}/reject": { + "post": { + "operationId": "rejectRun", + "tags": [ + "Approvals" + ], + "summary": "Reject a proposal", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "Rejects the proposal of a run in `awaiting_approval`: the run ends `cancelled` with `error_code: approval_rejected`, and none of the actions that waited for approval is applied. Actions that applied automatically before it stay applied.\n\nApproving is not available here: it needs you signed in through the browser, in the portal.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`) with the `runs` scope.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The run id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Optional, for your own record." + } + } + }, + "example": { + "reason": "not now" + } + } + } + }, + "responses": { + "200": { + "description": "The run, now `cancelled`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Run" + }, + "example": { + "id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", + "object": "run", + "workspace": { + "id": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "name": "develop" + }, + "agent": "pi", + "model": null, + "trigger": "webhook", + "state": "cancelled", + "queue_position": null, + "prompt_preview": "Review the pull request described in the input for correctness, security and maintainability.", + "cwd": "/home/nan/projects/api", + "git_isolation": true, + "timeout_seconds": 1800, + "cancel_requested": false, + "exit_code": null, + "error_code": "approval_rejected", + "result": { + "status": "succeeded", + "summary": "Proposed one review on acme/api#42.", + "branch": null, + "commit": null, + "changed_files": 0, + "artifacts": [], + "actions_applied": [] + }, + "usage": { + "tokens_in": 48210, + "tokens_out": 2304, + "source": "litellm" + }, + "last_seq": 57, + "created_at": "2026-10-11T09:00:00Z", + "started_at": "2026-10-11T09:00:02Z", + "finished_at": "2026-10-11T10:12:00Z", + "phase": "propose", + "inference": { + "source": "nan" + }, + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "parent_run_id": null, + "apply_run_ids": [], + "approval": { + "run_id": "6f1c2a9b-3d4e-4f5a-8b6c-7d8e9f0a1b2c", + "state": "rejected", + "approval_from": 0, + "expires_at": null, + "proposal": { + "v": 1, + "actions": [ + { + "kind": "github_pr_review", + "repo": "acme/api", + "number": 42, + "event": "COMMENT", + "body": "The new branch in `login.go` skips the rate limit. Move the check above it.\n\n" + } + ] + }, + "proposal_sha256": "3f1d0c9a8b7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c4b3a29180f7e6d5c", + "diffstat": null, + "has_diff": false, + "diff_masked": false, + "decided_at": "2026-10-11T10:12:00Z", + "prefix_apply_run_id": null, + "apply_run_id": null + }, + "approval_kinds": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ], + "github_repo": "acme/api", + "trigger_depth": 1, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "agent_finished_at": "2026-10-11T09:03:41Z", + "checkout": null, + "superseded_by": null, + "sticky_pr_comment": false + } + } + } + }, + "400": { + "description": "`reason` is not text, or unknown fields.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "unknown field", + "type": "invalid_request_error", + "param": "foo", + "code": "invalid_request" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `runs` scope (`token_scope_denied`), or the key that lives inside a workspace (`workspace_key_not_allowed`): agents cannot manage automations.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `runs` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"runs\" scope this endpoint requires. Create a token with that scope in the Tokens section of Settings at cloud.nan.builders.", + "type": "permission_error", + "param": null, + "code": "token_scope_denied" + } + } + }, + "workspace_key_not_allowed": { + "summary": "The key that lives inside a workspace.", + "value": { + "error": { + "message": "workspace keys cannot manage runs", + "type": "invalid_request_error", + "param": null, + "code": "workspace_key_not_allowed" + } + } + } + } + } + } + }, + "404": { + "description": "No run with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "run not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The run is not waiting for approval.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "the run is not awaiting approval", + "type": "invalid_request_error", + "param": null, + "code": "not_awaiting_approval" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl -X POST https://api.nan.builders/v1/runs/$RUN_ID/reject \\\n -H \"Authorization: Bearer $NAN_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"reason\": \"not now\"}'" + } + ] + } + }, + "/api/workspaces": { + "servers": [ + { + "url": "https://api.nan.builders", + "description": "Production. These routes live outside `/v1`." + } + ], + "get": { + "operationId": "listWorkspaces", + "tags": [ + "Workspaces" + ], + "summary": "List workspaces", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "Your own workspaces, and only yours. No keys, environment values or agent configuration are ever included. Use it to find the `name` or `workspace_uuid` to pass as `workspace` when you create a run.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`). The key that lives inside a workspace is not accepted.\n\nOn `api.nan.builders` this route takes a bearer credential only: cookies are ignored, and there is no browser CORS support (`OPTIONS` answers `404`), so call it from a server or a script, not from a web page.", + "responses": { + "200": { + "description": "Your workspaces (a JSON array, empty if you have none).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Workspace" + } + }, + "example": [ + { + "workspace_uuid": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "name": "develop", + "agent_selection": [ + "pi", + "hermes" + ], + "run_agents": [ + "pi", + "pi_gentle", + "hermes" + ], + "status": "running", + "ssh_command": "ssh 3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15@ssh.nan.builders -p 30222", + "vcpus": 2, + "memory_mib": 4096, + "disk_gib": 20, + "base_image_ver": "2026.10.05-180da44", + "tier": "nano", + "created_at": "2026-10-01T09:30:00Z", + "updated_at": "2026-10-10T08:12:00Z", + "state_reason": null, + "grace_until": null, + "slot": { + "id": 412, + "tier": "nano", + "status": "active", + "cancel_at_period_end": false, + "current_period_end": "2026-11-01T09:30:00Z" + }, + "inference_attached": true, + "rescue": { + "allowed": false, + "active_until": null, + "reason": null, + "rescues_left_today": null + }, + "backup": { + "status": "off", + "stale": false + } + } + ] + } + } + }, + "401": { + "description": "No credentials (`unauthorized`); an unknown, revoked or expired API key or platform token (`invalid session`); or the key that lives inside a workspace (`session_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformError" + }, + "examples": { + "unauthorized": { + "value": { + "error": "unauthorized" + } + }, + "invalid_session": { + "value": { + "error": "invalid session" + } + }, + "session_required": { + "value": { + "error": "session_required", + "message": "This endpoint requires a signed-in session. API keys (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota." + } + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/api/workspaces \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/api/workspaces/{id}": { + "servers": [ + { + "url": "https://api.nan.builders", + "description": "Production. These routes live outside `/v1`." + } + ], + "get": { + "operationId": "getWorkspace", + "tags": [ + "Workspaces" + ], + "summary": "Retrieve a workspace", + "security": [ + { + "apiKey": [] + }, + { + "platformToken": [] + } + ], + "description": "One of your own workspaces, plus `env_var_keys`: the names (never the values) of the variables in its environment file. Only your own workspaces are returned: another member's workspace answers `404`, exactly like an id that does not exist.\n\nAuthentication: a member API key (`sk-...`) or a platform token (`nan_pat_...`). The key that lives inside a workspace is not accepted.\n\nOn `api.nan.builders` this route takes a bearer credential only: cookies are ignored, and there is no browser CORS support (`OPTIONS` answers `404`), so call it from a server or a script, not from a web page.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The `workspace_uuid`, in lowercase. Anything else is answered `404` before it reaches the API." + } + ], + "responses": { + "200": { + "description": "The workspace.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceDetail" + }, + "example": { + "workspace_uuid": "3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15", + "name": "develop", + "agent_selection": [ + "pi", + "hermes" + ], + "run_agents": [ + "pi", + "pi_gentle", + "hermes" + ], + "status": "running", + "ssh_command": "ssh 3b0e8f52-1c7a-4d29-9e61-0a4f2b7c8d15@ssh.nan.builders -p 30222", + "vcpus": 2, + "memory_mib": 4096, + "disk_gib": 20, + "base_image_ver": "2026.10.05-180da44", + "tier": "nano", + "created_at": "2026-10-01T09:30:00Z", + "updated_at": "2026-10-10T08:12:00Z", + "state_reason": null, + "grace_until": null, + "slot": { + "id": 412, + "tier": "nano", + "status": "active", + "cancel_at_period_end": false, + "current_period_end": "2026-11-01T09:30:00Z" + }, + "inference_attached": true, + "rescue": { + "allowed": false, + "active_until": null, + "reason": null, + "rescues_left_today": null + }, + "backup": { + "status": "off", + "stale": false + }, + "env_var_keys": [ + "OPENAI_API_KEY", + "TELEGRAM_BOT_TOKEN" + ] + } + } + } + }, + "401": { + "description": "No credentials (`unauthorized`); an unknown, revoked or expired API key or platform token (`invalid session`); or the key that lives inside a workspace (`session_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformError" + }, + "examples": { + "unauthorized": { + "value": { + "error": "unauthorized" + } + }, + "invalid_session": { + "value": { + "error": "invalid session" + } + }, + "session_required": { + "value": { + "error": "session_required", + "message": "This endpoint requires a signed-in session. API keys (sk-) only work for inference (https://api.nan.builders/v1), the runs API (/v1/runs) and the read-only endpoints GET /api/workspaces, GET /api/workspaces/{uuid} and GET /api/usage/quota." + } + } + } + } + } + }, + "404": { + "description": "No workspace of yours with that id: it does not exist, belongs to another member (the answer is identical), or the id is not a lowercase UUID (answered by the edge in the OpenAI error shape).", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/PlatformError" + }, + { + "$ref": "#/components/schemas/Error" + } + ] + }, + "examples": { + "workspace_not_found": { + "value": { + "error": "workspace not found" + } + }, + "malformed_id": { + "value": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", "code": "404" } } @@ -4467,2420 +5369,5072 @@ } } } - }, - "x-codeSamples": [ - { - "lang": "curl", - "label": "cURL", - "source": "curl https://api.nan.builders/api/workspaces/$WORKSPACE_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + }, + "x-codeSamples": [ + { + "lang": "curl", + "label": "cURL", + "source": "curl https://api.nan.builders/api/workspaces/$WORKSPACE_ID \\\n -H \"Authorization: Bearer $NAN_TOKEN\"" + } + ] + } + }, + "/secrets": { + "get": { + "operationId": "listSecrets", + "tags": [ + "Secrets" + ], + "summary": "List secrets", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Your secrets' metadata: names, kinds, hints, descriptions and the automations that use each one. Never a value.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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 secrets, by name.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretList" + }, + "example": { + "object": "list", + "data": [ + { + "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" + } + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `secrets: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 `secrets:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"secrets: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 \"secrets: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": "Secrets through the API are not available for your account yet (`404 Not found.`): the write API is being opened gradually.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + }, + "post": { + "operationId": "createSecret", + "tags": [ + "Secrets" + ], + "summary": "Create a secret", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Saves a new secret. The value is encrypted and can never be read back, by anyone. Up to 100 secrets per member.\n\nWithout an `Idempotency-Key`, a retry after a lost answer gets `409 name_taken`: list your secrets to check it was created.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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": "Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "description": "Optional. A retry with the same key, `name`, `kind` and `provider` returns the secret the first request created (`200`); the value of the retry is ignored, not compared. The same key with a different `name`, `kind` or `provider` is `409 idempotency_key_reused`. Deleting the secret frees its key." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSecretRequest" + }, + "examples": { + "generic": { + "summary": "A token for your automations.", + "value": { + "name": "GITHUB_TOKEN", + "value": "github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz", + "description": "fine-grained, repo acme/api" + } + }, + "provider_key": { + "summary": "Your own key with an OpenAI-compatible provider.", + "value": { + "name": "TOGETHER_KEY", + "value": "tgp_v1_0123456789abcdef", + "kind": "provider_key", + "provider": "openai_compatible", + "base_url": "https://api.together.xyz/v1" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A retry with the same `Idempotency-Key`: the secret the first request created.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + }, + "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": [], + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-11T09:00:00Z", + "last_used_at": null, + "updated_by": "cli_session" + } + } + } + }, + "201": { + "description": "Created.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + }, + "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": [], + "created_at": "2026-10-11T09:00:00Z", + "updated_at": "2026-10-11T09:00:00Z", + "last_used_at": null, + "updated_by": "cli_session" + } + } + } + }, + "400": { + "description": "A field that does not pass validation (`param` names it, never echoing the value), an unknown field, a body over 32 KiB, or a malformed `Idempotency-Key`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "name": { + "summary": "A name that is not an environment variable name.", + "value": { + "error": { + "message": "must match ^[A-Z][A-Z0-9_]{0,63}$ (an environment variable name)", + "type": "invalid_request_error", + "param": "name", + "code": "invalid_request" + } + } + }, + "value": { + "summary": "A value that ends with a line break.", + "value": { + "error": { + "message": "must not start or end with a line break", + "type": "invalid_request_error", + "param": "value", + "code": "invalid_request" + } + } + }, + "base_url": { + "summary": "An OpenAI-compatible key without its base URL.", + "value": { + "error": { + "message": "base_url is required for an openai_compatible provider key", + "type": "invalid_request_error", + "param": "base_url", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `secrets: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`). Also `platform_access_required` without an active membership.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `secrets:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"secrets: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 \"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.", + "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" + } + } + }, + "platform_access_required": { + "summary": "No active membership.", + "value": { + "error": { + "message": "secrets require an active membership", + "type": "permission_error", + "param": null, + "code": "platform_access_required" + } + } + } + } + } + } + }, + "404": { + "description": "Secrets through the API are not available for your account yet (`404 Not found.`): the write API is being opened gradually.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Not found.", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "The name is taken (`name_taken`), you have 100 secrets (`too_many_secrets`), or the `Idempotency-Key` was used for a different secret (`idempotency_key_reused`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "name_taken": { + "summary": "A secret with this name exists.", + "value": { + "error": { + "message": "a secret with this name already exists", + "type": "invalid_request_error", + "param": null, + "code": "name_taken" + } + } + }, + "too_many_secrets": { + "summary": "100 secrets already.", + "value": { + "error": { + "message": "you have reached the maximum of 100 secrets", + "type": "invalid_request_error", + "param": null, + "code": "too_many_secrets" + } + } + }, + "idempotency_key_reused": { + "summary": "The key was used for another secret.", + "value": { + "error": { + "message": "this Idempotency-Key was already used to create a different secret", + "type": "invalid_request_error", + "param": null, + "code": "idempotency_key_reused" + } + } + } + } + } + } + }, + "429": { + "description": "Too many secret changes (`rate_limited`): every create, value replacement, change and delete of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many secret changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + }, + "503": { + "description": "Secrets are temporarily unavailable (`secrets_unavailable`). Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "secrets are temporarily unavailable, try again shortly", + "type": "server_error", + "param": null, + "code": "secrets_unavailable" + } + } + } + } + } + } + } + }, + "/secrets/{id}": { + "get": { + "operationId": "getSecret", + "tags": [ + "Secrets" + ], + "summary": "Get a secret", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "One secret's metadata. Never the value.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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 secret id. A malformed id answers `404`." + } + ], + "responses": { + "200": { + "description": "The secret.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + }, + "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" + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `secrets: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 `secrets:read` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"secrets: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 \"secrets: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 secret with that id among yours (another member's is also `404`, and so is a malformed id), or secrets are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "secret not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + } + } + }, + "patch": { + "operationId": "updateSecret", + "tags": [ + "Secrets" + ], + "summary": "Change a secret description or base URL", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Changes the description and, for an `openai_compatible` provider key, the base URL. The value is changed with [Replace a secret value](#tag/secrets/PUT/secrets/{id}/value); the name, kind and provider never change.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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 secret id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSecretRequest" + }, + "example": { + "description": "fine-grained, repo acme/api, expires in December" + } + } + } + }, + "responses": { + "200": { + "description": "The secret, changed.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + }, + "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" + } + } + } + }, + "400": { + "description": "No field, an unknown field, or a field that does not pass validation (`param` names it).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "empty": { + "summary": "Neither field.", + "value": { + "error": { + "message": "description or base_url is required", + "type": "invalid_request_error", + "param": "description", + "code": "invalid_request" + } + } + }, + "base_url": { + "summary": "A base URL on a secret that is not an OpenAI-compatible key.", + "value": { + "error": { + "message": "only an openai_compatible provider key has a base_url", + "type": "invalid_request_error", + "param": "base_url", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `secrets: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 `secrets:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"secrets: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 \"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.", + "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 secret with that id among yours (another member's is also `404`, and so is a malformed id), or secrets are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "secret not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "429": { + "description": "Too many secret changes (`rate_limited`): every create, value replacement, change and delete of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many secret changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteSecret", + "tags": [ + "Secrets" + ], + "summary": "Delete a secret", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Deletes the secret and its value for good. Refused while an automation uses it, as one of its secrets or as its provider key: change those automations first.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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 secret id. A malformed id answers `404`." + } + ], + "responses": { + "204": { + "description": "Deleted." + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `secrets: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 `secrets:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"secrets: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 \"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.", + "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 secret with that id among yours (another member's is also `404`, and so is a malformed id), or secrets are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "secret not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "An automation uses it (`secret_in_use`): `error.automations` lists them.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretConflictError" + }, + "example": { + "error": { + "message": "the secret is assigned to automations", + "type": "invalid_request_error", + "param": null, + "code": "secret_in_use", + "automations": [ + { + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "name": "PR review api" + } + ] + } + } + } + } + }, + "429": { + "description": "Too many secret changes (`rate_limited`): every create, value replacement, change and delete of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many secret changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + } + }, + "/secrets/{id}/value": { + "put": { + "operationId": "replaceSecretValue", + "tags": [ + "Secrets" + ], + "summary": "Replace a secret value", + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "description": "Replaces the value. The new one is used by every run that starts from then on, including runs already queued; the old one cannot be recovered.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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 secret id. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplaceSecretValueRequest" + }, + "example": { + "value": "github_pat_11ABCDEFG0123456789_zyxwvutsrqponmlkjihgfedcba" + } + } + } + }, + "responses": { + "200": { + "description": "The secret, with its new hint and size.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Secret" + }, + "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" + } + } + } + }, + "400": { + "description": "A value that does not pass validation (`param: value`, never echoing it).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "value": { + "summary": "A value that starts with a line break.", + "value": { + "error": { + "message": "must not start or end with a line break", + "type": "invalid_request_error", + "param": "value", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `secrets: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`). Also `platform_access_required` without an active membership.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "token_scope_denied": { + "summary": "A platform token without the `secrets:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"secrets: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 \"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.", + "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" + } + } + }, + "platform_access_required": { + "summary": "No active membership.", + "value": { + "error": { + "message": "secrets require an active membership", + "type": "permission_error", + "param": null, + "code": "platform_access_required" + } + } + } + } + } + } + }, + "404": { + "description": "No secret with that id among yours (another member's is also `404`, and so is a malformed id), or secrets are not available for your account (`404 Not found.`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "secret not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "409": { + "description": "With the new value, the secrets of a run of some automation would add up to more than 48 KiB (`secrets_too_large`): `error.automations` lists them.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecretConflictError" + }, + "example": { + "error": { + "message": "the secrets of a run would exceed 48 KiB", + "type": "invalid_request_error", + "param": null, + "code": "secrets_too_large", + "automations": [ + { + "id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "name": "PR review api" + } + ] + } + } + } + } + }, + "429": { + "description": "Too many secret changes (`rate_limited`): every create, value replacement, change and delete of a member shares one budget of 30 at once, then 120 per hour, together with the changes made in the portal. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many secret changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + }, + "503": { + "description": "Secrets are temporarily unavailable (`secrets_unavailable`). Retry shortly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "secrets are temporarily unavailable, try again shortly", + "type": "server_error", + "param": null, + "code": "secrets_unavailable" + } + } + } + } + } + } + } + }, + "/automations/{id}/triggers/{trigger_id}": { + "patch": { + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "tags": [ + "Automations" + ], + "operationId": "updateAutomationTrigger", + "summary": "Change a trigger", + "description": "Pauses or resumes a trigger (`enabled`), or changes its filters or its schedule. The kind and the signature scheme never change: delete it and create another.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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 automation id. A malformed id answers `404`." + }, + { + "name": "trigger_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The trigger id, from the trigger list. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateTriggerRequest" + }, + "example": { + "enabled": false + } + } + } + }, + "responses": { + "200": { + "description": "The trigger, changed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Trigger" + }, + "example": { + "id": "9e2b4d6f-1a3c-4e5b-8d7f-6c0a2b4e6f81", + "object": "trigger", + "automation_id": "0d5a7c3e-8b21-4f6a-9c4d-2e7f1a3b5c69", + "kind": "webhook", + "enabled": false, + "token_prefix": "nan_whk_Q3vX", + "signature_scheme": "github", + "event_filter": { + "events": [ + "pull_request" + ], + "actions": [ + "opened", + "synchronize", + "reopened", + "ready_for_review" + ], + "exclude_drafts": true + }, + "actor_filter": { + "logins": [], + "author_associations": [ + "OWNER", + "MEMBER", + "COLLABORATOR" + ], + "exclude_bots": true + }, + "prev_secret_until": null, + "cron": null, + "timezone": null, + "next_fire_at": null, + "last_fired_at": null, + "last_outcome": null, + "created_at": "2026-10-14T09:01:00Z", + "updated_at": "2026-10-14T09:01:00Z", + "updated_by": "cli_session" + } + } + } + }, + "400": { + "description": "A field that does not pass validation, or one that does not apply to the kind of trigger.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "cron": { + "summary": "A schedule on a webhook.", + "value": { + "error": { + "message": "a webhook trigger has no schedule", + "type": "invalid_request_error", + "param": "cron", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations: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 `automations:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations: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 \"automations: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 trigger with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "trigger not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + }, + "delete": { + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "tags": [ + "Automations" + ], + "operationId": "deleteAutomationTrigger", + "summary": "Delete a trigger", + "description": "Deletes the trigger: its webhook URL stops working, or its schedule stops, at once.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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 automation id. A malformed id answers `404`." + }, + { + "name": "trigger_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The trigger id, from the trigger list. A malformed id answers `404`." + } + ], + "responses": { + "204": { + "description": "Deleted." + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations: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 `automations:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations: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 \"automations: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 trigger with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "trigger not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + } + }, + "/automations/{id}/triggers/{trigger_id}/rotate": { + "post": { + "security": [ + { + "cliSession": [] + }, + { + "platformToken": [] + } + ], + "tags": [ + "Automations" + ], + "operationId": "rotateAutomationTrigger", + "summary": "Rotate a webhook URL or signing secret", + "description": "Gives a webhook a new URL, a new signing secret, or both. A new URL replaces the old one at once. A new signing secret keeps the old one working for 24 hours unless `grace` is `false`. The answer carries the new values, **the only time they are returned**, with `Cache-Control: no-store`. For `stripe`, roll the secret in Stripe first and send it as `signing_secret`.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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 automation id. A malformed id answers `404`." + }, + { + "name": "trigger_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "The trigger id, from the trigger list. A malformed id answers `404`." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RotateTriggerRequest" + }, + "example": { + "rotate": [ + "secret" + ], + "grace": true + } + } + } + }, + "responses": { + "200": { + "description": "Rotated. The only time the new values are returned.", + "headers": { + "Cache-Control": { + "description": "Always `no-store`.", + "schema": { + "type": "string", + "const": "no-store" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TriggerRotated" + }, + "example": { + "signing_secret": "whsec_newexampleexampleexampleexample" + } + } + } + }, + "400": { + "description": "`rotate` empty or with something other than `url` and `secret`, a schedule (no URL or secret), or a `signing_secret` without `secret`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "examples": { + "rotate": { + "summary": "Nothing to rotate.", + "value": { + "error": { + "message": "rotate must list \"url\" and/or \"secret\"", + "type": "invalid_request_error", + "param": "rotate", + "code": "invalid_request" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/RunsUnauthorized" + }, + "403": { + "description": "A platform token without the `automations: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 `automations:write` scope.", + "value": { + "error": { + "message": "This platform token does not have the \"automations: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 \"automations: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 trigger with that id among yours (another member's is also `404`, and so is a malformed id).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "trigger not found", + "type": "invalid_request_error", + "param": null, + "code": "not_found" + } + } + } + } + }, + "429": { + "description": "Too many automation changes (`rate_limited`): every write to a member's automations and their triggers shares one budget of 30 at once, then 300 per hour. Wait and retry.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + }, + "example": { + "error": { + "message": "Too many automation changes recently. Try again later.", + "type": "rate_limit_exceeded", + "param": null, + "code": "rate_limited" + } + } + } + } + } + } + } + } + }, + "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" + }, + "object": { + "type": "string", + "enum": [ + "model" + ], + "description": "The object type, which is always `model`.", + "example": "model" + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the model became available.", + "example": 1677610602 + }, + "owned_by": { + "type": "string", + "description": "The organization that owns the model.", + "example": "nan" + } + } + }, + "ContentPart": { + "description": "One part of a multimodal message. Use an array of these as `content` for vision input.", + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "text" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "text" + ] + }, + "text": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "type", + "image_url" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "image_url" + ] + }, + "image_url": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Public URL or `data:` URI of the image." + } + } + } + } + } + ] + }, + "Message": { + "type": "object", + "required": [ + "role" + ], + "description": "A single message in a conversation. The `role` determines which other fields apply.", + "properties": { + "role": { + "type": "string", + "enum": [ + "system", + "user", + "assistant", + "tool" + ], + "description": "The role of the message author: `system`, `user`, `assistant`, or `tool`." + }, + "content": { + "description": "The contents of the message. A plain string, or an array of content parts for multimodal input. Can be `null` on an `assistant` message that only returns tool calls.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContentPart" + } + } + ] + }, + "name": { + "type": "string", + "description": "An optional name for the author of this message." + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolCall" + }, + "description": "The tool calls generated by the model. Present on `assistant` messages when the model decides to invoke one or more tools." + }, + "tool_call_id": { + "type": "string", + "description": "The id of the tool call this message answers. Required when `role` is `tool`." + }, + "reasoning_content": { + "type": "string", + "description": "The model's reasoning trace. Present only for reasoning models; safe to display or ignore." + } + } + }, + "Tool": { + "type": "object", + "required": [ + "type", + "function" + ], + "description": "A function the model may call. Provide these in the request's `tools` array.", + "properties": { + "type": { + "type": "string", + "enum": [ + "function" + ], + "description": "The type of the tool. Currently only `function` is supported." + }, + "function": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,64}$", + "description": "The name of the function to call. Up to 64 characters; letters, digits, underscores and dashes. A name outside that pattern is rejected with a `400` before the request reaches the model, on every model." + }, + "description": { + "type": "string", + "description": "What the function does, used by the model to decide when and how to call it. Be descriptive." + }, + "strict": { + "type": "boolean", + "default": false, + "description": "Request that the generated arguments follow `parameters` exactly (fields, types and required keys); enforced where the model supports strict decoding. Combined with a forced `tool_choice`, it is the way to get schema-shaped output on `deepseek-v4-flash`, which returns arguments that follow the schema." + }, + "parameters": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "object" + ], + "description": "The root type of the schema. Must be `object`." + } + }, + "additionalProperties": true, + "description": "The parameters the function accepts, described as a JSON Schema object. When present, its root must be `\"type\": \"object\"`: a root of any other type, or with no `type`, is rejected with a `400` before the request reaches the model, on every model. A function that takes no arguments can omit `parameters` or send `{\"type\": \"object\", \"properties\": {}}`." + } + } + } + } + }, + "ToolChoice": { + "description": "Controls tool use: `\"auto\"` (default), `\"none\"`, `\"required\"`, or force one with `{ \"type\": \"function\", \"function\": { \"name\": \"...\" } }`.", + "oneOf": [ + { + "type": "string", + "enum": [ + "auto", + "none", + "required" + ] + }, + { + "type": "object" + } + ] + }, + "ToolCall": { + "type": "object", + "description": "A tool the model chose to call. Execute it, then send the result back in a `tool` message referencing `id`.", + "properties": { + "id": { + "type": "string", + "description": "A unique id for this tool call. Pass it as `tool_call_id` in your follow-up `tool` message." + }, + "type": { + "type": "string", + "enum": [ + "function" + ], + "description": "The type of the tool call. Always `function`." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the function to call." + }, + "arguments": { + "type": "string", + "description": "The function arguments as a JSON-encoded string. Parse and validate it before use; models can occasionally emit malformed JSON." + } + } + } + } + }, + "ResponseFormat": { + "description": "Force structured output. What each mode guarantees:\n\n- `json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt. On `deepseek-v4-flash` the prompt must also contain the word JSON, otherwise `400`.\n- `json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).\n\n`json_schema` works on `qwen3.6` and `gemma4`. On `deepseek-v4-flash` it is rejected with a `400` before the request reaches the model; `json_object` still works there. For output that follows a schema on `deepseek-v4-flash`, define one function tool whose `parameters` is your JSON Schema (root `type: object`, `strict: true`), force it with `tool_choice: {\"type\": \"function\", \"function\": {\"name\": \"...\"}}` and read `choices[0].message.tool_calls[0].function.arguments`.", + "oneOf": [ + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "json_object" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "json_schema" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "json_schema" + ] + }, + "json_schema": { + "type": "object", + "required": [ + "name", + "schema" + ], + "properties": { + "name": { + "type": "string" + }, + "strict": { + "type": "boolean" + }, + "schema": { + "type": "object" + } + } + } + } + } + ] + }, + "Usage": { + "type": "object", + "description": "Token accounting for the request.", + "properties": { + "prompt_tokens": { + "type": "integer", + "description": "The number of tokens in the prompt (input)." + }, + "completion_tokens": { + "type": "integer", + "description": "The number of tokens in the generated completion (output)." + }, + "total_tokens": { + "type": "integer", + "description": "The total tokens used: `prompt_tokens` plus `completion_tokens`. This is what counts against your quota." + } + } + }, + "ChatCompletion": { + "type": "object", + "description": "The model's response to a chat request. Represents a single, non-streamed completion.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the completion." + }, + "object": { + "type": "string", + "enum": [ + "chat.completion" + ], + "description": "The object type, always `chat.completion`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the completion was created." + }, + "model": { + "type": "string", + "description": "The model that produced the completion." + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "description": "The index of this choice in the `choices` array." + }, + "finish_reason": { + "type": "string", + "enum": [ + "stop", + "length", + "tool_calls" + ], + "description": "Why the model stopped: `stop` (natural end or a stop sequence), `length` (hit `max_tokens`), or `tool_calls` (the model called a tool)." + }, + "message": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "The list of completion choices. Contains a single choice unless you request more." + }, + "usage": { + "$ref": "#/components/schemas/Usage", + "description": "Token usage for the request." + } + } + }, + "ChatCompletionChunk": { + "type": "object", + "description": "One Server-Sent Event chunk when `stream: true`. New text arrives in `choices[0].delta.content`; the stream ends with `data: [DONE]`.", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "enum": [ + "chat.completion.chunk" + ], + "description": "The object type, always `chat.completion.chunk`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the chunk was created. Identical across all chunks of one response." + }, + "model": { + "type": "string", + "description": "The model producing the response." + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "delta": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "content": { + "type": "string" + }, + "reasoning_content": { + "type": "string" + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolCall" + } + } + }, + "description": "The incremental update for this chunk. `role` is set on the first chunk; `content` carries the new text to append." + }, + "finish_reason": { + "type": [ + "string", + "null" + ], + "description": "`null` on intermediate chunks; set to the stop reason on the final content chunk." + } + } + } + } + } + }, + "Error": { + "type": "object", + "description": "OpenAI-standard error envelope: a non-2xx HTTP status with a JSON body describing the problem.", + "properties": { + "error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "type": { + "type": [ + "string", + "null" + ] + }, + "param": { + "type": [ + "string", + "null" + ] + }, + "code": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "example": { + "error": { + "message": "...", + "type": null, + "param": null, + "code": "..." + } + } + }, + "Embedding": { + "type": "object", + "description": "A single embedding vector for one input.", + "properties": { + "object": { + "type": "string", + "enum": [ + "embedding" + ], + "description": "The object type, always `embedding`." + }, + "index": { + "type": "integer", + "description": "The position of this embedding in the request `input` array." + }, + "embedding": { + "type": "array", + "items": { + "type": "number" + }, + "description": "The embedding vector: a list of 4096 floats. Cosine similarity between vectors measures semantic closeness." + } + } + }, + "EmbeddingList": { + "type": "object", + "description": "A list of embeddings, one per input.", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ], + "description": "Always `list`." + }, + "model": { + "type": "string", + "description": "The model used to create the embeddings." + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Embedding" + }, + "description": "The embeddings, in the same order as the request `input`." + }, + "usage": { + "type": "object", + "description": "Token usage for the request.", + "properties": { + "prompt_tokens": { + "type": "integer", + "description": "Tokens in the input." + }, + "total_tokens": { + "type": "integer", + "description": "Total tokens used." + } + } + } + } + }, + "RerankResult": { + "type": "object", + "description": "One reranked document.", + "properties": { + "index": { + "type": "integer", + "description": "The position of this document in the request `documents` array." + }, + "relevance_score": { + "type": "number", + "description": "Relevance to the query, in the range [0, 1]. Higher is more relevant. Results are returned sorted by this, descending." + }, + "document": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The document text." + } + }, + "description": "The document, echoed back." + } + } + }, + "RerankResponse": { + "type": "object", + "description": "The reranked documents, most-relevant first.", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the rerank request." + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RerankResult" + }, + "description": "The documents, sorted by descending `relevance_score`. Each carries its original `index`." + }, + "meta": { + "type": "object", + "description": "Request metadata, including token accounting.", + "properties": { + "billed_units": { + "type": "object", + "properties": { + "total_tokens": { + "type": "integer" + } + } + }, + "tokens": { + "type": "object", + "properties": { + "input_tokens": { + "type": "integer" + } + } + } + } + } + } + }, + "Transcription": { + "type": "object", + "description": "A transcription of the supplied audio.", + "properties": { + "text": { + "type": "string", + "description": "The full transcribed text." + }, + "language": { + "type": "string", + "description": "The detected (or supplied) language, as an ISO-639-1 code." + }, + "task": { + "type": "string", + "description": "The task performed, always `transcribe`." + }, + "duration": { + "type": "number", + "description": "The audio duration in seconds. Present with `verbose_json`." + }, + "segments": { + "type": "array", + "description": "Time-aligned segments. Present with `verbose_json`.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "start": { + "type": "number", + "description": "Segment start time in seconds." + }, + "end": { + "type": "number", + "description": "Segment end time in seconds." + }, + "text": { + "type": "string" + }, + "avg_logprob": { + "type": "number" + }, + "no_speech_prob": { + "type": "number" + } + } + } + }, + "words": { + "type": [ + "array", + "null" + ], + "description": "Per-word timestamps `[{ word, start, end, probability }]`. Populated only when `timestamp_granularities[]=word`." } - ] - } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "http", - "scheme": "bearer", - "description": "Member API key (`sk-...`), from \"API Keys\" in your settings on the platform. Send it as `Authorization: Bearer `. It works on every inference endpoint, on the Runs, Automations and Approvals endpoints and on the two Workspaces endpoints (it cannot approve runs or change automations). Personal and non-transferable. The key that lives inside a workspace (the one its agents use) is refused by the Runs endpoints with `403 workspace_key_not_allowed`." + } }, - "platformToken": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "nan_pat_...", - "description": "Platform token (`nan_pat_...`), from \"Tokens\" in your settings on the platform. Send it as `Authorization: Bearer `. It works only on the platform API: Runs (`/v1/runs...`), Automations (`/v1/automations...`), Approvals and the two Workspaces endpoints, and only where it carries the scope the endpoint names: `runs`, `workspaces:read`, `automations:read` or `automations:run`. A missing scope answers `403 token_scope_denied`. New tokens get `runs` and `workspaces:read` by default; a token with an `automations:` scope can only be created from a browser sign-in. It never works for inference: inference endpoints refuse it with `401 token_not_valid_for_inference`. It can never approve a run or change automations or secrets. Shown once when you create it; up to 10 active; expires after 30, 90 or 365 days, or never; revocable at any time. Any member with an active subscription can create one; creating runs still needs a plan with inference." - } - }, - "schemas": { - "Model": { + "ResponseObject": { "type": "object", - "description": "An available model, in the OpenAI-compatible model shape.", + "description": "A response produced by the Responses API.", "properties": { "id": { "type": "string", - "description": "The model's unique slug. Pass this value as the `model` field in your requests.", - "example": "deepseek-v4-flash" + "description": "A unique identifier for the response." + }, + "created_at": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the response was created." + }, + "model": { + "type": "string", + "description": "The model that produced the response." }, "object": { "type": "string", "enum": [ - "model" + "response" ], - "description": "The object type, which is always `model`.", - "example": "model" - }, - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the model became available.", - "example": 1677610602 + "description": "The object type, always `response`." }, - "owned_by": { + "status": { "type": "string", - "description": "The organization that owns the model.", - "example": "nan" - } - } - }, - "ContentPart": { - "description": "One part of a multimodal message. Use an array of these as `content` for vision input.", - "oneOf": [ - { + "description": "The response status, e.g. `completed`." + }, + "output": { + "type": "array", + "description": "The output items. May contain `reasoning` blocks (reasoning models) followed by a `message` block with the reply text in `content[].text`.", + "items": { + "type": "object" + } + }, + "usage": { "type": "object", - "required": [ - "type", - "text" - ], + "description": "Token usage for the request.", "properties": { - "type": { - "type": "string", - "enum": [ - "text" - ] + "input_tokens": { + "type": "integer" }, - "text": { - "type": "string" + "output_tokens": { + "type": "integer" + }, + "total_tokens": { + "type": "integer" } } + } + } + }, + "ImageResponse": { + "type": "object", + "description": "The generated image(s).", + "properties": { + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the images were created." }, - { - "type": "object", - "required": [ - "type", - "image_url" + "data": { + "type": "array", + "description": "The generated images. Each item has a `url` (default) or a `b64_json` payload, depending on `response_format`.", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "A temporary URL to the image, valid ~60 minutes. Returned when `response_format` is `url`." + }, + "b64_json": { + "type": "string", + "description": "The base64-encoded image bytes. Returned when `response_format` is `b64_json`." + } + } + } + } + } + }, + "Completion": { + "type": "object", + "description": "A completion for the provided prompt (legacy).", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string", + "enum": [ + "text_completion" ], - "properties": { - "type": { - "type": "string", - "enum": [ - "image_url" - ] - }, - "image_url": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Public URL or `data:` URI of the image." - } + "description": "Always `text_completion`." + }, + "created": { + "type": "integer", + "description": "The Unix timestamp (in seconds) when the completion was created." + }, + "model": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The generated text." + }, + "index": { + "type": "integer" + }, + "finish_reason": { + "type": "string", + "enum": [ + "stop", + "length" + ], + "description": "Why generation stopped." + }, + "logprobs": { + "type": [ + "object", + "null" + ] } } } + }, + "usage": { + "$ref": "#/components/schemas/Usage" } - ] + } }, - "Message": { + "UsageReport": { "type": "object", - "required": [ - "role" - ], - "description": "A single message in a conversation. The `role` determines which other fields apply.", + "description": "Your token usage for the requested window.", "properties": { - "role": { + "object": { "type": "string", "enum": [ - "system", - "user", - "assistant", - "tool" + "usage.report" ], - "description": "The role of the message author: `system`, `user`, `assistant`, or `tool`." + "description": "The object type, always `usage.report`." }, - "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" - } - } - ] + "start_date": { + "type": "string", + "format": "date", + "description": "First day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover." }, - "name": { + "end_date": { "type": "string", - "description": "An optional name for the author of this message." + "format": "date", + "description": "Last day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover." }, - "tool_calls": { + "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ToolCall" + "$ref": "#/components/schemas/UsageRow" }, - "description": "The tool calls generated by the model. Present on `assistant` messages when the model decides to invoke one or more tools." + "description": "One row per (date, model) pair in the window, ordered by date ascending, then model ascending. Long windows come back paginated." }, - "tool_call_id": { - "type": "string", - "description": "The id of the tool call this message answers. Required when `role` is `tool`." + "totals": { + "$ref": "#/components/schemas/UsageTotals", + "description": "Totals over the full requested window, not just the current page." }, - "reasoning_content": { - "type": "string", - "description": "The model's reasoning trace. Present only for reasoning models; safe to display or ignore." + "all_time": { + "$ref": "#/components/schemas/UsageAllTime", + "description": "Your lifetime usage, always present." + }, + "has_more": { + "type": "boolean", + "description": "Whether more pages follow. When `true`, pass `next_cursor` back as the `cursor` parameter." + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "The cursor for the next page, or `null` on the last page." } } }, - "Tool": { + "UsageRow": { "type": "object", - "required": [ - "type", - "function" - ], - "description": "A function the model may call. Provide these in the request's `tools` array.", + "description": "Usage for one model on one day.", "properties": { - "type": { + "date": { "type": "string", - "enum": [ - "function" - ], - "description": "The type of the tool. Currently only `function` is supported." + "description": "The UTC day this row covers, `YYYY-MM-DD`." }, - "function": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]{1,64}$", - "description": "The name of the function to call. Up to 64 characters; letters, digits, underscores and dashes. A name outside that pattern is rejected with a `400` before the request reaches the model, on every model." - }, - "description": { - "type": "string", - "description": "What the function does, used by the model to decide when and how to call it. Be descriptive." - }, - "strict": { - "type": "boolean", - "default": false, - "description": "Request that the generated arguments follow `parameters` exactly (fields, types and required keys); enforced where the model supports strict decoding. Combined with a forced `tool_choice`, it is the way to get schema-shaped output on `deepseek-v4-flash`, which returns arguments that follow the schema." - }, - "parameters": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "object" - ], - "description": "The root type of the schema. Must be `object`." - } - }, - "additionalProperties": true, - "description": "The parameters the function accepts, described as a JSON Schema object. When present, its root must be `\"type\": \"object\"`: a root of any other type, or with no `type`, is rejected with a `400` before the request reaches the model, on every model. A function that takes no arguments can omit `parameters` or send `{\"type\": \"object\", \"properties\": {}}`." - } - } - } - } - }, - "ToolChoice": { - "description": "Controls tool use: `\"auto\"` (default), `\"none\"`, `\"required\"`, or force one with `{ \"type\": \"function\", \"function\": { \"name\": \"...\" } }`.", - "oneOf": [ - { + "model": { "type": "string", - "enum": [ - "auto", - "none", - "required" - ] + "description": "The model id, as in the Model catalog." }, - { - "type": "object" + "prompt_tokens": { + "type": "integer", + "description": "Input tokens used that day on that model." + }, + "completion_tokens": { + "type": "integer", + "description": "Output tokens generated that day on that model." + }, + "total_tokens": { + "type": "integer", + "description": "`prompt_tokens` plus `completion_tokens`." + }, + "api_requests": { + "type": "integer", + "description": "Requests made that day on that model. Request counts are only available from 2026-09-02 onward; older days report `0`." } - ] + } }, - "ToolCall": { + "UsageTotals": { "type": "object", - "description": "A tool the model chose to call. Execute it, then send the result back in a `tool` message referencing `id`.", + "description": "Totals over the full requested window, independent of pagination.", "properties": { - "id": { - "type": "string", - "description": "A unique id for this tool call. Pass it as `tool_call_id` in your follow-up `tool` message." + "prompt_tokens": { + "type": "integer", + "description": "Total input tokens in the window." }, - "type": { - "type": "string", - "enum": [ - "function" - ], - "description": "The type of the tool call. Always `function`." + "completion_tokens": { + "type": "integer", + "description": "Total output tokens in the window." }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The name of the function to call." - }, - "arguments": { - "type": "string", - "description": "The function arguments as a JSON-encoded string. Parse and validate it before use; models can occasionally emit malformed JSON." - } - } + "total_tokens": { + "type": "integer", + "description": "`prompt_tokens` plus `completion_tokens`." + }, + "api_requests": { + "type": "integer", + "description": "Total requests in the window. Request counts are only available from 2026-09-02 onward; older days report `0`." + }, + "by_model": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageModelTotals" + }, + "description": "The same totals, broken down per model." } } }, - "ResponseFormat": { - "description": "Force structured output. What each mode guarantees:\n\n- `json_object`: syntactically valid JSON only, NOT its shape. Describe the schema you want in the prompt. On `deepseek-v4-flash` the prompt must also contain the word JSON, otherwise `400`.\n- `json_schema` with `strict: true`: the output is constrained to the schema (fields, types and required keys).\n\n`json_schema` works on `qwen3.6` and `gemma4`. On `deepseek-v4-flash` it is rejected with a `400` before the request reaches the model; `json_object` still works there. For output that follows a schema on `deepseek-v4-flash`, define one function tool whose `parameters` is your JSON Schema (root `type: object`, `strict: true`), force it with `tool_choice: {\"type\": \"function\", \"function\": {\"name\": \"...\"}}` and read `choices[0].message.tool_calls[0].function.arguments`.", - "oneOf": [ - { - "type": "object", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "json_object" - ] - } - } - }, - { - "type": "object", - "required": [ - "type", - "json_schema" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "json_schema" - ] - }, - "json_schema": { - "type": "object", - "required": [ - "name", - "schema" - ], - "properties": { - "name": { - "type": "string" - }, - "strict": { - "type": "boolean" - }, - "schema": { - "type": "object" - } - } - } - } + "UsageModelTotals": { + "type": "object", + "description": "Window totals for one model.", + "properties": { + "model": { + "type": "string", + "description": "The model id, as in the Model catalog." + }, + "prompt_tokens": { + "type": "integer", + "description": "Input tokens used on this model in the window." + }, + "completion_tokens": { + "type": "integer", + "description": "Output tokens generated on this model in the window." + }, + "total_tokens": { + "type": "integer", + "description": "`prompt_tokens` plus `completion_tokens`." + }, + "api_requests": { + "type": "integer", + "description": "Requests made on this model in the window. Request counts are only available from 2026-09-02 onward; older days report `0`." } - ] + } }, - "Usage": { + "UsageAllTime": { "type": "object", - "description": "Token accounting for the request.", + "description": "Your lifetime usage across all models.", "properties": { "prompt_tokens": { "type": "integer", - "description": "The number of tokens in the prompt (input)." + "description": "Total input tokens ever used." }, "completion_tokens": { "type": "integer", - "description": "The number of tokens in the generated completion (output)." + "description": "Total output tokens ever generated." }, "total_tokens": { "type": "integer", - "description": "The total tokens used: `prompt_tokens` plus `completion_tokens`. This is what counts against your quota." + "description": "`prompt_tokens` plus `completion_tokens`." + }, + "api_requests": { + "type": "integer", + "description": "Total requests recorded for this member. Request counts are only available from 2026-09-02 onward; older days report `0`." + }, + "cached_at": { + "type": "string", + "description": "When the all-time summary was last computed, as an ISO 8601 UTC timestamp." } } }, - "ChatCompletion": { + "PlatformError": { "type": "object", - "description": "The model's response to a chat request. Represents a single, non-streamed completion.", + "description": "Error body of the Workspaces endpoints (outside `/v1`): a short `error` string, plus `message` on `session_required`.", "properties": { - "id": { + "error": { "type": "string", - "description": "A unique identifier for the completion." + "description": "Short error: `unauthorized`, `invalid session`, `session_required` or `workspace not found`." }, - "object": { + "message": { + "type": "string", + "description": "Present on `session_required`: what the credential can be used for." + } + }, + "required": [ + "error" + ] + }, + "CreateRunRequest": { + "type": "object", + "description": "Body of `POST /v1/runs`. Unknown fields are refused with `400`.", + "required": [ + "workspace", + "agent", + "prompt" + ], + "additionalProperties": false, + "properties": { + "workspace": { + "type": "string", + "description": "Name or id of one of your workspaces.", + "example": "develop" + }, + "agent": { "type": "string", "enum": [ - "chat.completion" + "pi", + "pi_gentle", + "hermes" ], - "description": "The object type, always `chat.completion`." + "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes). It must be installed in the workspace (see `run_agents` on the workspace), otherwise `409 agent_not_installed` with `param: \"agent\"`." }, - "created": { + "prompt": { + "type": "string", + "minLength": 1, + "description": "The task. 1 to 32768 bytes of UTF-8 (32 KiB), no NUL bytes. Called Task in the portal.", + "example": "add tests for the date parser and make them pass" + }, + "cwd": { + "type": "string", + "default": "/home/nan", + "maxLength": 512, + "description": "Working directory: an absolute path inside `/home/nan`, without `.` or `..` segments. Called Folder in the portal." + }, + "git_isolation": { + "type": [ + "boolean", + "null" + ], + "default": null, + "description": "`null` (auto): when `cwd` is inside a git repository, the agent works on its own branch `nan-run/` in a separate worktree, commits its changes there and never pushes; outside a repository it works in the folder. `true`: always a separate branch (fails with `config_error` outside a repository). `false`: always in the folder. Called Separate branch in the portal." + }, + "timeout_seconds": { "type": "integer", - "description": "The Unix timestamp (in seconds) when the completion was created." + "minimum": 60, + "maximum": 7200, + "default": 1800, + "description": "Time limit. The run is stopped (`timed_out`) when it reaches it. Called Time limit in the portal." }, "model": { - "type": "string", - "description": "The model that produced the completion." + "type": [ + "string", + "null" + ], + "pattern": "^[A-Za-z0-9._:/-]{1,128}$", + "default": null, + "description": "Model for the agent on NaN inference. `null` uses the agent's own default. With `inference.source: byok` leave it out: the model is `inference.model` (`400` with `param: \"model\"` otherwise)." }, - "choices": { - "type": "array", - "items": { - "type": "object", - "properties": { - "index": { - "type": "integer", - "description": "The index of this choice in the `choices` array." - }, - "finish_reason": { - "type": "string", - "enum": [ - "stop", - "length", - "tool_calls" - ], - "description": "Why the model stopped: `stop` (natural end or a stop sequence), `length` (hit `max_tokens`), or `tool_calls` (the model called a tool)." - }, - "message": { - "$ref": "#/components/schemas/Message" - } - } - }, - "description": "The list of completion choices. Contains a single choice unless you request more." + "trigger": { + "type": "string", + "enum": [ + "api", + "cli", + "ui" + ], + "default": "api", + "description": "Who started the run. Informational only." }, - "usage": { - "$ref": "#/components/schemas/Usage", - "description": "Token usage for the request." + "inference": { + "$ref": "#/components/schemas/CreateRunInference" } } }, - "ChatCompletionChunk": { + "RunArtifact": { "type": "object", - "description": "One Server-Sent Event chunk when `stream: true`. New text arrives in `choices[0].delta.content`; the stream ends with `data: [DONE]`.", + "description": "A file the agent left in its run's `artifacts/` folder.", "properties": { - "id": { - "type": "string" + "path": { + "type": "string", + "description": "Path relative to the run folder, under `artifacts/`.", + "example": "artifacts/report.md" }, - "object": { + "bytes": { + "type": "integer" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + }, + "RunResult": { + "type": "object", + "description": "How the run ended, written once it is over. Text from the agent is data: render it as plain text.", + "properties": { + "status": { "type": "string", "enum": [ - "chat.completion.chunk" + "succeeded", + "failed", + "cancelled", + "timed_out" + ] + }, + "summary": { + "type": "string", + "description": "The agent's final answer, up to 4 KiB." + }, + "branch": { + "type": [ + "string", + "null" ], - "description": "The object type, always `chat.completion.chunk`." + "description": "The `nan-run/...` branch with the agent's commit, when it worked on a separate branch and changed something. `null` otherwise (with no changes the branch is deleted).", + "example": "nan-run/6f1c2a9b" }, - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the chunk was created. Identical across all chunks of one response." + "commit": { + "type": [ + "string", + "null" + ], + "description": "The commit on `branch`." }, - "model": { - "type": "string", - "description": "The model producing the response." + "changed_files": { + "type": "integer" }, - "choices": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunArtifact" + } + }, + "actions_applied": { "type": "array", + "description": "On an `apply` run: each action applied, in order. Empty otherwise.", "items": { "type": "object", "properties": { - "index": { - "type": "integer" - }, - "delta": { - "type": "object", - "properties": { - "role": { - "type": "string" - }, - "content": { - "type": "string" - }, - "reasoning_content": { - "type": "string" - }, - "tool_calls": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolCall" - } - } - }, - "description": "The incremental update for this chunk. `role` is set on the first chunk; `content` carries the new text to append." - }, - "finish_reason": { - "type": [ - "string", - "null" - ], - "description": "`null` on intermediate chunks; set to the stop reason on the final content chunk." + "kind": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + }, + "ok": { + "type": "boolean" + }, + "ref": { + "type": "string", + "description": "What the action produced: a commit, or the link of the pull request, comment or review." } } } } } }, - "Error": { + "Run": { "type": "object", - "description": "OpenAI-standard error envelope: a non-2xx HTTP status with a JSON body describing the problem.", + "description": "A run. Every response returns this object; `prompt` (the full task), `instructions` and `input` are only in `GET /v1/runs/{id}`.", "properties": { - "error": { + "id": { + "type": "string", + "format": "uuid" + }, + "object": { + "type": "string", + "enum": [ + "run" + ] + }, + "workspace": { "type": "object", "properties": { - "message": { - "type": "string" - }, - "type": { - "type": [ - "string", - "null" - ] - }, - "param": { - "type": [ - "string", - "null" - ] + "id": { + "type": "string", + "format": "uuid" }, - "code": { + "name": { "type": [ "string", "null" - ] + ], + "description": "`null` once the workspace is deleted." } } - } - }, - "example": { - "error": { - "message": "...", - "type": null, - "param": null, - "code": "..." - } - } - }, - "Embedding": { - "type": "object", - "description": "A single embedding vector for one input.", - "properties": { - "object": { + }, + "agent": { "type": "string", "enum": [ - "embedding" + "pi", + "pi_gentle", + "hermes" ], - "description": "The object type, always `embedding`." + "description": "The agent: `pi` (Pi), `pi_gentle` (Pi + Gentle Shell: Pi started through gentle-shell) or `hermes` (Hermes)." }, - "index": { - "type": "integer", - "description": "The position of this embedding in the request `input` array." + "model": { + "type": [ + "string", + "null" + ], + "description": "The model the run asked for, or `null` for the agent's default. With `byok` inference it is `inference.model`." }, - "embedding": { - "type": "array", - "items": { - "type": "number" - }, - "description": "The embedding vector: a list of 4096 floats. Cosine similarity between vectors measures semantic closeness." - } - } - }, - "EmbeddingList": { - "type": "object", - "description": "A list of embeddings, one per input.", - "properties": { - "object": { + "trigger": { "type": "string", "enum": [ - "list" + "api", + "cli", + "ui", + "webhook", + "schedule", + "approval", + "automation" ], - "description": "Always `list`." + "description": "What created the run: `api`, `cli` or `ui` (you, from the API, the CLI or the portal; a manual run of an automation is `api` or `ui`), `webhook` or `schedule` (an automation trigger), `approval` (the step that applies the actions you approved) or `automation` (the step that applies actions that need no approval)." }, - "model": { + "state": { "type": "string", - "description": "The model used to create the embeddings." - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Embedding" - }, - "description": "The embeddings, in the same order as the request `input`." + "enum": [ + "queued", + "starting", + "running", + "awaiting_approval", + "succeeded", + "failed", + "cancelled", + "timed_out" + ], + "description": "`queued` waits for a free place (per-workspace and per-member limits), then `starting`, `running`, and one of the four final states. An automation run whose proposal needs your approval stops in `awaiting_approval` until you approve it (`succeeded`), reject it or it expires (`cancelled`)." }, - "usage": { - "type": "object", - "description": "Token usage for the request.", - "properties": { - "prompt_tokens": { - "type": "integer", - "description": "Tokens in the input." - }, - "total_tokens": { - "type": "integer", - "description": "Total tokens used." - } - } - } - } - }, - "RerankResult": { - "type": "object", - "description": "One reranked document.", - "properties": { - "index": { - "type": "integer", - "description": "The position of this document in the request `documents` array." + "queue_position": { + "type": [ + "integer", + "null" + ], + "description": "1-based position while `queued`, otherwise `null`." }, - "relevance_score": { - "type": "number", - "description": "Relevance to the query, in the range [0, 1]. Higher is more relevant. Results are returned sorted by this, descending." + "prompt_preview": { + "type": "string", + "description": "The first 200 characters of the task." }, - "document": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The document text." - } - }, - "description": "The document, echoed back." - } - } - }, - "RerankResponse": { - "type": "object", - "description": "The reranked documents, most-relevant first.", - "properties": { - "id": { + "prompt": { "type": "string", - "description": "A unique identifier for the rerank request." + "description": "The full task. Only in `GET /v1/runs/{id}`." }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RerankResult" - }, - "description": "The documents, sorted by descending `relevance_score`. Each carries its original `index`." + "cwd": { + "type": "string" }, - "meta": { + "git_isolation": { + "type": [ + "boolean", + "null" + ] + }, + "timeout_seconds": { + "type": "integer" + }, + "cancel_requested": { + "type": "boolean" + }, + "exit_code": { + "type": [ + "integer", + "null" + ] + }, + "error_code": { + "type": [ + "string", + "null" + ], + "enum": [ + "unreachable", + "access_denied", + "admin_unavailable", + "host_key_mismatch", + "workspace_not_running", + "workspace_deleted", + "workspace_unavailable", + "not_entitled", + "no_inference_key", + "agent_not_installed", + "config_error", + "install_failed", + "busy", + "agent_failed", + "timed_out", + "cancelled", + "interrupted", + "protocol_error", + "executor_error", + "secrets_unavailable", + "secret_missing", + "proposal_invalid", + "approval_rejected", + "approval_expired", + "action_failed", + "automation_deleted", + null + ], + "description": "Why the run did not succeed, when there is a reason. For automation runs: `secrets_unavailable` or `secret_missing` (its secrets could not be delivered, so it did not start), `proposal_invalid` (the proposed actions were refused), `approval_rejected`, `approval_expired`, `action_failed` (an action could not be applied) and `automation_deleted` (deleted while the run was queued)." + }, + "result": { + "oneOf": [ + { + "$ref": "#/components/schemas/RunResult" + }, + { + "type": "null" + } + ] + }, + "usage": { "type": "object", - "description": "Request metadata, including token accounting.", + "description": "Tokens the run used. Each field is `null` until it is known.", "properties": { - "billed_units": { - "type": "object", - "properties": { - "total_tokens": { - "type": "integer" - } - } + "tokens_in": { + "type": [ + "integer", + "null" + ] }, - "tokens": { - "type": "object", - "properties": { - "input_tokens": { - "type": "integer" - } - } + "tokens_out": { + "type": [ + "integer", + "null" + ] + }, + "source": { + "type": [ + "string", + "null" + ], + "enum": [ + "litellm", + "agent", + "byok", + null + ], + "description": "Where the counts come from: `litellm` (measured by the NaN gateway), `agent` (reported by the agent) or `byok` (a run on your own provider key: the counts are what the agent reported, `null` when it reports none, and nothing is charged against your NaN plan; your provider's bill is the reference)." } } - } - } - }, - "Transcription": { - "type": "object", - "description": "A transcription of the supplied audio.", - "properties": { - "text": { - "type": "string", - "description": "The full transcribed text." }, - "language": { - "type": "string", - "description": "The detected (or supplied) language, as an ISO-639-1 code." + "last_seq": { + "type": "integer", + "description": "`seq` of the last stored event." }, - "task": { + "created_at": { "type": "string", - "description": "The task performed, always `transcribe`." - }, - "duration": { - "type": "number", - "description": "The audio duration in seconds. Present with `verbose_json`." - }, - "segments": { - "type": "array", - "description": "Time-aligned segments. Present with `verbose_json`.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "start": { - "type": "number", - "description": "Segment start time in seconds." - }, - "end": { - "type": "number", - "description": "Segment end time in seconds." - }, - "text": { - "type": "string" - }, - "avg_logprob": { - "type": "number" - }, - "no_speech_prob": { - "type": "number" - } - } - } + "format": "date-time" }, - "words": { + "started_at": { "type": [ - "array", + "string", "null" ], - "description": "Per-word timestamps `[{ word, start, end, probability }]`. Populated only when `timestamp_granularities[]=word`." - } - } - }, - "ResponseObject": { - "type": "object", - "description": "A response produced by the Responses API.", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for the response." - }, - "created_at": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the response was created." + "format": "date-time" }, - "model": { - "type": "string", - "description": "The model that produced the response." + "finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" }, - "object": { + "phase": { "type": "string", "enum": [ - "response" + "run", + "propose", + "apply" ], - "description": "The object type, always `response`." + "description": "`run`: a run you start with `POST /v1/runs`. `propose`: an automation run, whose agent proposes actions instead of acting. `apply`: the step that applies a proposal's actions, with no agent." }, - "status": { - "type": "string", - "description": "The response status, e.g. `completed`." + "inference": { + "$ref": "#/components/schemas/RunInference" }, - "output": { + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The automation that created the run. `null` for runs you start with `POST /v1/runs`, and once the automation is deleted." + }, + "parent_run_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "On an `apply` run: the automation run whose proposal it applies." + }, + "apply_run_ids": { "type": "array", - "description": "The output items. May contain `reasoning` blocks (reasoning models) followed by a `message` block with the reply text in `content[].text`.", "items": { - "type": "object" - } + "type": "string", + "format": "uuid" + }, + "description": "On an automation run: the `apply` runs that applied its actions, in order." }, - "usage": { - "type": "object", - "description": "Token usage for the request.", - "properties": { - "input_tokens": { - "type": "integer" + "approval": { + "oneOf": [ + { + "$ref": "#/components/schemas/Approval" }, - "output_tokens": { - "type": "integer" + { + "type": "null" + } + ], + "description": "On an automation run that proposed actions: the proposal and where its approval stands. `null` otherwise." + }, + "instructions": { + "type": "string", + "description": "The automation's instructions the run used. Only in `GET /v1/runs/{id}` of an automation run." + }, + "input": { + "oneOf": [ + { + "$ref": "#/components/schemas/RunInput" }, - "total_tokens": { - "type": "integer" + { + "type": "null" } - } - } - } - }, - "ImageResponse": { - "type": "object", - "description": "The generated image(s).", - "properties": { - "created": { - "type": "integer", - "description": "The Unix timestamp (in seconds) when the images were created." + ], + "description": "The event or input that started an automation run. Only in `GET /v1/runs/{id}`." }, - "data": { - "type": "array", - "description": "The generated images. Each item has a `url` (default) or a `b64_json` payload, depending on `response_format`.", + "approval_kinds": { + "type": [ + "array", + "null" + ], "items": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "A temporary URL to the image, valid ~60 minutes. Returned when `response_format` is `url`." - }, - "b64_json": { - "type": "string", - "description": "The base64-encoded image bytes. Returned when `response_format` is `b64_json`." - } - } - } - } - } - }, - "Completion": { - "type": "object", - "description": "A completion for the provided prompt (legacy).", - "properties": { - "id": { - "type": "string" + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + }, + "description": "On an automation run: the action kinds that needed approval when it started. `null` otherwise." }, - "object": { - "type": "string", - "enum": [ - "text_completion" + "github_repo": { + "type": [ + "string", + "null" ], - "description": "Always `text_completion`." + "description": "On an automation run: the repository its actions act on." }, - "created": { + "trigger_depth": { "type": "integer", - "description": "The Unix timestamp (in seconds) when the completion was created." - }, - "model": { - "type": "string" + "description": "0 for a run you start. 1 for a webhook or schedule run, one more for each run caused by another run (loop protection)." }, - "choices": { + "secrets": { "type": "array", + "description": "The secrets the run receives: variable names and who gets them, never values.", "items": { "type": "object", "properties": { - "text": { - "type": "string", - "description": "The generated text." - }, - "index": { - "type": "integer" + "env": { + "type": "string" }, - "finish_reason": { + "exposure": { "type": "string", "enum": [ - "stop", - "length" - ], - "description": "Why generation stopped." - }, - "logprobs": { - "type": [ - "object", - "null" + "agent", + "actions" ] } } } }, - "usage": { - "$ref": "#/components/schemas/Usage" + "agent_finished_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the agent of an automation run finished, before any approval." + }, + "error": { + "type": [ + "object", + "null" + ], + "description": "Why a finished run failed, or `null`: `code` is its `error_code`, and on `GET /v1/runs/{id}` `message` is the text of its last `error` event (masked; render it as plain text; `null` when there is none). On a run on your own provider key, a provider error (a wrong key, an unknown model) shows up here.", + "required": [ + "code", + "message" + ], + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": [ + "string", + "null" + ] + } + } + }, + "checkout": { + "oneOf": [ + { + "$ref": "#/components/schemas/RunCheckout" + }, + { + "type": "null" + } + ], + "description": "On an automation run (`phase: \"propose\"`) whose automation uses `checkout: \"fresh\"`: the commit it works on. `null` for every other run, apply runs included." + }, + "superseded_by": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "When a newer run for the same subject cancelled this one (`concurrency_policy: \"supersede\"`): that run's id. The run ends `cancelled` with `error_code: \"cancelled\"`." + }, + "sticky_pr_comment": { + "type": "boolean", + "description": "On an automation run (`phase: \"propose\"`): whether its pull request comments edit the automation's one comment per pull request (`sticky_pr_comment` of the automation when the run started). `false` on other runs." } } }, - "UsageReport": { + "RunList": { "type": "object", - "description": "Your token usage for the requested window.", "properties": { "object": { "type": "string", "enum": [ - "usage.report" - ], - "description": "The object type, always `usage.report`." - }, - "start_date": { - "type": "string", - "format": "date", - "description": "First day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover." - }, - "end_date": { - "type": "string", - "format": "date", - "description": "Last day of the effective window actually served, `YYYY-MM-DD`, after any clamping: the window your totals cover." + "list" + ] }, "data": { "type": "array", "items": { - "$ref": "#/components/schemas/UsageRow" + "$ref": "#/components/schemas/Run" }, - "description": "One row per (date, model) pair in the window, ordered by date ascending, then model ascending. Long windows come back paginated." - }, - "totals": { - "$ref": "#/components/schemas/UsageTotals", - "description": "Totals over the full requested window, not just the current page." - }, - "all_time": { - "$ref": "#/components/schemas/UsageAllTime", - "description": "Your lifetime usage, always present." - }, - "has_more": { - "type": "boolean", - "description": "Whether more pages follow. When `true`, pass `next_cursor` back as the `cursor` parameter." + "description": "Newest first. `prompt` is not included: use `prompt_preview`." }, "next_cursor": { "type": [ "string", "null" ], - "description": "The cursor for the next page, or `null` on the last page." + "description": "Pass it as `cursor` for the next page. `null` on the last page." } } }, - "UsageRow": { + "RunEventDataStarted": { "type": "object", - "description": "Usage for one model on one day.", + "description": "`data` of a `started` event. The run started.", "properties": { - "date": { + "agent": { "type": "string", - "description": "The UTC day this row covers, `YYYY-MM-DD`." + "enum": [ + "pi", + "pi_gentle", + "hermes" + ] + }, + "agent_version": { + "type": "string" }, "model": { + "type": [ + "string", + "null" + ] + }, + "cwd": { "type": "string", - "description": "The model id, as in the Model catalog." + "description": "Where the agent works (inside the worktree when there is one)." }, - "prompt_tokens": { - "type": "integer", - "description": "Input tokens used that day on that model." + "worktree": { + "type": [ + "string", + "null" + ], + "description": "The separate worktree, or `null` when the agent works in the folder." }, - "completion_tokens": { - "type": "integer", - "description": "Output tokens generated that day on that model." + "branch": { + "type": [ + "string", + "null" + ], + "description": "`nan-run/...` when there is a worktree, otherwise `null`." }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." + "unit": { + "type": "string" }, - "api_requests": { - "type": "integer", - "description": "Requests made that day on that model. Request counts are only available from 2026-09-02 onward; older days report `0`." + "checkout": { + "type": "object", + "description": "Only on runs with a fresh checkout: the commit checked out.", + "properties": { + "mode": { + "type": "string", + "enum": [ + "fresh" + ] + }, + "sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$" + } + } } } }, - "UsageTotals": { + "RunEventDataLog": { "type": "object", - "description": "Totals over the full requested window, independent of pagination.", + "description": "`data` of a `log` event. A line of output that is not a message.", "properties": { - "prompt_tokens": { - "type": "integer", - "description": "Total input tokens in the window." + "stream": { + "type": "string", + "enum": [ + "agent_stdout", + "agent_stderr", + "nan-run" + ] }, - "completion_tokens": { - "type": "integer", - "description": "Total output tokens in the window." + "text": { + "type": "string", + "description": "Up to 8 KiB." + } + } + }, + "RunEventDataMessage": { + "type": "object", + "description": "`data` of a `message` event. Assistant text, sent incrementally: each event carries only its own chunk, and the last chunk of a message has `final: true` (its text may be empty). Concatenate the chunks to rebuild the message.", + "properties": { + "role": { + "type": "string", + "enum": [ + "assistant" + ] }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." + "text": { + "type": "string", + "description": "This chunk only, up to 16 KiB." }, - "api_requests": { - "type": "integer", - "description": "Total requests in the window. Request counts are only available from 2026-09-02 onward; older days report `0`." + "final": { + "type": "boolean" + } + } + }, + "RunEventDataToolCall": { + "type": "object", + "description": "`data` of a `tool_call` event. The agent called a tool.", + "properties": { + "id": { + "type": "string" }, - "by_model": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UsageModelTotals" - }, - "description": "The same totals, broken down per model." + "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." } } }, - "UsageModelTotals": { + "RunEventDataUsage": { "type": "object", - "description": "Window totals for one model.", + "description": "`data` of a `usage` event. Tokens used so far, as the agent reports them (cumulative).", "properties": { "model": { - "type": "string", - "description": "The model id, as in the Model catalog." + "type": [ + "string", + "null" + ] }, "prompt_tokens": { - "type": "integer", - "description": "Input tokens used on this model in the window." + "type": "integer" }, "completion_tokens": { - "type": "integer", - "description": "Output tokens generated on this model in the window." - }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." - }, - "api_requests": { - "type": "integer", - "description": "Requests made on this model in the window. Request counts are only available from 2026-09-02 onward; older days report `0`." + "type": "integer" } } }, - "UsageAllTime": { + "RunEventDataError": { "type": "object", - "description": "Your lifetime usage across all models.", + "description": "`data` of an `error` event. Something went wrong.", "properties": { - "prompt_tokens": { - "type": "integer", - "description": "Total input tokens ever used." - }, - "completion_tokens": { - "type": "integer", - "description": "Total output tokens ever generated." - }, - "total_tokens": { - "type": "integer", - "description": "`prompt_tokens` plus `completion_tokens`." - }, - "api_requests": { - "type": "integer", - "description": "Total requests recorded for this member. Request counts are only available from 2026-09-02 onward; older days report `0`." + "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" + ] }, - "cached_at": { + "message": { "type": "string", - "description": "When the all-time summary was last computed, as an ISO 8601 UTC timestamp." + "description": "Up to 2 KiB." } } }, - "PlatformError": { + "RunEventDataTruncated": { "type": "object", - "description": "Error body of the Workspaces endpoints (outside `/v1`): a short `error` string, plus `message` on `session_required`.", + "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": { - "error": { - "type": "string", - "description": "Short error: `unauthorized`, `invalid session`, `session_required` or `workspace not found`." - }, - "message": { + "reason": { "type": "string", - "description": "Present on `session_required`: what the credential can be used for." + "enum": [ + "event_cap", + "size_cap" + ] } - }, - "required": [ - "error" - ] + } }, - "CreateRunRequest": { + "RunEventDataFinished": { "type": "object", - "description": "Body of `POST /v1/runs`. Unknown fields are refused with `400`.", - "required": [ - "workspace", - "agent", - "prompt" - ], - "additionalProperties": false, + "description": "`data` of a `finished` event. The run ended. Always the last event.", "properties": { - "workspace": { - "type": "string", - "description": "Name or id of one of your workspaces.", - "example": "develop" - }, - "agent": { + "status": { "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). It must be installed in the workspace (see `run_agents` on the workspace), otherwise `409 agent_not_installed` with `param: \"agent\"`." - }, - "prompt": { - "type": "string", - "minLength": 1, - "description": "The task. 1 to 32768 bytes of UTF-8 (32 KiB), no NUL bytes. Called Task in the portal.", - "example": "add tests for the date parser and make them pass" + "succeeded", + "failed", + "cancelled", + "timed_out" + ] }, - "cwd": { - "type": "string", - "default": "/home/nan", - "maxLength": 512, - "description": "Working directory: an absolute path inside `/home/nan`, without `.` or `..` segments. Called Folder in the portal." + "exit_code": { + "type": "integer" }, - "git_isolation": { + "error_code": { "type": [ - "boolean", + "string", "null" ], - "default": null, - "description": "`null` (auto): when `cwd` is inside a git repository, the agent works on its own branch `nan-run/` in a separate worktree, commits its changes there and never pushes; outside a repository it works in the folder. `true`: always a separate branch (fails with `config_error` outside a repository). `false`: always in the folder. Called Separate branch in the portal." + "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 + ] }, - "timeout_seconds": { + "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", - "minimum": 60, - "maximum": 7200, - "default": 1800, - "description": "Time limit. The run is stopped (`timed_out`) when it reaches it. Called Time limit in the portal." + "description": "1, 2, 3... in order, per run. Use it to resume (`after`, `Last-Event-ID`)." }, - "model": { - "type": [ - "string", - "null" - ], - "pattern": "^[A-Za-z0-9._:/-]{1,128}$", - "default": null, - "description": "Model for the agent on NaN inference. `null` uses the agent's own default. With `inference.source: byok` leave it out: the model is `inference.model` (`400` with `param: \"model\"` otherwise)." + "ts": { + "type": "string", + "format": "date-time" }, - "trigger": { + "type": { "type": "string", "enum": [ - "api", - "cli", - "ui" + "started", + "log", + "message", + "tool_call", + "tool_result", + "usage", + "artifact", + "error", + "truncated", + "finished", + "approval_required", + "action" ], - "default": "api", - "description": "Who started the run. Informational only." + "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." }, - "inference": { - "$ref": "#/components/schemas/CreateRunInference" + "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 ./...\"}" } } }, - "RunArtifact": { + "RunEventPage": { "type": "object", - "description": "A file the agent left in its run's `artifacts/` folder.", "properties": { - "path": { - "type": "string", - "description": "Path relative to the run folder, under `artifacts/`.", - "example": "artifacts/report.md" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RunEvent" + } }, - "bytes": { - "type": "integer" + "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." }, - "sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" + "done": { + "type": "boolean", + "description": "`true` when the run is over and every event has been returned." } } }, - "RunResult": { + "WorkspaceSlot": { "type": "object", - "description": "How the run ended, written once it is over. Text from the agent is data: render it as plain text.", + "description": "The purchased slot paying for the workspace.", "properties": { - "status": { - "type": "string", - "enum": [ - "succeeded", - "failed", - "cancelled", - "timed_out" - ] + "id": { + "type": "integer" }, - "summary": { - "type": "string", - "description": "The agent's final answer, up to 4 KiB." + "tier": { + "type": "string" }, - "branch": { - "type": [ - "string", - "null" - ], - "description": "The `nan-run/...` branch with the agent's commit, when it worked on a separate branch and changed something. `null` otherwise (with no changes the branch is deleted).", - "example": "nan-run/6f1c2a9b" + "status": { + "type": "string" }, - "commit": { + "cancel_at_period_end": { + "type": "boolean" + }, + "current_period_end": { "type": [ "string", "null" ], - "description": "The commit on `branch`." - }, - "changed_files": { - "type": "integer" - }, - "artifacts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RunArtifact" - } - }, - "actions_applied": { - "type": "array", - "description": "On an `apply` run: each action applied, in order. Empty otherwise.", - "items": { - "type": "object", - "properties": { - "kind": { - "type": "string", - "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ] - }, - "ok": { - "type": "boolean" - }, - "ref": { - "type": "string", - "description": "What the action produced: a commit, or the link of the pull request, comment or review." - } - } - } + "format": "date-time" } } }, - "Run": { + "Workspace": { "type": "object", - "description": "A run. Every response returns this object; `prompt` (the full task), `instructions` and `input` are only in `GET /v1/runs/{id}`.", + "description": "One of your workspaces. Never carries keys, environment values or agent configuration.", "properties": { - "id": { + "workspace_uuid": { "type": "string", - "format": "uuid" + "format": "uuid", + "description": "The workspace id. Accepted by `workspace` in `POST /v1/runs`." }, - "object": { + "name": { "type": "string", - "enum": [ - "run" - ] - }, - "workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": [ - "string", - "null" - ], - "description": "`null` once the workspace is deleted." - } - } + "description": "Unique among your workspaces. Accepted by `workspace` in `POST /v1/runs`." }, - "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)." + "agent_selection": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Agents and tools chosen for the workspace." }, - "model": { - "type": [ - "string", - "null" - ], - "description": "The model the run asked for, or `null` for the agent's default. With `byok` inference it is `inference.model`." + "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." }, - "trigger": { + "status": { "type": "string", "enum": [ - "api", - "cli", - "ui", - "webhook", - "schedule", - "approval", - "automation" + "provisioning", + "running", + "stopped", + "crashed", + "unknown" ], - "description": "What created the run: `api`, `cli` or `ui` (you, from the API, the CLI or the portal; a manual run of an automation is `api` or `ui`), `webhook` or `schedule` (an automation trigger), `approval` (the step that applies the actions you approved) or `automation` (the step that applies actions that need no approval)." + "description": "Runs can only be created while it is `running`." }, - "state": { + "ssh_command": { "type": "string", - "enum": [ - "queued", - "starting", - "running", - "awaiting_approval", - "succeeded", - "failed", - "cancelled", - "timed_out" - ], - "description": "`queued` waits for a free place (per-workspace and per-member limits), then `starting`, `running`, and one of the four final states. An automation run whose proposal needs your approval stops in `awaiting_approval` until you approve it (`succeeded`), reject it or it expires (`cancelled`)." + "description": "The command to connect over SSH." }, - "queue_position": { - "type": [ - "integer", - "null" - ], - "description": "1-based position while `queued`, otherwise `null`." + "vcpus": { + "type": "integer" }, - "prompt_preview": { - "type": "string", - "description": "The first 200 characters of the task." + "memory_mib": { + "type": "integer" }, - "prompt": { - "type": "string", - "description": "The full task. Only in `GET /v1/runs/{id}`." + "disk_gib": { + "type": "integer" }, - "cwd": { + "base_image_ver": { "type": "string" }, - "git_isolation": { - "type": [ - "boolean", - "null" - ] + "tier": { + "type": "string", + "enum": [ + "free", + "micro", + "nano", + "basic", + "medium", + "large" + ], + "description": "Size. `free` is the Micro-sized workspace included with Premium." }, - "timeout_seconds": { - "type": "integer" + "created_at": { + "type": "string", + "format": "date-time" }, - "cancel_requested": { - "type": "boolean" + "updated_at": { + "type": "string", + "format": "date-time" }, - "exit_code": { + "state_reason": { "type": [ - "integer", + "string", "null" - ] + ], + "enum": [ + "dunning", + "canceled", + "disputed", + "churn", + "banned", + "pending_capacity", + "rescue", + null + ], + "description": "Why the workspace is not running normally, or `null`." }, - "error_code": { + "grace_until": { "type": [ "string", "null" ], - "enum": [ - "unreachable", - "access_denied", - "admin_unavailable", - "host_key_mismatch", - "workspace_not_running", - "workspace_deleted", - "workspace_unavailable", - "not_entitled", - "no_inference_key", - "agent_not_installed", - "config_error", - "install_failed", - "busy", - "agent_failed", - "timed_out", - "cancelled", - "interrupted", - "protocol_error", - "executor_error", - "secrets_unavailable", - "secret_missing", - "proposal_invalid", - "approval_rejected", - "approval_expired", - "action_failed", - "automation_deleted", - null - ], - "description": "Why the run did not succeed, when there is a reason. For automation runs: `secrets_unavailable` or `secret_missing` (its secrets could not be delivered, so it did not start), `proposal_invalid` (the proposed actions were refused), `approval_rejected`, `approval_expired`, `action_failed` (an action could not be applied) and `automation_deleted` (deleted while the run was queued)." + "format": "date-time", + "description": "When an open grace period ends (the data is deleted then), or `null`." }, - "result": { + "slot": { "oneOf": [ { - "$ref": "#/components/schemas/RunResult" + "$ref": "#/components/schemas/WorkspaceSlot" }, { "type": "null" } - ] + ], + "description": "`null` for the free workspace." }, - "usage": { + "inference_attached": { + "type": "boolean", + "description": "Whether the workspace has an inference key (never the key itself). Runs need it." + }, + "rescue": { "type": "object", - "description": "Tokens the run used. Each field is `null` until it is known.", + "description": "Rescue sessions during a grace period.", "properties": { - "tokens_in": { + "allowed": { + "type": "boolean" + }, + "active_until": { "type": [ - "integer", + "string", "null" - ] + ], + "format": "date-time" }, - "tokens_out": { + "reason": { "type": [ - "integer", + "string", "null" ] }, - "source": { + "rescues_left_today": { "type": [ - "string", + "integer", "null" - ], + ] + } + } + }, + "backup": { + "type": [ + "object", + "null" + ], + "description": "The backup add-on.", + "properties": { + "status": { + "type": "string", "enum": [ - "litellm", - "agent", - "byok", - null - ], - "description": "Where the counts come from: `litellm` (measured by the NaN gateway), `agent` (reported by the agent) or `byok` (a run on your own provider key: the counts are what the agent reported, `null` when it reports none, and nothing is charged against your NaN plan; your provider's bill is the reference)." + "off", + "pending_payment", + "active", + "disabling", + "disabled", + "failed" + ] + }, + "stale": { + "type": "boolean", + "description": "`true` when the last snapshot is older than expected." } } + } + } + }, + "WorkspaceDetail": { + "allOf": [ + { + "$ref": "#/components/schemas/Workspace" }, - "last_seq": { - "type": "integer", - "description": "`seq` of the last stored event." + { + "type": "object", + "properties": { + "env_var_keys": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of the variables in the workspace's environment file. Never the values." + } + } + } + ] + }, + "RunInput": { + "type": "object", + "description": "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 + ] }, - "created_at": { + "source": { "type": "string", - "format": "date-time" + "enum": [ + "api", + "webhook:github", + "webhook:stripe", + "webhook:nan", + "schedule" + ] }, - "started_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "event": { + "type": "string", + "description": "The event name, for a webhook." }, - "finished_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "delivery": { + "type": "string", + "description": "The delivery id, for a webhook." }, - "phase": { + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The input itself: a JSON object." + } + } + }, + "ProposalAction": { + "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" + ], + "properties": { + "kind": { "type": "string", "enum": [ - "run", - "propose", - "apply" - ], - "description": "`run`: a run you start with `POST /v1/runs`. `propose`: an automation run, whose agent proposes actions instead of acting. `apply`: the step that applies a proposal's actions, with no agent." + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] }, - "inference": { - "$ref": "#/components/schemas/RunInference" + "repo": { + "type": "string", + "description": "`owner/name`: always the automation's `github_repo`." }, - "automation_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "The automation that created the run. `null` for runs you start with `POST /v1/runs`, and once the automation is deleted." + "base": { + "type": "string", + "description": "`git_push`, `github_create_pr`: the base branch." }, - "parent_run_id": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "On an `apply` run: the automation run whose proposal it applies." + "branch": { + "type": "string", + "description": "`git_push`: the branch pushed to." }, - "apply_run_ids": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" - }, - "description": "On an automation run: the `apply` runs that applied its actions, in order." + "commit": { + "type": "string", + "description": "`git_push`: the agent's commit." }, - "approval": { - "oneOf": [ - { - "$ref": "#/components/schemas/Approval" - }, - { - "type": "null" - } - ], - "description": "On an automation run that proposed actions: the proposal and where its approval stands. `null` otherwise." + "commit_message": { + "type": "string", + "description": "`git_push`: the message of the pushed commit." + }, + "force": { + "type": "boolean", + "description": "`git_push`: a force push." + }, + "head": { + "type": "string", + "description": "`github_create_pr`: the branch to merge." + }, + "title": { + "type": "string", + "description": "`github_create_pr`: the title." + }, + "draft": { + "type": "boolean", + "description": "`github_create_pr`: open it as a draft." }, - "instructions": { - "type": "string", - "description": "The automation's instructions the run used. Only in `GET /v1/runs/{id}` of an automation run." + "number": { + "type": "integer", + "description": "The pull request or issue number." }, - "input": { - "oneOf": [ - { - "$ref": "#/components/schemas/RunInput" - }, - { - "type": "null" - } + "event": { + "type": "string", + "enum": [ + "COMMENT", + "APPROVE", + "REQUEST_CHANGES" ], - "description": "The event or input that started an automation run. Only in `GET /v1/runs/{id}`." + "description": "`github_pr_review`: the review." }, - "approval_kinds": { - "type": [ - "array", - "null" - ], + "body": { + "type": "string", + "description": "The text of a pull request, comment or review." + }, + "labels": { + "type": "array", "items": { - "type": "string", - "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ] + "type": "string" }, - "description": "On an automation run: the action kinds that needed approval when it started. `null` otherwise." + "description": "`github_add_labels`: up to 10." + } + } + }, + "Diffstat": { + "type": "object", + "description": "The files a push changes.", + "properties": { + "files_changed": { + "type": "integer" }, - "github_repo": { - "type": [ - "string", - "null" - ], - "description": "On an automation run: the repository its actions act on." + "insertions": { + "type": "integer" }, - "trigger_depth": { - "type": "integer", - "description": "0 for a run you start. 1 for a webhook or schedule run, one more for each run caused by another run (loop protection)." + "deletions": { + "type": "integer" }, - "secrets": { + "files": { "type": "array", - "description": "The secrets the run receives: variable names and who gets them, never values.", "items": { "type": "object", "properties": { - "env": { + "path": { "type": "string" }, - "exposure": { - "type": "string", - "enum": [ - "agent", - "actions" - ] + "added": { + "type": "integer" + }, + "deleted": { + "type": "integer" + }, + "binary": { + "type": "boolean" } } } }, - "agent_finished_at": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "When the agent of an automation run finished, before any approval." - }, - "error": { - "type": [ - "object", - "null" - ], - "description": "Why a finished run failed, or `null`: `code` is its `error_code`, and on `GET /v1/runs/{id}` `message` is the text of its last `error` event (masked; render it as plain text; `null` when there is none). On a run on your own provider key, a provider error (a wrong key, an unknown model) shows up here.", - "required": [ - "code", - "message" - ], - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": [ - "string", - "null" - ] - } - } - }, - "checkout": { - "oneOf": [ - { - "$ref": "#/components/schemas/RunCheckout" - }, - { - "type": "null" - } - ], - "description": "On an automation run (`phase: \"propose\"`) whose automation uses `checkout: \"fresh\"`: the commit it works on. `null` for every other run, apply runs included." - }, - "superseded_by": { - "type": [ - "string", - "null" - ], - "format": "uuid", - "description": "When a newer run for the same subject cancelled this one (`concurrency_policy: \"supersede\"`): that run's id. The run ends `cancelled` with `error_code: \"cancelled\"`." - }, - "sticky_pr_comment": { + "truncated": { "type": "boolean", - "description": "On an automation run (`phase: \"propose\"`): whether its pull request comments edit the automation's one comment per pull request (`sticky_pr_comment` of the automation when the run started). `false` on other runs." + "description": "Only the first files are listed." } } }, - "RunList": { + "Proposal": { "type": "object", + "description": "What an automation run proposed: up to 10 actions. Immutable once stored.", "properties": { - "object": { - "type": "string", + "v": { + "type": "integer", "enum": [ - "list" + 1 ] }, - "data": { + "actions": { "type": "array", "items": { - "$ref": "#/components/schemas/Run" - }, - "description": "Newest first. `prompt` is not included: use `prompt_preview`." + "$ref": "#/components/schemas/ProposalAction" + } }, - "next_cursor": { - "type": [ - "string", - "null" - ], - "description": "Pass it as `cursor` for the next page. `null` on the last page." + "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." + }, + "bundle_sha256": { + "type": "string" + }, + "diff_sha256": { + "type": "string", + "description": "With a push: the hash of the diff. What is pushed must match it." + }, + "diffstat": { + "$ref": "#/components/schemas/Diffstat" } } }, - "RunEventDataStarted": { + "Approval": { "type": "object", - "description": "`data` of a `started` event. The run started.", + "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": { - "agent": { + "run_id": { + "type": "string", + "format": "uuid" + }, + "state": { "type": "string", "enum": [ - "pi", - "pi_gentle", - "hermes" - ] + "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)." }, - "agent_version": { - "type": "string" + "approval_from": { + "type": [ + "integer", + "null" + ], + "description": "Index of the first action that needs approval. `null` when none does." }, - "model": { + "expires_at": { "type": [ "string", "null" - ] + ], + "format": "date-time", + "description": "When a pending approval expires." }, - "cwd": { + "proposal": { + "$ref": "#/components/schemas/Proposal" + }, + "proposal_sha256": { "type": "string", - "description": "Where the agent works (inside the worktree when there is one)." + "description": "The hash of the stored proposal. Approving in the portal binds to it." }, - "worktree": { + "diffstat": { + "oneOf": [ + { + "$ref": "#/components/schemas/Diffstat" + }, + { + "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" ], - "description": "The separate worktree, or `null` when the agent works in the folder." + "format": "date-time" }, - "branch": { + "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" ], - "description": "`nan-run/...` when there is a worktree, otherwise `null`." - }, - "unit": { - "type": "string" + "format": "uuid", + "description": "The `apply` run of the approved actions." + } + } + }, + "ApprovalListItem": { + "allOf": [ + { + "$ref": "#/components/schemas/Approval" }, - "checkout": { + { "type": "object", - "description": "Only on runs with a fresh checkout: the commit checked out.", "properties": { - "mode": { + "run_state": { "type": "string", - "enum": [ - "fresh" - ] + "description": "The state of the run." }, - "sha": { + "workspace_id": { "type": "string", - "pattern": "^[0-9a-f]{40}$" + "format": "uuid" + }, + "automation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" } } } - } + ] }, - "RunEventDataLog": { + "ApprovalList": { "type": "object", - "description": "`data` of a `log` event. A line of output that is not a message.", "properties": { - "stream": { + "object": { "type": "string", "enum": [ - "agent_stdout", - "agent_stderr", - "nan-run" + "list" ] }, - "text": { - "type": "string", - "description": "Up to 8 KiB." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApprovalListItem" + }, + "description": "Newest first." + }, + "next_cursor": { + "type": [ + "string", + "null" + ], + "description": "Pass it as `cursor` for the next page. `null` on the last page." } } }, - "RunEventDataMessage": { + "Automation": { "type": "object", - "description": "`data` of a `message` event. Assistant text, sent incrementally: each event carries only its own chunk, and the last chunk of a message has `final: true` (its text may be empty). Concatenate the chunks to rebuild the message.", + "description": "A saved agent task. Created and edited in the portal. Never carries a secret value.", "properties": { - "role": { + "id": { + "type": "string", + "format": "uuid" + }, + "object": { "type": "string", "enum": [ - "assistant" + "automation" ] }, - "text": { + "name": { "type": "string", - "description": "This chunk only, up to 16 KiB." - }, - "final": { - "type": "boolean" - } - } - }, - "RunEventDataToolCall": { - "type": "object", - "description": "`data` of a `tool_call` event. The agent called a tool.", - "properties": { - "id": { - "type": "string" + "description": "Up to 64 characters, unique among yours." }, - "name": { + "description": { "type": "string" }, - "args_preview": { + "template": { "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" + "enum": [ + "custom", + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" + ] }, - "is_error": { - "type": "boolean" + "workspace": { + "type": "string", + "format": "uuid", + "description": "The workspace id." }, - "output_preview": { + "agent": { "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": { + "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" - ] - }, - "prompt_tokens": { - "type": "integer" + ], + "description": "The model of its runs, or `null` for the agent's default. With `byok` inference it is `inference.model` and is never `null`." }, - "completion_tokens": { - "type": "integer" - } - } - }, - "RunEventDataError": { - "type": "object", - "description": "`data` of an `error` event. Something went wrong.", - "properties": { - "code": { + "instructions": { "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" - ] + "description": "Up to 16 KiB. Trusted: the agent follows them." }, - "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": { + "cwd": { + "type": "string" + }, + "git_isolation": { + "type": [ + "boolean", + "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" + }, + "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": [ - "succeeded", - "failed", - "cancelled", - "timed_out" - ] + "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`." }, - "exit_code": { - "type": "integer" + "max_trigger_depth": { + "type": "integer", + "minimum": 0, + "maximum": 5 }, - "error_code": { + "enabled": { + "type": "boolean" + }, + "disabled_reason": { "type": [ "string", "null" ], "enum": [ - "unreachable", - "access_denied", - "admin_unavailable", - "host_key_mismatch", - "workspace_not_running", + "member", "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", + "secret_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`)." + "created_at": { + "type": "string", + "format": "date-time" }, - "ts": { + "updated_at": { "type": "string", "format": "date-time" }, - "type": { + "checkout": { "type": "string", "enum": [ - "started", - "log", - "message", - "tool_call", - "tool_result", - "usage", - "artifact", - "error", - "truncated", - "finished", - "approval_required", - "action" + "workspace", + "fresh" ], - "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." + "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`." }, - "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" - } + "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": "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 ./...\"}" + "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`." } } }, - "RunEventPage": { + "AutomationList": { "type": "object", "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] + }, "data": { "type": "array", "items": { - "$ref": "#/components/schemas/RunEvent" + "$ref": "#/components/schemas/Automation" } - }, - "next_after": { - "type": "integer", - "description": "The last `seq` returned (or your `after` when the page is empty). Pass it as `after` for the next page." - }, - "done": { - "type": "boolean", - "description": "`true` when the run is over and every event has been returned." } } }, - "WorkspaceSlot": { + "AutomationTemplate": { "type": "object", - "description": "The purchased slot paying for the workspace.", + "description": "A template: the defaults the portal fills in when you create an automation from it.", "properties": { "id": { - "type": "integer" + "type": "string", + "enum": [ + "pr_review", + "issue_triage", + "ci_failure", + "generic_webhook" + ] }, - "tier": { + "name": { "type": "string" }, - "status": { + "description": { + "type": "string" + }, + "instructions": { "type": "string" }, - "cancel_at_period_end": { + "approval_kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + } + }, + "trigger_kind": { + "type": "string", + "enum": [ + "webhook", + "schedule" + ] + }, + "signature_scheme": { + "type": [ + "string", + "null" + ], + "enum": [ + "github", + "stripe", + "nan", + null + ] + }, + "event_filter": { + "oneOf": [ + { + "$ref": "#/components/schemas/EventFilter" + }, + { + "type": "null" + } + ] + }, + "secret_slots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "env": { + "type": "string" + }, + "exposure": { + "type": "string", + "enum": [ + "agent", + "actions" + ] + }, + "optional": { + "type": "boolean" + }, + "help": { + "type": "string" + } + } + } + }, + "requires_github_repo": { "type": "boolean" }, - "current_period_end": { - "type": [ - "string", - "null" - ], - "format": "date-time" + "suggested_concurrency": { + "type": "string", + "enum": [ + "queue", + "skip_if_active" + ] } } }, - "Workspace": { + "EventFilter": { "type": "object", - "description": "One of your workspaces. Never carries keys, environment values or agent configuration.", + "description": "Which webhook deliveries start a run. Every list you fill must match; empty lists match everything.", "properties": { - "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": { + "events": { "type": "array", "items": { "type": "string" }, - "description": "Agents and tools chosen for the workspace." + "description": "Event names (GitHub `X-GitHub-Event`, Stripe event type, NaN `X-Nan-Event`). An entry ending in `.*` matches a prefix." }, - "run_agents": { + "actions": { "type": "array", "items": { - "type": "string", - "enum": [ - "pi", - "pi_gentle", - "hermes" - ] + "type": "string" }, - "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." + "description": "GitHub `action`." }, - "status": { - "type": "string", - "enum": [ - "provisioning", - "running", - "stopped", - "crashed", - "unknown" - ], - "description": "Runs can only be created while it is `running`." + "exclude_drafts": { + "type": "boolean", + "description": "Ignore draft pull requests." }, - "ssh_command": { + "conclusions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "GitHub `workflow_run` conclusion." + } + } + }, + "Trigger": { + "type": "object", + "description": "What starts an automation: a webhook or a schedule. The webhook URL and its signing secret are returned only once, in the answer to creating or rotating the trigger, and never afterwards.", + "properties": { + "id": { "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" + "format": "uuid" }, - "tier": { + "object": { "type": "string", "enum": [ - "free", - "micro", - "nano", - "basic", - "medium", - "large" - ], - "description": "Size. `free` is the Micro-sized workspace included with Premium." + "trigger" + ] }, - "created_at": { + "automation_id": { "type": "string", - "format": "date-time" + "format": "uuid" }, - "updated_at": { + "kind": { "type": "string", - "format": "date-time" + "enum": [ + "webhook", + "schedule" + ] }, - "state_reason": { + "enabled": { + "type": "boolean", + "description": "`false`: paused." + }, + "token_prefix": { "type": [ "string", "null" ], - "enum": [ - "dunning", - "canceled", - "disputed", - "churn", - "banned", - "pending_capacity", - "rescue", - null - ], - "description": "Why the workspace is not running normally, or `null`." + "description": "Webhook: the start of the URL token, to recognise it." }, - "grace_until": { + "signature_scheme": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When an open grace period ends (the data is deleted then), or `null`." + "enum": [ + "github", + "stripe", + "nan", + null + ] }, - "slot": { + "event_filter": { "oneOf": [ { - "$ref": "#/components/schemas/WorkspaceSlot" + "$ref": "#/components/schemas/EventFilter" }, { "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": { + "actor_filter": { "type": [ "object", "null" ], - "description": "The backup add-on.", + "description": "GitHub only. `null`: every sender.", "properties": { - "status": { - "type": "string", - "enum": [ - "off", - "pending_payment", - "active", - "disabling", - "disabled", - "failed" - ] + "logins": { + "type": "array", + "items": { + "type": "string" + } }, - "stale": { - "type": "boolean", - "description": "`true` when the last snapshot is older than expected." - } - } - } - } - }, - "WorkspaceDetail": { - "allOf": [ - { - "$ref": "#/components/schemas/Workspace" - }, - { - "type": "object", - "properties": { - "env_var_keys": { + "author_associations": { "type": "array", "items": { "type": "string" - }, - "description": "Names of the variables in the workspace's environment file. Never the values." + } + }, + "exclude_bots": { + "type": "boolean" } } - } - ] - }, - "RunInput": { - "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 + }, + "prev_secret_until": { + "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" ] }, - "source": { - "type": "string", + "next_fire_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_fired_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "last_outcome": { + "type": [ + "string", + "null" + ], "enum": [ - "api", - "webhook:github", - "webhook:stripe", - "webhook:nan", - "schedule" + "run_created", + "skipped_active", + "not_accepted", + "missed", + "rate_limited", + "queue_full", + "automation_disabled", + "workspace_deleted", + "error", + null ] }, - "event": { + "created_at": { "type": "string", - "description": "The event name, for a webhook." + "format": "date-time" }, - "delivery": { + "updated_at": { "type": "string", - "description": "The delivery id, for a webhook." + "format": "date-time" }, - "payload": { - "type": "object", - "additionalProperties": true, - "description": "The input itself: a JSON object." + "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`." } } }, - "ProposalAction": { + "TriggerList": { "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" - ], "properties": { - "kind": { + "object": { "type": "string", "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" + "list" ] }, - "repo": { - "type": "string", - "description": "`owner/name`: always the automation's `github_repo`." - }, - "base": { - "type": "string", - "description": "`git_push`, `github_create_pr`: the base branch." - }, - "branch": { - "type": "string", - "description": "`git_push`: the branch pushed to." - }, - "commit": { - "type": "string", - "description": "`git_push`: the agent's commit." - }, - "commit_message": { + "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", - "description": "`git_push`: the message of the pushed commit." - }, - "force": { - "type": "boolean", - "description": "`git_push`: a force push." + "format": "uuid" }, - "head": { + "delivery_id": { "type": "string", - "description": "`github_create_pr`: the branch to merge." + "description": "The sender's delivery id (GitHub `X-GitHub-Delivery`, Stripe event id, NaN `X-Nan-Delivery`)." }, - "title": { + "received_at": { "type": "string", - "description": "`github_create_pr`: the title." - }, - "draft": { - "type": "boolean", - "description": "`github_create_pr`: open it as a draft." - }, - "number": { - "type": "integer", - "description": "The pull request or issue number." + "format": "date-time" }, - "event": { + "outcome": { "type": "string", "enum": [ - "COMMENT", - "APPROVE", - "REQUEST_CHANGES" - ], - "description": "`github_pr_review`: the review." + "run_created", + "ignored", + "loop_suppressed", + "skipped_active", + "not_accepted", + "too_large" + ] }, - "body": { + "run_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "WebhookDeliveryList": { + "type": "object", + "properties": { + "object": { "type": "string", - "description": "The text of a pull request, comment or review." + "enum": [ + "list" + ] }, - "labels": { + "data": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/WebhookDelivery" }, - "description": "`github_add_labels`: up to 10." + "description": "Newest first." } } }, - "Diffstat": { + "RunAutomationRequest": { "type": "object", - "description": "The files a push changes.", + "additionalProperties": false, "properties": { - "files_changed": { - "type": "integer" + "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" }, - "insertions": { + "kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] + }, + "description": "The kinds of the actions that wait for approval." + } + } + }, + "RunEventDataAction": { + "type": "object", + "description": "`data` of an `action` event: one step of an `apply` run.", + "required": [ + "index", + "kind", + "status", + "message" + ], + "additionalProperties": false, + "properties": { + "index": { "type": "integer" }, - "deletions": { - "type": "integer" + "kind": { + "type": "string", + "enum": [ + "git_push", + "github_create_pr", + "github_pr_comment", + "github_pr_review", + "github_issue_comment", + "github_add_labels" + ] }, - "files": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "added": { - "type": "integer" - }, - "deleted": { - "type": "integer" - }, - "binary": { - "type": "boolean" - } - } - } + "status": { + "type": "string", + "enum": [ + "started", + "ok", + "failed" + ] }, - "truncated": { - "type": "boolean", - "description": "Only the first files are listed." + "message": { + "type": "string", + "description": "Plain text, masked." } } }, - "Proposal": { + "RunCheckout": { "type": "object", - "description": "What an automation run proposed: up to 10 actions. Immutable once stored.", + "description": "The fresh checkout of a run whose automation uses `checkout: \"fresh\"`.", "properties": { - "v": { - "type": "integer", + "mode": { + "type": "string", "enum": [ - 1 + "fresh" ] }, - "actions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProposalAction" - } + "sha": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{40}$", + "description": "The commit checked out. `null` until the run has resolved the default branch." }, - "base": { - "type": "string", - "description": "With a push: the base branch." + "pr_number": { + "type": [ + "integer", + "null" + ], + "description": "The pull request, when the event was about one." }, - "merge_base": { + "base_ref": { + "type": [ + "string", + "null" + ], + "description": "The pull request's base branch, when there is one." + }, + "label": { "type": "string", - "description": "With a push: the commit the pushed commit is built on." + "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": [ + "nan", + "byok" + ] }, - "bundle_sha256": { - "type": "string" + "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)." }, - "diff_sha256": { + "secret": { "type": "string", - "description": "With a push: the hash of the diff. What is pushed must match it." + "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." }, - "diffstat": { - "$ref": "#/components/schemas/Diffstat" + "model": { + "type": "string", + "description": "Only with `byok`: the model id, as your provider names it." } } }, - "Approval": { + "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" + ] + } + } + }, + { + "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" + } + } + } + ] + }, + "Secret": { "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.", + "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/automations#secrets).", "properties": { - "run_id": { + "id": { "type": "string", "format": "uuid" }, - "state": { + "object": { "type": "string", "enum": [ - "auto", - "pending", - "approved", - "rejected", - "expired", - "cancelled" + "secret" + ] + }, + "name": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$", + "description": "Unique among your secrets. The variable name automations see by default." + }, + "kind": { + "type": "string", + "enum": [ + "generic", + "provider_key" ], - "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)." + "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))." }, - "approval_from": { + "provider": { "type": [ - "integer", + "string", "null" ], - "description": "Index of the first action that needs approval. `null` when none does." + "enum": [ + "openai_compatible", + "anthropic", + "openai", + "gemini", + null + ], + "description": "A provider key's provider; `null` for a generic secret." }, - "expires_at": { + "base_url": { "type": [ "string", "null" ], - "format": "date-time", - "description": "When a pending approval expires." + "description": "An `openai_compatible` provider key's base URL; `null` otherwise. It is not a secret: it is shown as it is." }, - "proposal": { - "$ref": "#/components/schemas/Proposal" + "hint": { + "type": [ + "string", + "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." }, - "proposal_sha256": { + "value_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 16384, + "description": "The size of the value, in bytes." + }, + "description": { "type": "string", - "description": "The hash of the stored proposal. Approving in the portal binds to it." + "description": "Up to 256 characters; `\"\"` when there is none." }, - "diffstat": { - "oneOf": [ - { - "$ref": "#/components/schemas/Diffstat" - }, - { - "type": "null" + "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" + } } - ] - }, - "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." + "created_at": { + "type": "string", + "format": "date-time" }, - "decided_at": { - "type": [ - "string", - "null" - ], + "updated_at": { + "type": "string", "format": "date-time" }, - "prefix_apply_run_id": { + "last_used_at": { "type": [ "string", "null" ], - "format": "uuid", - "description": "The `apply` run of the actions before `approval_from`." + "format": "date-time", + "description": "When a run last received it (updated at most once a minute)." }, - "apply_run_id": { - "type": [ - "string", - "null" + "updated_by": { + "type": "string", + "enum": [ + "browser_session", + "cli_session", + "platform_token" ], - "format": "uuid", - "description": "The `apply` run of the approved actions." + "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" } }, - "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": { + "SecretList": { "type": "object", + "additionalProperties": false, "properties": { "object": { "type": "string", @@ -6891,39 +10445,158 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ApprovalListItem" - }, - "description": "Newest first." + "$ref": "#/components/schemas/Secret" + } + } + } + }, + "CreateSecretRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "value" + ], + "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`)." }, - "next_cursor": { + "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." + }, + "provider": { "type": [ "string", "null" ], - "description": "Pass it as `cursor` for the next page. `null` on the last page." + "enum": [ + "openai_compatible", + "anthropic", + "openai", + "gemini", + null + ], + "description": "Required for a `provider_key`, `null` or absent for a `generic` secret. Fixed once created." + }, + "base_url": { + "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." } } }, - "Automation": { + "UpdateSecretRequest": { "type": "object", - "description": "A saved agent task. Created and edited in the portal. Never carries a secret value.", + "additionalProperties": false, + "minProperties": 1, + "description": "At least one field. The whole change applies, or none of it.", "properties": { - "id": { + "description": { "type": "string", - "format": "uuid" + "description": "Up to 256 characters without control characters. `\"\"` clears it." }, - "object": { + "base_url": { "type": "string", - "enum": [ - "automation" - ] - }, + "description": "Only for an `openai_compatible` provider key (`400` on any other secret), with the rules of create. `null` is refused." + } + } + }, + "ReplaceSecretValueRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "value" + ], + "properties": { + "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", + "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" + } + } + } + } + } + } + } + }, + "CreateAutomationRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "workspace", + "agent" + ], + "description": "The automation to create. Fields left out take their defaults. Unknown fields are refused (`400`).", + "properties": { "name": { "type": "string", - "description": "Up to 64 characters, unique among yours." + "description": "1 to 64 characters, unique among your automations regardless of case (`409 name_taken`)." }, "description": { - "type": "string" + "type": "string", + "description": "Optional, up to 1024 characters." }, "template": { "type": "string", @@ -6933,12 +10606,12 @@ "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", - "format": "uuid", - "description": "The workspace id." + "description": "The workspace, by id or by name." }, "agent": { "type": "string", @@ -6958,10 +10631,11 @@ }, "instructions": { "type": "string", - "description": "Up to 16 KiB. Trusted: the agent follows them." + "description": "What the agent does on every run, 1 to 16384 bytes. Optional with a template, which brings its own." }, "cwd": { - "type": "string" + "type": "string", + "description": "The folder the agent works in: `/home/nan` (the default) or a folder inside it." }, "git_isolation": { "type": [ @@ -6973,7 +10647,8 @@ "timeout_seconds": { "type": "integer", "minimum": 60, - "maximum": 7200 + "maximum": 7200, + "description": "Default 1800." }, "approval_kinds": { "type": "array", @@ -6988,16 +10663,21 @@ "github_add_labels" ] }, - "description": "The action kinds that wait for your approval. Empty: every action applies automatically." + "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. 259200 (72 hours) by default." + "description": "How long a proposal waits for you. Default 259200 (72 hours)." }, "inference": { - "$ref": "#/components/schemas/RunInference" + "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": [ @@ -7008,9 +10688,15 @@ }, "secrets": { "type": "array", - "description": "The secrets its runs receive: names, variable names and who gets them. Never values.", + "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", @@ -7034,7 +10720,8 @@ "max_runs_per_hour": { "type": "integer", "minimum": 1, - "maximum": 120 + "maximum": 120, + "description": "Default 30." }, "concurrency_policy": { "type": "string", @@ -7048,30 +10735,12 @@ "max_trigger_depth": { "type": "integer", "minimum": 0, - "maximum": 5 + "maximum": 5, + "description": "Default 2." }, "enabled": { - "type": "boolean" - }, - "disabled_reason": { - "type": [ - "string", - "null" - ], - "enum": [ - "member", - "workspace_deleted", - "secret_deleted", - null - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" + "type": "boolean", + "description": "Default `true`. `false` creates it switched off." }, "checkout": { "type": "string", @@ -7094,44 +10763,58 @@ } } }, - "AutomationList": { + "UpdateAutomationRequest": { "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": { - "object": { + "name": { "type": "string", - "enum": [ - "list" - ] + "description": "1 to 64 characters, unique among your automations regardless of case." }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Automation" - } - } - } - }, - "AutomationTemplate": { - "type": "object", - "description": "A template: the defaults the portal fills in when you create an automation from it.", - "properties": { - "id": { + "description": { "type": "string", - "enum": [ - "pr_review", - "issue_triage", - "ci_failure", - "generic_webhook" - ] + "description": "Optional, up to 1024 characters." }, - "name": { - "type": "string" + "workspace": { + "type": "string", + "description": "The workspace, by id or by name." }, - "description": { - "type": "string" + "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" + "type": "string", + "description": "1 to 16384 bytes." + }, + "cwd": { + "type": "string", + "description": "`/home/nan` 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 }, "approval_kinds": { "type": "array", @@ -7145,122 +10828,112 @@ "github_issue_comment", "github_add_labels" ] - } - }, - "trigger_kind": { - "type": "string", - "enum": [ - "webhook", - "schedule" - ] + }, + "description": "Replaces the list. `[]`: nothing waits for your approval, every action applies without asking." }, - "signature_scheme": { - "type": [ - "string", - "null" - ], - "enum": [ - "github", - "stripe", - "nan", - null - ] + "approval_ttl_seconds": { + "type": "integer", + "minimum": 3600, + "maximum": 604800 }, - "event_filter": { + "inference": { "oneOf": [ { - "$ref": "#/components/schemas/EventFilter" - }, - { - "type": "null" + "$ref": "#/components/schemas/CreateRunInference" } - ] + ], + "description": "Replaces the inference. `null` is refused." + }, + "github_repo": { + "type": [ + "string", + "null" + ], + "description": "`owner/name`: the repository the actions act on." }, - "secret_slots": { + "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" + "type": "string", + "description": "The variable name the run sees." }, "exposure": { "type": "string", "enum": [ "agent", "actions" - ] - }, - "optional": { - "type": "boolean" - }, - "help": { - "type": "string" + ], + "description": "`agent`: the agent receives it. `actions`: only the step that applies actions." } } } }, - "requires_github_repo": { - "type": "boolean" + "max_runs_per_hour": { + "type": "integer", + "minimum": 1, + "maximum": 120 }, - "suggested_concurrency": { + "concurrency_policy": { "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." + "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`." }, - "actions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "GitHub `action`." + "max_trigger_depth": { + "type": "integer", + "minimum": 0, + "maximum": 5 }, - "exclude_drafts": { + "enabled": { "type": "boolean", - "description": "Ignore draft pull requests." + "description": "`false` switches it off (its triggers stop starting runs), `true` switches it back on." }, - "conclusions": { - "type": "array", - "items": { - "type": "string" - }, - "description": "GitHub `workflow_run` conclusion." + "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." } } }, - "Trigger": { + "CreateTriggerRequest": { "type": "object", - "description": "What starts an automation: a webhook or a schedule. The webhook URL and its signing secret are never returned after they are created or rotated.", + "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": { - "id": { - "type": "string", - "format": "uuid" - }, - "object": { - "type": "string", - "enum": [ - "trigger" - ] - }, - "automation_id": { - "type": "string", - "format": "uuid" - }, "kind": { "type": "string", "enum": [ @@ -7268,28 +10941,21 @@ "schedule" ] }, - "enabled": { - "type": "boolean", - "description": "`false`: paused." - }, - "token_prefix": { - "type": [ - "string", - "null" + "signature_scheme": { + "type": "string", + "enum": [ + "github", + "stripe", + "nan" ], - "description": "Webhook: the start of the URL token, to recognise it." + "description": "Webhook: how the sender signs. See [Webhooks and schedules](https://nan.builders/docs/webhooks)." }, - "signature_scheme": { + "signing_secret": { "type": [ "string", "null" ], - "enum": [ - "github", - "stripe", - "nan", - 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." }, "event_filter": { "oneOf": [ @@ -7297,370 +10963,171 @@ "$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" - } - } - }, - "prev_secret_until": { - "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" - ] - }, - "next_fire_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "last_fired_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "last_outcome": { - "type": [ - "string", - "null" - ], - "enum": [ - "run_created", - "skipped_active", - "not_accepted", - "missed", - "rate_limited", - "queue_full", - "automation_disabled", - "workspace_deleted", - "error", - null - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "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": [ - "run_created", - "ignored", - "loop_suppressed", - "skipped_active", - "not_accepted", - "too_large" + "type": "null" + } ] }, - "run_id": { + "actor_filter": { "type": [ - "string", + "object", "null" ], - "format": "uuid" - } - } - }, - "WebhookDeliveryList": { - "type": "object", - "properties": { - "object": { + "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", - "enum": [ - "list" - ] + "description": "Schedule: a five-field cron expression, for example `0 9 * * 1-5`." }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookDelivery" - }, - "description": "Newest first." + "timezone": { + "type": "string", + "description": "Schedule: an IANA time zone, for example `Europe/Madrid`." + }, + "enabled": { + "type": "boolean", + "description": "Default `true`." } } }, - "RunAutomationRequest": { + "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": { - "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." + "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" + } + } + }, + "cron": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "description": "`false` pauses it, `true` resumes it." } } }, - "RunEventDataApprovalRequired": { + "RotateTriggerRequest": { "type": "object", - "description": "`data` of an `approval_required` event. The proposal needs your approval.", + "additionalProperties": false, "required": [ - "proposal_sha256", - "kinds" + "rotate" ], - "additionalProperties": false, + "description": "Webhooks only.", "properties": { - "proposal_sha256": { - "type": "string" - }, - "kinds": { + "rotate": { "type": "array", + "minItems": 1, "items": { "type": "string", "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" + "url", + "secret" ] }, - "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": "`url`: a new webhook URL; the current one stops working at once. `secret`: a new signing secret." }, - "kind": { - "type": "string", - "enum": [ - "git_push", - "github_create_pr", - "github_pr_comment", - "github_pr_review", - "github_issue_comment", - "github_add_labels" - ] - }, - "status": { - "type": "string", - "enum": [ - "started", - "ok", - "failed" - ] + "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)." }, - "message": { - "type": "string", - "description": "Plain text, masked." + "signing_secret": { + "type": [ + "string", + "null" + ], + "description": "Only for `stripe`, with `secret`: the new `whsec_...` you rolled in Stripe." } } }, - "RunCheckout": { + "TriggerCreated": { "type": "object", - "description": "The fresh checkout of a run whose automation uses `checkout: \"fresh\"`.", + "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": { - "mode": { - "type": "string", - "enum": [ - "fresh" - ] + "trigger": { + "$ref": "#/components/schemas/Trigger" }, - "sha": { + "webhook_url": { "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." + "description": "Webhook: the full URL, `https://api.nan.builders/hooks/nan_whk_...`. `null` for a schedule." }, - "base_ref": { + "signing_secret": { "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`." + "description": "Webhook with `github` or `nan`: the signing secret NaN generated. `null` for a schedule, and for `stripe` (you pasted it)." } } }, - "RunInference": { + "TriggerRotated": { "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, + "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": { - "source": { - "type": "string", - "enum": [ - "nan", - "byok" - ] - }, - "provider": { + "webhook_url": { "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." + "description": "The new full webhook URL." }, - "model": { + "signing_secret": { "type": "string", - "description": "Only with `byok`: the model id, as your provider names it." + "description": "The new signing secret." } } - }, - "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; the secrets API is portal-only) 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" - ] - } - } - }, - { - "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" - } - } - } - ] } }, "responses": { From 232efd6fdf230126f3347a4a79cc222e11c93d3c Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 16:00:48 +0200 Subject: [PATCH 3/7] docs(automations): manage secrets, automations and triggers from the CLI and the API (EN, ES) The write scopes and their warning, one-time webhook URL and signing secret, the rollout answers, rate limits and updated_by. Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 140 ++++++++++++++++++++-- src/content/docs-es/nan-cli.mdx | 20 +++- src/content/docs-es/runs.mdx | 4 +- src/content/docs-es/webhooks.mdx | 6 +- src/content/docs/automations.mdx | 140 ++++++++++++++++++++-- src/content/docs/nan-cli.mdx | 20 +++- src/content/docs/runs.mdx | 4 +- src/content/docs/webhooks.mdx | 6 +- src/tests/layouts/automationsDocs.test.ts | 69 +++++++++-- 9 files changed, 371 insertions(+), 38 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index 59dd5ba5..145133ef 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -25,7 +25,7 @@ El agente de una automatización no hace push ni publica nada por su cuenta. **P - **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). - **Un plan con inferencia, o tu propia clave de proveedor.** Con la inferencia de NaN, los runs de una automatización usan la clave de inferencia de tu workspace y cuentan para el uso de tu plan como cualquier otro run. Con [tu propia clave de proveedor](#usa-tu-propia-clave-de-proveedor-byok), los factura tu proveedor y basta con un workspace propio activo. -- **Una sesión en el navegador.** Crear o cambiar automatizaciones, escribir secretos y aprobar acciones exige que hayas iniciado sesión en [cloud.nan.builders](https://cloud.nan.builders/) desde tu navegador, con el enlace por correo. Una sesión de la CLI, una API key o un token pueden listar automatizaciones y lanzarlas, pero nunca cambiarlas. Consulta [Por qué algunas acciones necesitan el navegador](#por-qué-algunas-acciones-necesitan-el-navegador). +- **Dónde hacer los cambios.** Crea y cambia automatizaciones y secretos en el portal, en [cloud.nan.builders](https://cloud.nan.builders/), con sesión iniciada mediante el enlace por correo. Si tu cuenta tiene la API de escritura (se está abriendo poco a poco), también puedes hacerlo desde la CLI y la API: consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). Aprobar acciones siempre necesita el portal. Una API key nunca cambia automatizaciones ni secretos. Consulta [Por qué algunas acciones necesitan el navegador](#por-qué-algunas-acciones-necesitan-el-navegador). - **Para las plantillas de GitHub, un token de GitHub guardado como secreto.** Sin él la automatización no se puede guardar, así que créalo antes: consulta [Un token de GitHub para las acciones](#un-token-de-github-para-las-acciones). ## Crea una automatización @@ -104,6 +104,8 @@ Los secretos son de **solo escritura**: una vez guardados, nadie puede volver a Puedes guardar hasta 100 secretos. Mejor tokens de vida corta, limitados a lo que necesita la automatización. +Cada secreto muestra desde dónde se cambió por última vez (**Last changed from**: **Browser**, **CLI** o **API token**), así que un cambio que no hiciste tú salta a la vista. Desde la CLI: `nan secrets set`, `nan secrets edit` y `nan secrets rm`, en [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). + ### Asigna secretos a una automatización En la tarjeta **Secrets** de la automatización, **Add a secret** y elige, para cada uno: @@ -146,7 +148,7 @@ Una automatización puede usar **tu propia clave** con un proveedor de IA en lug En los ajustes de la automatización, pon **Inference** en **My own key**, elige la **Provider key** y escribe el **Model**, tal como lo llama tu proveedor. A partir de ahí todos los runs de la automatización usan esa clave y ese modelo, los lance quien los lance: un webhook, una programación, **Run now**, la CLI o la API. Si vuelves a poner **Inference** en **NaN (included in your plan)**, vuelve a la inferencia de NaN, con el modelo de NaN que elijas o, si no eliges ninguno, el predeterminado del agente. -- **Quién puede usarla.** Sin un plan con inferencia de NaN, una automatización con tu propia clave funciona con un workspace propio activo. Crearla y cambiarla necesitan el navegador, como cualquier automatización. +- **Quién puede usarla.** Sin un plan con inferencia de NaN, una automatización con tu propia clave funciona con un workspace propio activo. Crearla y cambiarla funciona como en cualquier automatización: el portal, o la CLI y la API si tu cuenta tiene la API de escritura. - **Cualquier credencial puede lanzarla.** A diferencia de [un run suelto con tu clave](/es/docs/runs#quién-puede-usarla), una automatización con tu clave se puede lanzar con una API key o con un token de plataforma con `automations:run`, por ejemplo desde CI: sus instrucciones son las que tú guardaste, y quien la lanza solo añade la entrada. - **Facturación.** Sus runs los factura tu proveedor; NaN no los mide. La clave cuenta para los 48 KiB de secretos que puede recibir un run. - **Límites.** Hasta 8 runs con tus propias claves a la vez entre todos tus workspaces, además de los límites de todos los runs. Consulta [Límites](/es/docs/runs#límites). @@ -351,7 +353,7 @@ nan secrets ls # nombres y metadatos, nunca valores `nan run`, `nan runs logs -f` y `nan automations run` salen con código **10** cuando el run se para esperando aprobación, e imprimen los comandos para leer y rechazar la propuesta y el enlace para aprobarla. -No hay ningún comando para crear o cambiar automatizaciones, escribir secretos o aprobar: la CLI te dice dónde hacerlo en el portal. `nan secrets ls` necesita tu sesión de `nan auth login`; el resto de comandos también aceptan un token de plataforma, igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). +Para crear y cambiar automatizaciones, triggers y secretos desde la terminal, consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). No hay ningún comando para aprobar: la CLI te dice dónde hacerlo en el portal. Los comandos aceptan tu sesión de `nan auth login` o un token de plataforma, igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). `nan secrets` acepta tu sesión o, si tu cuenta tiene la API de escritura, un token con `secrets:read` (y `secrets:write` para cambiarlos). ### Desde la API @@ -366,6 +368,113 @@ curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ La respuesta es el run, que sigues como cualquier otro ([Crea un run y síguelo](/es/docs/runs#crea-un-run-y-síguelo)). La referencia completa está en [Referencia de la API → Automations](/es/docs/api#tag/automations) y [Referencia de la API → Approvals](/es/docs/api#tag/approvals). +## Gestiónalas desde la CLI o la API + +Si tu cuenta tiene la **API de escritura**, puedes crear y cambiar automatizaciones, sus triggers y tus secretos desde la [CLI de NaN](/es/docs/nan-cli) (**0.1.33** o posterior) y la API, no solo desde el portal. Se está abriendo poco a poco: mientras tu cuenta no la tenga, la CLI te dice que uses el portal (código de salida 64), las escrituras de automatizaciones responden `403 browser_session_required` y `/v1/secrets` responde `404`. Aprobar una propuesta sigue siendo siempre cosa del portal. + +Qué puede hacer estos cambios: + +- **Tu sesión de la CLI**, la de `nan auth login` en tu propia máquina. +- **Un token de plataforma** con el scope `automations:write` o `secrets:write`, para scripts y CI. Consulta [Tokens para automatizaciones](#tokens-para-automatizaciones). +- Nunca una API key (`sk-...`), la clave de dentro de un workspace ni una sesión de una CLI antigua: reciben `403 credential_not_allowed` (`workspace_key_not_allowed` en el caso de la clave del workspace). + + +Quien tenga una credencial que pueda escribir puede reemplazar cualquiera de tus secretos (por ejemplo, cambiar el token de GitHub con el que hace push una automatización) o crear una automatización que reciba todos tus secretos, con sus instrucciones y sin nada esperando tu aprobación: eso equivale a leer todos tus secretos. Así que: + +- Ejecuta `nan auth login` en tu propia máquina, **nunca dentro de un workspace**: los agentes que se ejecutan ahí pueden leer la sesión que guarda. +- Para un agente, un script o CI, usa un token solo con los scopes que necesita su trabajo y con caducidad corta, y revócalo cuando acabes. + + +Cada secreto, automatización y trigger registra de dónde vino su último cambio (`updated_by`: `browser_session`, `cli_session` o `platform_token`); el portal lo muestra como **Last changed from**. + +### Secretos desde la CLI + +```bash +nan secrets ls +nan secrets show GITHUB_TOKEN # metadatos, nunca el valor +nan secrets set GITHUB_WRITE_TOKEN --value-file ./token.txt --description "repo acme/api, write" +gh auth token | nan secrets set GITHUB_READ_TOKEN --value-stdin +nan secrets set TOGETHER_KEY --value-file key.txt --kind provider_key \ + --provider openai_compatible --base-url https://api.together.xyz/v1 +nan secrets edit GITHUB_WRITE_TOKEN --description "expires in December" +nan secrets rm GITHUB_READ_TOKEN +``` + +- **El valor nunca va en la línea de comandos**, donde se quedaría en el historial de tu shell y otros programas podrían verlo: `--value-file PATH` lo lee de un archivo y `--value-stdin` de una tubería. `--value-stdin` se niega a leer de la terminal, así que nunca se teclea un valor a la vista. +- Se quita un salto de línea al final (los archivos suelen acabar en uno). Aparte de eso, el valor no puede empezar ni terminar con un salto de línea. +- `set` crea el secreto o **reemplaza el valor** del que tenga ese nombre. El tipo, el proveedor y el nombre quedan fijos al crearlo. +- `edit` cambia la descripción, o la base URL de una provider key compatible con OpenAI. +- `rm` pregunta antes de borrar; `--yes` se salta la pregunta en scripts. Se rechaza mientras una automatización use el secreto, y la CLI te dice cuáles. +- `--json` imprime los metadatos en JSON. + +### Automatizaciones desde la CLI + +```bash +nan automations create -f pr-review.yaml +nan automations edit "PR review api" -f changes.yaml +nan automations disable "PR review api" +nan automations enable "PR review api" +nan automations rm "PR review api" +``` + +El archivo es YAML o JSON con los campos de la API (`-f -` lo lee de stdin): los mismos ajustes que el formulario del portal, con sus nombres en la API. Por ejemplo: + +```yaml +name: PR review api +template: pr_review +workspace: dev +agent: pi +github_repo: acme/api +secrets: + - { secret: GITHUB_READ_TOKEN, env: GITHUB_TOKEN, exposure: agent } + - { secret: GITHUB_WRITE_TOKEN, env: GITHUB_TOKEN, exposure: actions } +approval_kinds: [git_push, github_create_pr] +``` + +Los campos que no pongas toman su valor por defecto; con una plantilla, sus instrucciones y su configuración de aprobación. Sin `approval_kinds`, todos los tipos de acción esperan tu aprobación; con `[]`, ninguno. `edit` cambia solo los campos del archivo, y `secrets` ahí reemplaza todas las asignaciones. La lista completa de campos está en [Referencia de la API → Create an automation](/es/docs/api#tag/automations/POST/automations). + +### Triggers desde la CLI + +```bash +nan automations triggers ls "PR review api" +nan automations triggers add "PR review api" -f github-webhook.yaml +nan automations triggers edit "PR review api" -f filter.yaml +nan automations triggers rotate "PR review api" +nan automations triggers rm "PR review api" +``` + +```yaml +# github-webhook.yaml +kind: webhook +signature_scheme: github +event_filter: + events: [pull_request] + actions: [opened, synchronize, reopened, ready_for_review] + exclude_drafts: true +``` + +`add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). + +### A través de la API + +Las mismas operaciones están en la API, en `/v1/secrets` y `/v1/automations`, con un token que tenga el scope (o una sesión de la CLI): + +```bash +jq -n --rawfile v token.txt '{name: "GITHUB_WRITE_TOKEN", value: ($v | rtrimstr("\n"))}' | + curl https://api.nan.builders/v1/secrets \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: create-github-write-token-1" \ + --data-binary @- +``` + +- Ninguna ruta devuelve nunca el valor. Leer da solo metadatos. +- `Idempotency-Key` hace seguro repetir una creación: la misma clave devuelve el secreto ya creado (`200`). Sin ella, un reintento recibe `409 name_taken`. +- Crear un trigger devuelve la URL del webhook y el secreto de firma una sola vez, con `Cache-Control: no-store`; rotarlo, igual. Crear un trigger no es idempotente: si se pierde la respuesta, lista los triggers y rota o borra el que no llegaste a ver. +- Demasiados cambios reciben `429 rate_limited`: consulta [Límites](#límites). + +Todas las rutas, cuerpos y errores están en [Referencia de la API → Secrets](/es/docs/api#tag/secrets) y [Referencia de la API → Automations](/es/docs/api#tag/automations). + ## Tokens para automatizaciones Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crearlo, en **Settings → Tokens**: @@ -376,8 +485,15 @@ Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crear | `workspaces:read` | Listar tus workspaces y leer uno por su id. | | `automations:read` | Leer tus automatizaciones, plantillas, triggers y entregas. Nunca los valores de los secretos. | | `automations:run` | Lanzar una automatización existente, por ejemplo desde CI. No puede crear ni cambiar automatizaciones, ni aprobar. | +| `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.** | + +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. 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 `automations:read` o `automations:run` solo se puede crear desde una sesión en el navegador. Un token sin el scope que necesita un endpoint recibe `403 token_scope_denied`. + +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. + Para CI, dale al token `automations:run` y `runs` (para seguir el run y conocer su resultado), y además `automations:read` si llamas a la automatización por su nombre. Por ejemplo: `nan automations run ` en un paso con `NAN_TOKEN` definido, como en [Usa la CLI en CI o en un servidor](/es/docs/runs#usa-la-cli-en-ci-o-en-un-servidor). @@ -393,23 +509,25 @@ Para CI, dale al token `automations:run` y `runs` (para seguir el run y conocer | Secretos | 100 por miembro, 20 por automatización, 48 KiB por run | | Instrucciones | 16 KiB | | Input de un run | Un objeto JSON de hasta 48 KiB, con 8 niveles de anidamiento como mucho | +| Cambios en secretos | 30 seguidos, luego 120 por hora, sumando el portal, la CLI y la API | +| Cambios en automatizaciones y triggers | 30 seguidos, luego 300 por hora | Los runs de las automatizaciones comparten los límites de todos los runs: cuántos trabajan a la vez en cada workspace, como mucho 5 a la vez entre todos tus workspaces (8 con Premium), y la cola. Consulta [Cuántos runs a la vez](/es/docs/runs#cuántos-runs-a-la-vez). Las automatizaciones con la inferencia de NaN necesitan un plan con inferencia: sin él, el portal te ofrece **My own key** (consulta [Usa tu propia clave de proveedor](#usa-tu-propia-clave-de-proveedor-byok)), y los webhooks de una automatización con la inferencia de NaN se responden sin lanzar ningún run. Los runs con tus propias claves tienen su propio límite: hasta 8 a la vez entre todos tus workspaces. ## Cambia o borra una automatización -Cambia cualquier ajuste en la pestaña **Settings** y pulsa **Save changes**. Un run conserva los ajustes con los que arrancó: editar una automatización nunca cambia un run que ya está en cola o trabajando. +Cambia cualquier ajuste en la pestaña **Settings** y pulsa **Save changes**, o usa `nan automations edit`, `enable`, `disable` y `rm` (consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api)). Un run conserva los ajustes con los que arrancó: editar una automatización nunca cambia un run que ya está en cola o trabajando. Al borrarla con **Delete**, sus triggers se detienen al momento y se cancelan sus runs en cola. Los runs que ya están trabajando terminan, y las acciones que ya aprobaste, o que se aplican solas, se completan igualmente. Los runs pasados se quedan en el historial del workspace, y un run que ya espera tu aprobación todavía se puede aprobar o rechazar. ## Por qué algunas acciones necesitan el navegador -Una automatización decide cuáles de tus secretos recibe un agente y qué puede hacer en tu repositorio sin preguntar. Si cualquier cosa capaz de leer una sesión de la CLI o un token pudiera cambiarla, también podría hacerlo un agente en un workspace o un job de CI. Por eso estas acciones te necesitan a ti, con sesión iniciada en el navegador: +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, cambiar o borrar una automatización o sus triggers. -- Añadir, reemplazar o borrar secretos. -- Aprobar una propuesta. -- Crear un token con `automations:read` o `automations:run`. +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)). Rechazar siempre es seguro, así que cualquier credencial que pueda leer tus runs puede rechazar. Si el portal te dice que tienes que volver a iniciar sesión, cierra sesión y entra con el enlace por correo desde el mismo navegador. @@ -539,7 +657,7 @@ Caduca tras el tiempo que hayas puesto (72 horas por defecto) y el run termina c
-Todavía no. Desde la CLI y la API puedes listarlas, consultarlas y lanzarlas, y leer y rechazar propuestas. Crearlas y editarlas se hace en el portal. +Sí, si tu cuenta tiene la API de escritura (se está abriendo poco a poco): `nan automations create`, `edit`, `rm` y `nan automations triggers`, o la API con un token que tenga `automations:write`. Consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). Mientras tanto, la CLI te dice que uses el portal. Aprobar sigue siendo cosa del portal.
diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index f4afbe3b..66397204 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -182,7 +182,25 @@ nan runs reject nan secrets ls ``` -Crear o editar automatizaciones y triggers, escribir secretos y aprobar propuestas solo se hace en el portal, con sesión iniciada en tu navegador: si lo intentas, la CLI te dice dónde. Un run que espera aprobación hace que `nan run`, `nan runs logs -f` y `nan automations run` salgan con código 10. Los detalles están en [Automatizaciones](/es/docs/automations#desde-la-terminal). +Desde la v0.1.33, si tu cuenta tiene la API de escritura (se está abriendo poco a poco), la CLI también crea y cambia tus secretos, automatizaciones y triggers: + +```bash +nan secrets set GITHUB_TOKEN --value-file ./token.txt # lo añade, o reemplaza su valor +gh auth token | nan secrets set GITHUB_READ_TOKEN --value-stdin +nan secrets show GITHUB_TOKEN +nan secrets edit GITHUB_TOKEN --description "repo acme/api" +nan secrets rm GITHUB_TOKEN +nan automations create -f automation.yaml +nan automations edit "PR review" -f changes.yaml +nan automations disable "PR review" +nan automations triggers add "PR review" -f webhook.yaml # imprime la URL y el secreto una vez +``` + +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. 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. + +Estos comandos usan tu sesión de `nan auth login`, o un token de plataforma con el scope `secrets:` o `automations:write` que corresponda. 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). ## Actualizar diff --git a/src/content/docs-es/runs.mdx b/src/content/docs-es/runs.mdx index 5babe05e..d03b5b7f 100644 --- a/src/content/docs-es/runs.mdx +++ b/src/content/docs-es/runs.mdx @@ -174,7 +174,7 @@ Escribe el id del modelo tal como lo llama tu proveedor. NaN no lo comprueba con ### Guarda tu clave como provider key -Abre tus ajustes en el portal, **Secrets**, y pulsa **Add secret** (guardar un secreto necesita que hayas iniciado sesión en el navegador). Rellena (el portal está en inglés): +Abre tus ajustes en el portal, **Secrets**, y pulsa **Add secret**. Rellena (el portal está en inglés): - **Name**: cómo la nombran los runs y las automatizaciones, por ejemplo `ANTHROPIC_KEY`. Mayúsculas, dígitos y `_`, empezando por una letra. - **Kind**: **Provider key**. @@ -185,6 +185,8 @@ Abre tus ajustes en el portal, **Secrets**, y pulsa **Add secret** (guardar un s Como todo secreto, una provider key es de **solo escritura**: nadie puede volver a leerla, ni siquiera tú. La lista la muestra como **Provider key · Anthropic** (o su proveedor) normalmente con sus 4 últimos caracteres y, en una clave OpenAI-compatible, su URL base, que puedes cambiar con **Base URL**. **Replace value** cambia la clave para todos los runs que arranquen a partir de ese momento. `nan secrets ls` la muestra con **KIND** `provider_key`, su **PROVIDER** y su **BASE URL**. +Si tu cuenta tiene la API de escritura, también puedes guardarla desde la CLI, leyendo la clave de un archivo o de una tubería: `nan secrets set ANTHROPIC_KEY --value-file key.txt --kind provider_key --provider anthropic`. Consulta [Secretos desde la CLI](/es/docs/automations#secretos-desde-la-cli). + **Reglas de la URL base.** NaN comprueba la URL base al guardarla y nunca se conecta a ella: solo lo hace el agente de tu workspace. - Solo `https`, hasta 512 caracteres. Un puerto vale (`https://llm.example.com:8443/v1`). diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index 1967dce4..97887735 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -13,7 +13,7 @@ import Callout from '../../components/docs/Callout.astro'; Un **trigger** lanza una [automatización](/es/docs/automations) sin ti: un **webhook** cuando GitHub, Stripe o uno de tus sistemas envía un evento, o una **programación** a las horas que elijas. Cada arranque crea un run en tu workspace con los ajustes de la automatización, exactamente igual que **Run now**. -Una automatización puede tener hasta **5 triggers**, de cualquiera de los dos tipos. Los gestionas en su pestaña **Triggers**, en [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule** y, en cada trigger, **Pause** / **Resume**, **Rotate** (webhooks) y el icono de la papelera para borrarlo. Añadir, cambiar y borrar triggers exige una [sesión en el navegador](/es/docs/automations#por-qué-algunas-acciones-necesitan-el-navegador). +Una automatización puede tener hasta **5 triggers**, de cualquiera de los dos tipos. Los gestionas en su pestaña **Triggers**, en [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule** y, en cada trigger, **Pause** / **Resume**, **Rotate** (webhooks) y el icono de la papelera para borrarlo. Si tu cuenta tiene la API de escritura, también puedes gestionarlos desde la CLI (`nan automations triggers`) y la API: consulta [Triggers desde la CLI](/es/docs/automations#triggers-desde-la-cli). Si no, añadir, cambiar y borrar triggers exige una [sesión en el navegador](/es/docs/automations#por-qué-algunas-acciones-necesitan-el-navegador). ## Añade un webhook @@ -46,6 +46,8 @@ Pulsa **Add webhook**. El portal te muestra, **una sola vez**: Cópialos y pulsa **I've saved them**. Después, el portal solo muestra el principio de la URL. Si los pierdes, [rótalos](#rota-la-url-o-el-secreto) para conseguir unos nuevos. +Desde la CLI, `nan automations triggers add -f webhook.yaml` imprime esos dos mismos valores **una sola vez**, con un aviso para que los guardes en ese momento; `--json` los imprime en JSON para un script. A través de la API, `POST /v1/automations/{id}/triggers` los devuelve una sola vez en `webhook_url` y `signing_secret`, con `Cache-Control: no-store`. Nada vuelve a devolverlos: ni el portal, ni `nan automations triggers ls`, ni la API. + ### Configúralo en el emisor Sigue la sección de tu emisor: [GitHub](#desde-github), [Stripe](#desde-stripe) o [tu propio código](#envía-tus-propios-eventos). @@ -251,7 +253,7 @@ Desde la terminal: `nan automations deliveries `. Desde la API: [`GE - **New webhook URL**: la URL actual deja de funcionar al momento. - **New signing secret** (marcado por defecto): por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. -Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. +Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo e imprime los valores nuevos una sola vez; a través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`. ## Bucles diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index 30fd1a59..38d8d672 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -25,7 +25,7 @@ The agent of an automation does not push or post on its own. It **proposes actio - **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). - **A plan with inference, or your own provider key.** On NaN inference, automation runs use your workspace's inference key and count against your plan's usage like any other run. On [your own provider key](#use-your-own-provider-key-byok), your provider bills them and an active workspace of your own is enough. -- **A browser sign-in.** Creating or changing automations, writing secrets and approving actions need you signed in at [cloud.nan.builders](https://cloud.nan.builders/) in your browser, with the email link. A CLI session, an API key or a token can list automations and run them, but never change them. See [Why some actions need the browser](#why-some-actions-need-the-browser). +- **Somewhere to make changes.** Create and change automations and secrets in the portal at [cloud.nan.builders](https://cloud.nan.builders/), signed in with the email link. Where your account has the write API (it is being opened gradually), you can also do it from the CLI and the API: see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). Approving actions always needs the portal. An API key never changes automations or secrets. See [Why some actions need the browser](#why-some-actions-need-the-browser). - **For the GitHub templates, a GitHub token saved as a secret.** The automation cannot be saved without it, so create it first: see [A GitHub token for actions](#a-github-token-for-actions). ## Create an automation @@ -104,6 +104,8 @@ Secrets are **write-only**: once saved, nobody can read a value back, not even y You can keep up to 100 secrets. Prefer short-lived tokens limited to what the automation needs. +Each secret shows where it was last changed from (**Last changed from**: **Browser**, **CLI** or **API token**), so a change you did not make stands out. From the CLI: `nan secrets set`, `nan secrets edit` and `nan secrets rm`, in [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). + ### Assign secrets to an automation In the **Secrets** card of the automation, **Add a secret** and pick, for each one: @@ -146,7 +148,7 @@ An automation can run on **your own key** with an AI provider instead of NaN inf In the automation's settings, set **Inference** to **My own key**, pick the **Provider key** and type the **Model**, exactly as your provider names it. Every run of the automation then uses that key and model, whatever starts it: a webhook, a schedule, **Run now**, the CLI or the API. Setting **Inference** back to **NaN (included in your plan)** returns it to NaN inference, with the NaN model you pick or, if you pick none, the agent's default. -- **Who can use it.** Without a NaN inference plan, an automation on your own key works with an active workspace of your own. Creating it and changing it need the browser, like any automation. +- **Who can use it.** Without a NaN inference plan, an automation on your own key works with an active workspace of your own. Creating it and changing it work like for any automation: the portal, or the CLI and the API where your account has the write API. - **Any credential can start it.** Unlike an [ad-hoc run on your key](/docs/runs#who-can-use-it), an automation on your key can be started with an API key or a platform token with `automations:run`, for example from CI: its instructions are the ones you saved, and the caller only adds input. - **Billing.** Your provider bills its runs; NaN does not meter them. The key counts towards the 48 KiB of secrets a run can receive. - **Limits.** Up to 8 runs on your own keys at once across all your workspaces, plus the limits of every run. See [Limits](/docs/runs#limits). @@ -351,7 +353,7 @@ nan secrets ls # names and metadata, never values `nan run`, `nan runs logs -f` and `nan automations run` exit with code **10** when the run stops waiting for approval, and print the commands to read and reject the proposal and the link to approve it. -There is no command to create or change automations, write secrets or approve: the CLI tells you where to do it in the portal. `nan secrets ls` needs your `nan auth login` session; the other commands also take a platform token, as `nan run` does ([Authentication](/docs/runs#authentication)). +To create and change automations, triggers and secrets from the terminal, see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). There is no command to approve: the CLI tells you where to do it in the portal. The commands take your `nan auth login` session or a platform token, as `nan run` does ([Authentication](/docs/runs#authentication)). `nan secrets` takes your session, or, where your account has the write API, a token with `secrets:read` (and `secrets:write` to change them). ### From the API @@ -366,6 +368,113 @@ curl https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs \ The answer is the run, which you follow like any other ([Create a run and follow it](/docs/runs#create-a-run-and-follow-it)). The full reference is in [API reference → Automations](/docs/api#tag/automations) and [API reference → Approvals](/docs/api#tag/approvals). +## Manage them from the CLI or the API + +Where your account has the **write API**, you can create and change automations, their triggers and your secrets from the [NaN CLI](/docs/nan-cli) (**0.1.33** or newer) and the API, not only from the portal. It is being opened gradually: until your account has it, the CLI tells you to use the portal (exit code 64), the automation writes answer `403 browser_session_required` and `/v1/secrets` answers `404`. Approving a proposal always stays in the portal. + +What can make these changes: + +- **Your CLI session**, from `nan auth login` on your own machine. +- **A platform token** with the `automations:write` or `secrets:write` scope, for scripts and CI. See [Tokens for automations](#tokens-for-automations). +- Never an API key (`sk-...`), the key inside a workspace or a session from an older CLI: they get `403 credential_not_allowed` (`workspace_key_not_allowed` for the workspace key). + + +Whoever holds a credential that can write can replace any of your secrets (for example swap the GitHub token an automation pushes with), or create an automation that receives all your secrets, with their instructions and nothing waiting for your approval: that is as good as reading every secret you have. So: + +- Run `nan auth login` on your own machine, **never inside a workspace**: the agents that run there can read the session it saves. +- For an agent, a script or CI, use a token with only the scopes its job needs, a short expiry, and revoke it when you are done. + + +Every secret, automation and trigger records where its last change came from (`updated_by`: `browser_session`, `cli_session` or `platform_token`); the portal shows it as **Last changed from**. + +### Secrets from the CLI + +```bash +nan secrets ls +nan secrets show GITHUB_TOKEN # metadata, never the value +nan secrets set GITHUB_WRITE_TOKEN --value-file ./token.txt --description "repo acme/api, write" +gh auth token | nan secrets set GITHUB_READ_TOKEN --value-stdin +nan secrets set TOGETHER_KEY --value-file key.txt --kind provider_key \ + --provider openai_compatible --base-url https://api.together.xyz/v1 +nan secrets edit GITHUB_WRITE_TOKEN --description "expires in December" +nan secrets rm GITHUB_READ_TOKEN +``` + +- **The value never goes on the command line**, where it would stay in your shell history and be visible to other programs: `--value-file PATH` reads it from a file, `--value-stdin` from a pipe. `--value-stdin` refuses to read from the terminal, so a value is never typed in clear. +- One line break at the end is removed (files usually end with one). The value cannot start or end with a line break otherwise. +- `set` creates the secret, or **replaces the value** of the one with that name. The kind, the provider and the name are fixed when it is created. +- `edit` changes the description, or the base URL of an OpenAI-compatible provider key. +- `rm` asks before deleting; `--yes` skips the question in scripts. It is refused while an automation uses the secret, and the CLI lists which. +- `--json` prints the metadata as JSON. + +### Automations from the CLI + +```bash +nan automations create -f pr-review.yaml +nan automations edit "PR review api" -f changes.yaml +nan automations disable "PR review api" +nan automations enable "PR review api" +nan automations rm "PR review api" +``` + +The file is YAML or JSON with the fields of the API (`-f -` reads it from stdin): the same settings as the portal form, by their API names. For example: + +```yaml +name: PR review api +template: pr_review +workspace: dev +agent: pi +github_repo: acme/api +secrets: + - { secret: GITHUB_READ_TOKEN, env: GITHUB_TOKEN, exposure: agent } + - { secret: GITHUB_WRITE_TOKEN, env: GITHUB_TOKEN, exposure: actions } +approval_kinds: [git_push, github_create_pr] +``` + +Fields you leave out take their defaults; with a template, its instructions and approval settings. `approval_kinds` left out means every kind of action waits for your approval; `[]` means none does. `edit` changes only the fields in the file, and `secrets` there replaces every assignment. The full list of fields is in [API reference → Create an automation](/docs/api#tag/automations/POST/automations). + +### Triggers from the CLI + +```bash +nan automations triggers ls "PR review api" +nan automations triggers add "PR review api" -f github-webhook.yaml +nan automations triggers edit "PR review api" -f filter.yaml +nan automations triggers rotate "PR review api" +nan automations triggers rm "PR review api" +``` + +```yaml +# github-webhook.yaml +kind: webhook +signature_scheme: github +event_filter: + events: [pull_request] + actions: [opened, synchronize, reopened, ready_for_review] + exclude_drafts: true +``` + +`add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. The details of each field are in [Webhooks and schedules](/docs/webhooks). + +### Through the API + +The same operations are in the API, under `/v1/secrets` and `/v1/automations`, with a token that has the scope (or a CLI session): + +```bash +jq -n --rawfile v token.txt '{name: "GITHUB_WRITE_TOKEN", value: ($v | rtrimstr("\n"))}' | + curl https://api.nan.builders/v1/secrets \ + -H "Authorization: Bearer $NAN_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: create-github-write-token-1" \ + --data-binary @- +``` + +- The value is never returned, by any route. Reading gives metadata only. +- `Idempotency-Key` makes a retried create safe: the same key returns the secret already created (`200`). Without it, a retry gets `409 name_taken`. +- Creating a trigger returns the webhook URL and signing secret once, with `Cache-Control: no-store`; so does rotating. Creating a trigger is not idempotent: after a lost answer, list the triggers and rotate or delete the one you did not see. +- Too many changes get `429 rate_limited`: see [Limits](#limits). + +Every route, body and error is in [API reference → Secrets](/docs/api#tag/secrets) and [API reference → Automations](/docs/api#tag/automations). + ## Tokens for automations A platform token carries **scopes**: what it may do. You pick them when you create it, under **Settings → Tokens**: @@ -376,8 +485,15 @@ A platform token carries **scopes**: what it may do. You pick them when you crea | `workspaces:read` | List your workspaces and read one by id. | | `automations:read` | Read your automations, templates, triggers and deliveries. Never secret values. | | `automations:run` | Run an existing automation, for example from CI. It cannot create or change automations, or approve. | +| `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.** | + +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. 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 `automations:read` or `automations:run` can only be created from a browser sign-in. A token without the scope an endpoint needs gets `403 token_scope_denied`. + +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. + For CI, give the token `automations:run` and `runs` (to follow the run and get its outcome), plus `automations:read` if you call the automation by its name. For example: `nan automations run ` in a step with `NAN_TOKEN` set, as in [Use the CLI in CI or on a server](/docs/runs#use-the-cli-in-ci-or-on-a-server). @@ -393,23 +509,25 @@ For CI, give the token `automations:run` and `runs` (to follow the run and get i | Secrets | 100 per member, 20 per automation, 48 KiB per run | | Instructions | 16 KiB | | Input of a run | A JSON object of up to 48 KiB, nested at most 8 levels | +| Changes to secrets | 30 at once, then 120 per hour, counting the portal, the CLI and the API together | +| Changes to automations and triggers | 30 at once, then 300 per hour | Automation runs share the limits of every run: how many work at once in each workspace, at most 5 at once across all your workspaces (8 on Premium), and the queue. See [How many runs at once](/docs/runs#how-many-runs-at-once). Automations on NaN inference need a plan with inference: without one, the portal offers **My own key** (see [Use your own provider key](#use-your-own-provider-key-byok)), and webhooks of an automation on NaN inference are answered without starting a run. Runs on your own keys have their own limit: up to 8 at once across all your workspaces. ## Change or delete an automation -Change any setting in the **Settings** tab and press **Save changes**. A run keeps the settings it started with: editing an automation never changes a run that is already queued or working. +Change any setting in the **Settings** tab and press **Save changes**, or use `nan automations edit`, `enable`, `disable` and `rm` (see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api)). A run keeps the settings it started with: editing an automation never changes a run that is already queued or working. **Delete** stops its triggers at once and cancels its queued runs. Runs already working finish, and actions you already approved, or that apply automatically, still complete. Past runs stay in the workspace's history, and a run already waiting for your approval can still be approved or rejected. ## Why some actions need the browser -An automation decides which of your secrets an agent receives and what it may do in your repository without asking. If anything that can read a CLI session or a token could change it, an agent in a workspace or a CI job could too. So these need you, signed in through your browser: +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, changing or deleting an automation or its triggers. -- Adding, replacing or deleting secrets. -- Approving a proposal. -- Creating a token with `automations:read` or `automations:run`. +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)). Rejecting is always safe, so any credential that can read your runs can reject. If the portal says you need to sign in again, sign out and sign in with the email link from the same browser. @@ -539,7 +657,7 @@ It expires after the time you set (72 hours by default) and the run ends as `can
-Not yet. From the CLI and the API you can list, inspect and run them, and read and reject proposals. Creating and editing happen in the portal. +Yes, where your account has the write API (it is being opened gradually): `nan automations create`, `edit`, `rm` and `nan automations triggers`, or the API with a token that has `automations:write`. See [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). Until then, the CLI tells you to use the portal. Approving stays in the portal.
diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 1b100857..6fdd773e 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -182,7 +182,25 @@ nan runs reject nan secrets ls ``` -Creating or editing automations and triggers, writing secrets and approving proposals happen only in the portal, signed in through your browser: the CLI tells you where if you try. A run waiting for approval makes `nan run`, `nan runs logs -f` and `nan automations run` exit with code 10. The details are in [Automations](/docs/automations#from-the-terminal). +From v0.1.33, where your account has the write API (it is being opened gradually), the CLI also creates and changes your secrets, automations and triggers: + +```bash +nan secrets set GITHUB_TOKEN --value-file ./token.txt # add it, or replace its value +gh auth token | nan secrets set GITHUB_READ_TOKEN --value-stdin +nan secrets show GITHUB_TOKEN +nan secrets edit GITHUB_TOKEN --description "repo acme/api" +nan secrets rm GITHUB_TOKEN +nan automations create -f automation.yaml +nan automations edit "PR review" -f changes.yaml +nan automations disable "PR review" +nan automations triggers add "PR review" -f webhook.yaml # prints the URL and secret once +``` + +A secret's value is read from a file or a pipe, never from the command line, and nothing ever prints it back. 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. + +These commands use your `nan auth login` session, or a platform token with the `secrets:` or `automations:write` scope. 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). ## Updating diff --git a/src/content/docs/runs.mdx b/src/content/docs/runs.mdx index 0303447c..3a2ad258 100644 --- a/src/content/docs/runs.mdx +++ b/src/content/docs/runs.mdx @@ -174,7 +174,7 @@ Type the model id exactly as your provider names it. NaN does not check it again ### Save your key as a provider key -Open your settings on the portal, **Secrets**, and press **Add secret** (saving a secret needs you signed in through the browser). Fill in: +Open your settings on the portal, **Secrets**, and press **Add secret**. Fill in: - **Name**: how runs and automations refer to it, for example `ANTHROPIC_KEY`. Capitals, digits and `_`, starting with a letter. - **Kind**: **Provider key**. @@ -185,6 +185,8 @@ Open your settings on the portal, **Secrets**, and press **Add secret** (saving Like every secret, a provider key is **write-only**: nobody can read it back, not even you. The list shows it as **Provider key · Anthropic** (or its provider) usually with its last 4 characters, and, for an OpenAI-compatible key, its base URL, which you can change with **Base URL**. **Replace value** changes the key for every run that starts from then on. `nan secrets ls` shows it with **KIND** `provider_key`, its **PROVIDER** and its **BASE URL**. +Where your account has the write API, you can also save it from the CLI, reading the key from a file or a pipe: `nan secrets set ANTHROPIC_KEY --value-file key.txt --kind provider_key --provider anthropic`. See [Secrets from the CLI](/docs/automations#secrets-from-the-cli). + **Base URL rules.** NaN checks the base URL when you save it, and never connects to it: only the agent in your workspace does. - `https` only, up to 512 characters. A port is fine (`https://llm.example.com:8443/v1`). diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index 47b0f5cd..d8b65778 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -13,7 +13,7 @@ import Callout from '../../components/docs/Callout.astro'; A **trigger** starts an [automation](/docs/automations) without you: a **webhook** when GitHub, Stripe or one of your systems sends an event, or a **schedule** at the times you pick. Each start creates a run in your workspace with the automation's settings, exactly like **Run now**. -An automation can have up to **5 triggers**, of either kind. You manage them in its **Triggers** tab, at [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule**, and on each trigger **Pause** / **Resume**, **Rotate** (webhooks) and the bin icon to delete it. Adding, changing and deleting triggers needs a [browser sign-in](/docs/automations#why-some-actions-need-the-browser). +An automation can have up to **5 triggers**, of either kind. You manage them in its **Triggers** tab, at [cloud.nan.builders/automations](https://cloud.nan.builders/automations): **+ Webhook**, **+ Schedule**, and on each trigger **Pause** / **Resume**, **Rotate** (webhooks) and the bin icon to delete it. Where your account has the write API, you can also manage them from the CLI (`nan automations triggers`) and the API: see [Triggers from the CLI](/docs/automations#triggers-from-the-cli). Otherwise, adding, changing and deleting triggers needs a [browser sign-in](/docs/automations#why-some-actions-need-the-browser). ## Add a webhook @@ -46,6 +46,8 @@ Press **Add webhook**. The portal shows, **only once**: Copy them, then press **I've saved them**. Afterwards the portal only shows the start of the URL. If you lose them, [rotate](#rotate-the-url-or-the-secret) to get new ones. +From the CLI, `nan automations triggers add -f webhook.yaml` prints the same two values **once**, with a reminder to store them now; `--json` prints them as JSON for a script. Through the API, `POST /v1/automations/{id}/triggers` returns them once in `webhook_url` and `signing_secret`, with `Cache-Control: no-store`. Nothing returns them again: not the portal, not `nan automations triggers ls`, not the API. + ### Set it up on the sender Follow the section for your sender: [GitHub](#from-github), [Stripe](#from-stripe) or [your own code](#send-your-own-events). @@ -251,7 +253,7 @@ From the terminal: `nan automations deliveries `. From the API: [`GE - **New webhook URL**: the current URL stops working at once. - **New signing secret** (ticked by default): by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. -The new values are shown once, like when you added the webhook. +The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same and prints the new values once; through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`. ## Loops diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts index 8adf8ade..fd4eae57 100644 --- a/src/tests/layouts/automationsDocs.test.ts +++ b/src/tests/layouts/automationsDocs.test.ts @@ -24,8 +24,8 @@ const read = (p: string) => readFileSync(resolve(src, p), 'utf-8'); const strip = (raw: string) => raw.replace(/^---[\s\S]*?\n---\n/, ''); const pages = { - en: { dir: 'content/docs/', prefix: '', group: 'Guides', titles: ['Automations', 'Webhooks and schedules'], tokensAnchor: 'tokens-for-automations' }, - es: { dir: 'content/docs-es/', prefix: '/es', group: 'Guías', titles: ['Automatizaciones', 'Webhooks y programaciones'], tokensAnchor: 'tokens-para-automatizaciones' }, + en: { dir: 'content/docs/', prefix: '', group: 'Guides', titles: ['Automations', 'Webhooks and schedules'], tokensAnchor: 'tokens-for-automations', manageAnchor: 'manage-them-from-the-cli-or-the-api' }, + es: { dir: 'content/docs-es/', prefix: '/es', group: 'Guías', titles: ['Automatizaciones', 'Webhooks y programaciones'], tokensAnchor: 'tokens-para-automatizaciones', manageAnchor: 'gestiónalas-desde-la-cli-o-la-api' }, } as const; /** Heading ids as the site renders them (github-slugger, as in runsDocs.test.ts). */ @@ -186,11 +186,64 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) } expect(autoBody).toContain('**0.1.26**'); expect(autoFlat).toMatch(/\*\*10\*\*/); - expect(autoBody).not.toMatch(/nan (runs approve|secrets set|automations create)/); - }); - - test('tokens: the four scopes and what needs the browser', () => { - for (const scope of ['runs', 'workspaces:read', 'automations:read', 'automations:run']) expect(autoBody, scope).toContain(`| \`${scope}\` |`); + // Approving stays in the portal: there is no command for it. + expect(autoBody).not.toMatch(/nan runs approve/); + }); + + // The write API (devops AGENT-AUTOMATIONS-PHASE3-WRITE-API.md, cloud-api + // #348, nan-cli 0.1.33): secrets and automations from the CLI and the API. + test('the write API: commands, credentials, the warning and the rollout', () => { + for (const cmd of [ + 'nan secrets set', 'nan secrets show', 'nan secrets edit', 'nan secrets rm', 'nan automations create -f', + 'nan automations edit', 'nan automations enable', 'nan automations disable', 'nan automations rm', + 'nan automations triggers ls', 'nan automations triggers add', 'nan automations triggers edit', + 'nan automations triggers rotate', 'nan automations triggers rm', + ]) { + expect(autoBody, cmd).toContain(cmd); + } + expect(autoBody).toContain('**0.1.33**'); + // A value never goes on the command line: every `nan secrets set` reads it from a file or a pipe. + for (const dir of [page.dir]) { + for (const f of ['automations.mdx', 'webhooks.mdx', 'runs.mdx', 'nan-cli.mdx']) { + for (const m of read(dir + f).matchAll(/nan secrets set [^\n`]*/g)) { + expect(m[0], `${f}: ${m[0]}`).toMatch(/--value-(file|stdin)/); + } + } + } + for (const code of ['`403 credential_not_allowed`', '`workspace_key_not_allowed`', '`403 browser_session_required`', '`429 rate_limited`', '`409 name_taken`', '`Idempotency-Key`', '`Cache-Control: no-store`']) { + expect(autoBody, code).toContain(code); + } + for (const v of ['`browser_session`', '`cli_session`', '`platform_token`', '**Last changed from**']) expect(autoBody, v).toContain(v); + // The warning: never log in inside a workspace, least scopes. + expect(autoBody).toMatch(/`nan auth login`[^\n]*(never inside a workspace|nunca dentro de un workspace)/); + expect(autoBody).toContain('variant="warning"'); + // The rollout: exit code 64 and the answers until the account has it. + expect(autoFlat).toMatch(/\(código de salida 64\)|\(exit code 64\)/); + expect(autoBody).toContain('`/v1/secrets`'); + // Rate limits, as in cloud-api (secretWriteBurst/automationWriteBurst). + expect(autoFlat).toMatch(/\| (Changes to secrets|Cambios en secretos) \| 30 [^|]*120 [^|]*\|/); + expect(autoFlat).toMatch(/\| (Changes to automations and triggers|Cambios en automatizaciones y triggers) \| 30 [^|]*300 [^|]*\|/); + }); + + test('webhooks: the URL and signing secret are shown once, from the CLI and the API too', () => { + expect(hooksBody).toContain('nan automations triggers add'); + expect(hooksBody).toContain('nan automations triggers rotate'); + expect(hooksBody).toContain('`Cache-Control: no-store`'); + expect(hooksBody).toContain('`webhook_url`'); + expect(hooksBody).toContain('`signing_secret`'); + }); + + test('the CLI guide lists the write commands and the workspace warning', () => { + const cli = read(page.dir + 'nan-cli.mdx'); + for (const cmd of ['nan secrets set', 'nan automations create', 'nan automations triggers add']) expect(cli, cmd).toContain(cmd); + expect(cli).toMatch(/v0\.1\.33/); + expect(cli).toContain(`${page.prefix}/docs/automations#${page.manageAnchor}`); + }); + + test('tokens: the seven scopes and what needs the browser', () => { + for (const scope of ['runs', 'workspaces:read', 'automations:read', 'automations:run', 'automations:write', 'secrets:read', 'secrets:write']) { + expect(autoBody, scope).toContain(`| \`${scope}\` |`); + } expect(autoBody).toContain('`403 token_scope_denied`'); expect(autoBody).toContain('https://api.nan.builders/v1/automations/$AUTOMATION_ID/runs'); }); @@ -408,7 +461,7 @@ describe.each(Object.entries(pages))('the other guides point at automations (%s) }); }); -describe('member secrets are a portal feature, not part of the public API', () => { +describe('the portal secrets route stays out of the docs (the public one is /v1/secrets)', () => { test('no page documents /api/account/secrets as an endpoint', () => { for (const dir of ['content/docs/', 'content/docs-es/']) { for (const f of ['automations.mdx', 'webhooks.mdx', 'runs.mdx', 'nan-cli.mdx']) { From c46f99d87e066b88f328621db065e09c9df1a19c Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 16:02:49 +0200 Subject: [PATCH 4/7] docs(automations): trigger rotate flags and automations:read for names, as the CLI does Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 4 ++-- src/content/docs-es/webhooks.mdx | 2 +- src/content/docs/automations.mdx | 4 ++-- src/content/docs/webhooks.mdx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index 145133ef..e715a803 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -453,7 +453,7 @@ event_filter: exclude_drafts: true ``` -`add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). +`add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. `rotate` da una URL nueva y un secreto de firma nuevo; `--url` o `--secret` reemplaza solo uno, y `--no-grace` hace que el secreto anterior deje de valer al momento en vez de a las 24 horas (si se filtró). El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). ### A través de la API @@ -489,7 +489,7 @@ Un token de plataforma lleva **scopes**: lo que puede hacer. Los eliges al crear | `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.** | -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. 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:` 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`. 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. diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index 97887735..5d4e301f 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -253,7 +253,7 @@ Desde la terminal: `nan automations deliveries `. Desde la API: [`GE - **New webhook URL**: la URL actual deja de funcionar al momento. - **New signing secret** (marcado por defecto): por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. -Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo e imprime los valores nuevos una sola vez; a través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`. +Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo (`--url` o `--secret` para solo uno, `--no-grace` para que el secreto anterior deje de valer al momento) e imprime los valores nuevos una sola vez; a través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`. ## Bucles diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index 38d8d672..8cf57db4 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -453,7 +453,7 @@ event_filter: exclude_drafts: true ``` -`add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. The details of each field are in [Webhooks and schedules](/docs/webhooks). +`add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. `rotate` gives a new URL and a new signing secret; `--url` or `--secret` replaces only one, and `--no-grace` stops the old secret at once instead of after 24 hours (for a leaked one). The details of each field are in [Webhooks and schedules](/docs/webhooks). ### Through the API @@ -489,7 +489,7 @@ A platform token carries **scopes**: what it may do. You pick them when you crea | `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.** | -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. 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:` 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`. 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. diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index d8b65778..d8d89654 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -253,7 +253,7 @@ From the terminal: `nan automations deliveries `. From the API: [`GE - **New webhook URL**: the current URL stops working at once. - **New signing secret** (ticked by default): by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. -The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same and prints the new values once; through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`. +The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same (`--url` or `--secret` for only one, `--no-grace` to stop the old secret at once) and prints the new values once; through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`. ## Loops From bc5a7d66ed4d5f34ad11b10ad156612770d4f7a1 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 16:11:58 +0200 Subject: [PATCH 5/7] docs(write-api): review fixes: Stripe triggers from the CLI, credential order, realistic examples - Stripe: the CLI rotates only the URL; add reads signing_secret from the file - the write commands' credential order (--token-file, session, NAN_TOKEN) - API keys never reach secrets or automation changes (overview) - nan_whsec_ examples, trigger 404 message, secrets newest first - browser_session_required examples on automation writes Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 17 ++- src/content/docs-es/nan-cli.mdx | 4 +- src/content/docs-es/webhooks.mdx | 4 +- src/content/docs/automations.mdx | 15 ++- src/content/docs/nan-cli.mdx | 4 +- src/content/docs/webhooks.mdx | 4 +- src/data/openapi.json | 141 ++++++++++++++++++---- src/lib/openapiWriteApi.test.ts | 31 +++++ src/tests/layouts/automationsDocs.test.ts | 15 ++- 9 files changed, 186 insertions(+), 49 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index e715a803..f4cfa3a3 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -353,7 +353,7 @@ nan secrets ls # nombres y metadatos, nunca valores `nan run`, `nan runs logs -f` y `nan automations run` salen con código **10** cuando el run se para esperando aprobación, e imprimen los comandos para leer y rechazar la propuesta y el enlace para aprobarla. -Para crear y cambiar automatizaciones, triggers y secretos desde la terminal, consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). No hay ningún comando para aprobar: la CLI te dice dónde hacerlo en el portal. Los comandos aceptan tu sesión de `nan auth login` o un token de plataforma, igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). `nan secrets` acepta tu sesión o, si tu cuenta tiene la API de escritura, un token con `secrets:read` (y `secrets:write` para cambiarlos). +Para crear y cambiar automatizaciones, triggers y secretos desde la terminal, consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). No hay ningún comando para aprobar: la CLI te dice dónde hacerlo en el portal. `nan automations ls`, `show`, `run` y `deliveries` se autentican igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). Los comandos `nan secrets` y los que cambian automatizaciones eligen la credencial en su propio orden, descrito en [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). ### Desde la API @@ -378,8 +378,10 @@ Qué puede hacer estos cambios: - **Un token de plataforma** con el scope `automations:write` o `secrets:write`, para scripts y CI. Consulta [Tokens para automatizaciones](#tokens-para-automatizaciones). - Nunca una API key (`sk-...`), la clave de dentro de un workspace ni una sesión de una CLI antigua: reciben `403 credential_not_allowed` (`workspace_key_not_allowed` en el caso de la clave del workspace). +La CLI usa la primera credencial que encuentra, en este orden: `--token-file`, tu sesión de `nan auth login`, `NAN_TOKEN` y, por último, un token guardado con `nan auth login --api-token`. Así que, mientras tengas la sesión iniciada, `NAN_TOKEN` no se usa: pasa el token con `--token-file`, o cierra sesión con `nan auth logout`, para actuar como el token. Una API key se rechaza antes de enviar nada. + -Quien tenga una credencial que pueda escribir puede reemplazar cualquiera de tus secretos (por ejemplo, cambiar el token de GitHub con el que hace push una automatización) o crear una automatización que reciba todos tus secretos, con sus instrucciones y sin nada esperando tu aprobación: eso equivale a leer todos tus secretos. Así que: +Quien tenga una credencial que pueda escribir puede reemplazar cualquiera de tus secretos (por ejemplo, cambiar el token de GitHub con el que hace push una automatización) o crear una automatización que reciba todos tus secretos, con las instrucciones que quiera y sin nada esperando tu aprobación: eso equivale a leer todos tus secretos. Así que: - Ejecuta `nan auth login` en tu propia máquina, **nunca dentro de un workspace**: los agentes que se ejecutan ahí pueden leer la sesión que guarda. - Para un agente, un script o CI, usa un token solo con los scopes que necesita su trabajo y con caducidad corta, y revócalo cuando acabes. @@ -403,8 +405,9 @@ nan secrets rm GITHUB_READ_TOKEN - **El valor nunca va en la línea de comandos**, donde se quedaría en el historial de tu shell y otros programas podrían verlo: `--value-file PATH` lo lee de un archivo y `--value-stdin` de una tubería. `--value-stdin` se niega a leer de la terminal, así que nunca se teclea un valor a la vista. - Se quita un salto de línea al final (los archivos suelen acabar en uno). Aparte de eso, el valor no puede empezar ni terminar con un salto de línea. - `set` crea el secreto o **reemplaza el valor** del que tenga ese nombre. El tipo, el proveedor y el nombre quedan fijos al crearlo. -- `edit` cambia la descripción, o la base URL de una provider key compatible con OpenAI. -- `rm` pregunta antes de borrar; `--yes` se salta la pregunta en scripts. Se rechaza mientras una automatización use el secreto, y la CLI te dice cuáles. +- `edit` cambia la descripción, o la base URL de una clave de proveedor compatible con OpenAI. +- `rm` pregunta antes de borrar (si respondes que no, sale con código 1); `--yes` se salta la pregunta en scripts. Se rechaza mientras una automatización use el secreto, y la CLI te dice cuáles. +- Con un token, nombrar un secreto que ya existe necesita `secrets:read` para buscar el nombre; un token solo con `secrets:write` puede crear un secreto nuevo. - `--json` imprime los metadatos en JSON. ### Automatizaciones desde la CLI @@ -431,7 +434,7 @@ secrets: approval_kinds: [git_push, github_create_pr] ``` -Los campos que no pongas toman su valor por defecto; con una plantilla, sus instrucciones y su configuración de aprobación. Sin `approval_kinds`, todos los tipos de acción esperan tu aprobación; con `[]`, ninguno. `edit` cambia solo los campos del archivo, y `secrets` ahí reemplaza todas las asignaciones. La lista completa de campos está en [Referencia de la API → Create an automation](/es/docs/api#tag/automations/POST/automations). +Los campos que no pongas toman su valor por defecto; con una plantilla, sus instrucciones y su concurrencia. Sin `approval_kinds`, todos los tipos de acción esperan tu aprobación; con `[]`, ninguno. `edit` cambia solo los campos del archivo, y `secrets` ahí reemplaza todas las asignaciones. La lista completa de campos está en [Referencia de la API → Create an automation](/es/docs/api#tag/automations/POST/automations). ### Triggers desde la CLI @@ -453,7 +456,9 @@ event_filter: exclude_drafts: true ``` -`add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. `rotate` da una URL nueva y un secreto de firma nuevo; `--url` o `--secret` reemplaza solo uno, y `--no-grace` hace que el secreto anterior deje de valer al momento en vez de a las 24 horas (si se filtró). El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). +`add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. `rotate` da una URL nueva y un secreto de firma nuevo; `--url` o `--secret` reemplaza solo uno, y `--no-grace` hace que el secreto anterior deje de valer al momento en vez de a las 24 horas (si se filtró). + +En un webhook de Stripe el secreto de firma es de Stripe, no de NaN: ponlo en el archivo que pasas a `add` como `signing_secret: whsec_...` (pásalo por una tubería con `-f -` para que no se quede en disco), y `add` imprime solo la URL. La CLI solo rota la URL de un webhook de Stripe (`--url`); para cambiar su secreto, rótalo en Stripe y pega el nuevo en el portal, o envíalo a través de la API en `signing_secret`. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). ### A través de la API diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index 66397204..7f3e8a61 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -189,7 +189,7 @@ nan secrets set GITHUB_TOKEN --value-file ./token.txt # lo añade, o reempla gh auth token | nan secrets set GITHUB_READ_TOKEN --value-stdin nan secrets show GITHUB_TOKEN nan secrets edit GITHUB_TOKEN --description "repo acme/api" -nan secrets rm GITHUB_TOKEN +nan secrets rm OLD_TOKEN nan automations create -f automation.yaml nan automations edit "PR review" -f changes.yaml nan automations disable "PR review" @@ -198,7 +198,7 @@ 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. 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. -Estos comandos usan tu sesión de `nan auth login`, o un token de plataforma con el scope `secrets:` o `automations:write` que corresponda. 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). +Estos comandos usan tu sesión de `nan auth login`, 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/webhooks.mdx b/src/content/docs-es/webhooks.mdx index 5d4e301f..22617ec3 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -46,7 +46,7 @@ Pulsa **Add webhook**. El portal te muestra, **una sola vez**: Cópialos y pulsa **I've saved them**. Después, el portal solo muestra el principio de la URL. Si los pierdes, [rótalos](#rota-la-url-o-el-secreto) para conseguir unos nuevos. -Desde la CLI, `nan automations triggers add -f webhook.yaml` imprime esos dos mismos valores **una sola vez**, con un aviso para que los guardes en ese momento; `--json` los imprime en JSON para un script. A través de la API, `POST /v1/automations/{id}/triggers` los devuelve una sola vez en `webhook_url` y `signing_secret`, con `Cache-Control: no-store`. Nada vuelve a devolverlos: ni el portal, ni `nan automations triggers ls`, ni la API. +Desde la CLI, `nan automations triggers add -f webhook.yaml` imprime esos mismos valores **una sola vez** (en Stripe, solo la URL), con un aviso para que los guardes en ese momento; `--json` los imprime en JSON para un script. A través de la API, `POST /v1/automations/{id}/triggers` los devuelve una sola vez en `webhook_url` y `signing_secret`, con `Cache-Control: no-store`. Nada vuelve a devolverlos: ni el portal, ni `nan automations triggers ls`, ni la API. ### Configúralo en el emisor @@ -253,7 +253,7 @@ Desde la terminal: `nan automations deliveries `. Desde la API: [`GE - **New webhook URL**: la URL actual deja de funcionar al momento. - **New signing secret** (marcado por defecto): por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. -Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo (`--url` o `--secret` para solo uno, `--no-grace` para que el secreto anterior deje de valer al momento) e imprime los valores nuevos una sola vez; a través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`. +Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo (`--url` o `--secret` para solo uno, `--no-grace` para que el secreto anterior deje de valer al momento) e imprime los valores nuevos una sola vez. En Stripe, la CLI solo rota la URL (`--url`): cambia el secreto en el portal o a través de la API. A través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`; en Stripe, `{"rotate": ["secret"], "signing_secret": "whsec_..."}` con el secreto nuevo de Stripe. ## Bucles diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index 8cf57db4..c3c92f25 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -353,7 +353,7 @@ nan secrets ls # names and metadata, never values `nan run`, `nan runs logs -f` and `nan automations run` exit with code **10** when the run stops waiting for approval, and print the commands to read and reject the proposal and the link to approve it. -To create and change automations, triggers and secrets from the terminal, see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). There is no command to approve: the CLI tells you where to do it in the portal. The commands take your `nan auth login` session or a platform token, as `nan run` does ([Authentication](/docs/runs#authentication)). `nan secrets` takes your session, or, where your account has the write API, a token with `secrets:read` (and `secrets:write` to change them). +To create and change automations, triggers and secrets from the terminal, see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). There is no command to approve: the CLI tells you where to do it in the portal. `nan automations ls`, `show`, `run` and `deliveries` authenticate like `nan run` ([Authentication](/docs/runs#authentication)). The `nan secrets` commands and the ones that change automations pick their credential in their own order, described in [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). ### From the API @@ -378,8 +378,10 @@ What can make these changes: - **A platform token** with the `automations:write` or `secrets:write` scope, for scripts and CI. See [Tokens for automations](#tokens-for-automations). - Never an API key (`sk-...`), the key inside a workspace or a session from an older CLI: they get `403 credential_not_allowed` (`workspace_key_not_allowed` for the workspace key). +The CLI uses the first credential it finds, in this order: `--token-file`, your `nan auth login` session, `NAN_TOKEN`, then a token saved with `nan auth login --api-token`. So while you are signed in, `NAN_TOKEN` is not used: pass the token with `--token-file`, or sign out with `nan auth logout`, to act as the token. An API key is refused before anything is sent. + -Whoever holds a credential that can write can replace any of your secrets (for example swap the GitHub token an automation pushes with), or create an automation that receives all your secrets, with their instructions and nothing waiting for your approval: that is as good as reading every secret you have. So: +Whoever holds a credential that can write can replace any of your secrets (for example swap the GitHub token an automation pushes with), or create an automation that receives all your secrets, with instructions of their choosing and nothing waiting for your approval: that is as good as reading every secret you have. So: - Run `nan auth login` on your own machine, **never inside a workspace**: the agents that run there can read the session it saves. - For an agent, a script or CI, use a token with only the scopes its job needs, a short expiry, and revoke it when you are done. @@ -404,7 +406,8 @@ nan secrets rm GITHUB_READ_TOKEN - One line break at the end is removed (files usually end with one). The value cannot start or end with a line break otherwise. - `set` creates the secret, or **replaces the value** of the one with that name. The kind, the provider and the name are fixed when it is created. - `edit` changes the description, or the base URL of an OpenAI-compatible provider key. -- `rm` asks before deleting; `--yes` skips the question in scripts. It is refused while an automation uses the secret, and the CLI lists which. +- `rm` asks before deleting (answering no exits with code 1); `--yes` skips the question in scripts. It is refused while an automation uses the secret, and the CLI lists which. +- With a token, naming an existing secret needs `secrets:read` to look the name up; a token with only `secrets:write` can still create a new secret. - `--json` prints the metadata as JSON. ### Automations from the CLI @@ -431,7 +434,7 @@ secrets: approval_kinds: [git_push, github_create_pr] ``` -Fields you leave out take their defaults; with a template, its instructions and approval settings. `approval_kinds` left out means every kind of action waits for your approval; `[]` means none does. `edit` changes only the fields in the file, and `secrets` there replaces every assignment. The full list of fields is in [API reference → Create an automation](/docs/api#tag/automations/POST/automations). +Fields you leave out take their defaults; with a template, its instructions and concurrency. `approval_kinds` left out means every kind of action waits for your approval; `[]` means none does. `edit` changes only the fields in the file, and `secrets` there replaces every assignment. The full list of fields is in [API reference → Create an automation](/docs/api#tag/automations/POST/automations). ### Triggers from the CLI @@ -453,7 +456,9 @@ event_filter: exclude_drafts: true ``` -`add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. `rotate` gives a new URL and a new signing secret; `--url` or `--secret` replaces only one, and `--no-grace` stops the old secret at once instead of after 24 hours (for a leaked one). The details of each field are in [Webhooks and schedules](/docs/webhooks). +`add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. `rotate` gives a new URL and a new signing secret; `--url` or `--secret` replaces only one, and `--no-grace` stops the old secret at once instead of after 24 hours (for a leaked one). + +For a Stripe webhook the signing secret is Stripe's, not NaN's: put it in the file you `add` as `signing_secret: whsec_...` (pipe the file with `-f -` so it is not left on disk), and `add` prints only the URL. The CLI rotates only the URL of a Stripe webhook (`--url`); to change its secret, roll it in Stripe and paste the new one in the portal, or send it through the API in `signing_secret`. The details of each field are in [Webhooks and schedules](/docs/webhooks). ### Through the API diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 6fdd773e..7bee534e 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -189,7 +189,7 @@ nan secrets set GITHUB_TOKEN --value-file ./token.txt # add it, or replace i gh auth token | nan secrets set GITHUB_READ_TOKEN --value-stdin nan secrets show GITHUB_TOKEN nan secrets edit GITHUB_TOKEN --description "repo acme/api" -nan secrets rm GITHUB_TOKEN +nan secrets rm OLD_TOKEN nan automations create -f automation.yaml nan automations edit "PR review" -f changes.yaml nan automations disable "PR review" @@ -198,7 +198,7 @@ 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. 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. -These commands use your `nan auth login` session, or a platform token with the `secrets:` or `automations:write` scope. 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). +These commands use your `nan auth login` session, 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/webhooks.mdx b/src/content/docs/webhooks.mdx index d8d89654..a2c385eb 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -46,7 +46,7 @@ Press **Add webhook**. The portal shows, **only once**: Copy them, then press **I've saved them**. Afterwards the portal only shows the start of the URL. If you lose them, [rotate](#rotate-the-url-or-the-secret) to get new ones. -From the CLI, `nan automations triggers add -f webhook.yaml` prints the same two values **once**, with a reminder to store them now; `--json` prints them as JSON for a script. Through the API, `POST /v1/automations/{id}/triggers` returns them once in `webhook_url` and `signing_secret`, with `Cache-Control: no-store`. Nothing returns them again: not the portal, not `nan automations triggers ls`, not the API. +From the CLI, `nan automations triggers add -f webhook.yaml` prints the same values **once** (for Stripe, only the URL), with a reminder to store them now; `--json` prints them as JSON for a script. Through the API, `POST /v1/automations/{id}/triggers` returns them once in `webhook_url` and `signing_secret`, with `Cache-Control: no-store`. Nothing returns them again: not the portal, not `nan automations triggers ls`, not the API. ### Set it up on the sender @@ -253,7 +253,7 @@ From the terminal: `nan automations deliveries `. From the API: [`GE - **New webhook URL**: the current URL stops working at once. - **New signing secret** (ticked by default): by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. -The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same (`--url` or `--secret` for only one, `--no-grace` to stop the old secret at once) and prints the new values once; through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`. +The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same (`--url` or `--secret` for only one, `--no-grace` to stop the old secret at once) and prints the new values once. For Stripe the CLI rotates only the URL (`--url`): change the secret in the portal, or through the API. Through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`; for Stripe, `{"rotate": ["secret"], "signing_secret": "whsec_..."}` with the new secret from Stripe. ## Loops diff --git a/src/data/openapi.json b/src/data/openapi.json index 8406d02c..c2526233 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 both inference and these endpoints. Each endpoint lists the credentials it accepts.\n\nA platform token carries **scopes**, and each endpoint names the one it needs: `runs` (runs and approvals), `workspaces:read`, `automations:read` (read automations, templates, triggers and deliveries), `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) 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.", "contact": { "name": "NaN", "url": "https://nan.builders" @@ -2854,7 +2854,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -2893,6 +2893,17 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } @@ -3414,7 +3425,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -3453,6 +3464,17 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } @@ -3549,7 +3571,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -3588,6 +3610,17 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } @@ -3923,7 +3956,7 @@ "updated_by": "cli_session" }, "webhook_url": "https://api.nan.builders/hooks/nan_whk_Q3vXexampleexampleexampleexampleexample", - "signing_secret": "whsec_exampleexampleexampleexample" + "signing_secret": "nan_whsec_Xy7kQ2mN9pR4tV6wZ1aB3cD5eF8gH0jL" } } } @@ -3955,7 +3988,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -3994,6 +4027,17 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } @@ -5397,7 +5441,7 @@ "description": "Your secrets' metadata: names, kinds, hints, descriptions and the automations that use each one. Never a value.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `secrets: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 secrets, by name.", + "description": "Your secrets, newest first.", "headers": { "Cache-Control": { "description": "Always `no-store`.", @@ -6696,7 +6740,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -6735,13 +6779,24 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } } }, "404": { - "description": "No trigger with that id among yours (another member's is also `404`, and so is a malformed id).", + "description": "No automation or trigger with that id among yours (another member's is also `404`, and so is a malformed id). The message names the automation either way.", "content": { "application/json": { "schema": { @@ -6749,7 +6804,7 @@ }, "example": { "error": { - "message": "trigger not found", + "message": "automation not found", "type": "invalid_request_error", "param": null, "code": "not_found" @@ -6823,7 +6878,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -6862,13 +6917,24 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } } }, "404": { - "description": "No trigger with that id among yours (another member's is also `404`, and so is a malformed id).", + "description": "No automation or trigger with that id among yours (another member's is also `404`, and so is a malformed id). The message names the automation either way.", "content": { "application/json": { "schema": { @@ -6876,7 +6942,7 @@ }, "example": { "error": { - "message": "trigger not found", + "message": "automation not found", "type": "invalid_request_error", "param": null, "code": "not_found" @@ -6921,7 +6987,7 @@ ], "operationId": "rotateAutomationTrigger", "summary": "Rotate a webhook URL or signing secret", - "description": "Gives a webhook a new URL, a new signing secret, or both. A new URL replaces the old one at once. A new signing secret keeps the old one working for 24 hours unless `grace` is `false`. The answer carries the new values, **the only time they are returned**, with `Cache-Control: no-store`. For `stripe`, roll the secret in Stripe first and send it as `signing_secret`.\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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`).", + "description": "Gives a webhook a new URL, a new signing secret, or both. A new URL replaces the old one at once. A new signing secret keeps the old one working for 24 hours unless `grace` is `false`. The answer carries the new values, **the only time they are returned**, with `Cache-Control: no-store`. For `stripe`, roll the secret in Stripe first and send it as `signing_secret` (required with `secret`, `400` without it).\n\nAuthentication: a CLI session (`nan auth login`) or a platform token (`nan_pat_...`) with the `automations: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", @@ -6951,11 +7017,25 @@ "schema": { "$ref": "#/components/schemas/RotateTriggerRequest" }, - "example": { - "rotate": [ - "secret" - ], - "grace": true + "examples": { + "nan_or_github": { + "summary": "A new signing secret, the old one valid for 24 hours.", + "value": { + "rotate": [ + "secret" + ], + "grace": true + } + }, + "stripe": { + "summary": "Stripe: the new secret you rolled in Stripe.", + "value": { + "rotate": [ + "secret" + ], + "signing_secret": "whsec_9f8e7d6c5b4a39281706f5e4d3c2b1a0" + } + } } } } @@ -6978,7 +7058,7 @@ "$ref": "#/components/schemas/TriggerRotated" }, "example": { - "signing_secret": "whsec_newexampleexampleexampleexample" + "signing_secret": "nan_whsec_Pq3rS5tU7vW9xY2zA4bC6dE8fG1hJ0kM" } } } @@ -7010,7 +7090,7 @@ "$ref": "#/components/responses/RunsUnauthorized" }, "403": { - "description": "A platform token without the `automations: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`).", + "description": "A platform token without the `automations: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`). Until your account has the write API, every credential but the portal gets `browser_session_required`.", "content": { "application/json": { "schema": { @@ -7049,13 +7129,24 @@ "code": "workspace_key_not_allowed" } } + }, + "browser_session_required": { + "summary": "The write API is not yet available for your account: the change is made in the portal.", + "value": { + "error": { + "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.", + "type": "permission_error", + "param": null, + "code": "browser_session_required" + } + } } } } } }, "404": { - "description": "No trigger with that id among yours (another member's is also `404`, and so is a malformed id).", + "description": "No automation or trigger with that id among yours (another member's is also `404`, and so is a malformed id). The message names the automation either way.", "content": { "application/json": { "schema": { @@ -7063,7 +7154,7 @@ }, "example": { "error": { - "message": "trigger not found", + "message": "automation not found", "type": "invalid_request_error", "param": null, "code": "not_found" @@ -11110,7 +11201,7 @@ "string", "null" ], - "description": "Webhook with `github` or `nan`: the signing secret NaN generated. `null` for a schedule, and for `stripe` (you pasted it)." + "description": "Webhook with `github` or `nan`: the signing secret NaN generated (`nan_whsec_...`). `null` for a schedule, and for `stripe` (you sent it)." } } }, @@ -11125,7 +11216,7 @@ }, "signing_secret": { "type": "string", - "description": "The new signing secret." + "description": "The new signing secret NaN generated (`nan_whsec_...`). Absent for `stripe`, whose new secret you sent." } } } diff --git a/src/lib/openapiWriteApi.test.ts b/src/lib/openapiWriteApi.test.ts index a33c9d98..77fa0fe3 100644 --- a/src/lib/openapiWriteApi.test.ts +++ b/src/lib/openapiWriteApi.test.ts @@ -243,6 +243,32 @@ describe('openapi.json: the automation writes', () => { expect(op('/automations/{id}/triggers', 'get').description).not.toMatch(/in the portal, when/); }); + it('examples look like the real answers (service.go SigningSecretPrefix, getTrigger, ListMemberSecrets)', () => { + const created = op('/automations/{id}/triggers', 'post').responses['201'].content['application/json'].example; + expect(created.signing_secret).toMatch(/^nan_whsec_/); + expect(created.webhook_url).toMatch(/^https:\/\/api\.nan\.builders\/hooks\/nan_whk_/); + const rotated = op('/automations/{id}/triggers/{trigger_id}/rotate', 'post').responses['200'].content['application/json'].example; + expect(rotated.signing_secret).toMatch(/^nan_whsec_/); + for (const [path, method] of [ + ['/automations/{id}/triggers/{trigger_id}', 'patch'], + ['/automations/{id}/triggers/{trigger_id}', 'delete'], + ['/automations/{id}/triggers/{trigger_id}/rotate', 'post'], + ]) { + expect(op(path, method).responses['404'].content['application/json'].example.error.message, `${method} ${path}`).toBe('automation not found'); + } + expect(op('/secrets', 'get').responses['200'].description).toMatch(/newest first/); + const stripe = op('/automations/{id}/triggers/{trigger_id}/rotate', 'post').requestBody.content['application/json'].examples.stripe.value; + expect(stripe.signing_secret).toMatch(/^whsec_/); + }); + + it('automation writes document browser_session_required for accounts without the write API', () => { + for (const [path, method, , tag] of OPS) { + if (tag !== 'Automations') continue; + const ex = op(path, method).responses['403'].content['application/json'].examples; + expect(ex.browser_session_required.value.error.code, `${method} ${path}`).toBe('browser_session_required'); + } + }); + it('create automation: 201, 409 name_taken, and the defaults', () => { const o = op('/automations', 'post'); expect(Object.keys(o.responses).sort()).toEqual(['201', '400', '401', '403', '404', '409', '429']); @@ -276,6 +302,11 @@ describe('openapi.json: credentials and scopes', () => { expect(pat).toMatch(/can never approve a run/); }); + 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).not.toContain('An API key works on both inference and these endpoints.'); + }); + it('the overview carries the real credential_not_allowed body', () => { 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])); diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts index fd4eae57..16e77ef8 100644 --- a/src/tests/layouts/automationsDocs.test.ts +++ b/src/tests/layouts/automationsDocs.test.ts @@ -203,11 +203,9 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) } expect(autoBody).toContain('**0.1.33**'); // A value never goes on the command line: every `nan secrets set` reads it from a file or a pipe. - for (const dir of [page.dir]) { - for (const f of ['automations.mdx', 'webhooks.mdx', 'runs.mdx', 'nan-cli.mdx']) { - for (const m of read(dir + f).matchAll(/nan secrets set [^\n`]*/g)) { - expect(m[0], `${f}: ${m[0]}`).toMatch(/--value-(file|stdin)/); - } + for (const f of ['automations.mdx', 'webhooks.mdx', 'runs.mdx', 'nan-cli.mdx']) { + for (const m of read(page.dir + f).matchAll(/nan secrets set [^\n`]*/g)) { + expect(m[0], `${f}: ${m[0]}`).toMatch(/--value-(file|stdin)/); } } for (const code of ['`403 credential_not_allowed`', '`workspace_key_not_allowed`', '`403 browser_session_required`', '`429 rate_limited`', '`409 name_taken`', '`Idempotency-Key`', '`Cache-Control: no-store`']) { @@ -219,6 +217,9 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) expect(autoBody).toContain('variant="warning"'); // The rollout: exit code 64 and the answers until the account has it. expect(autoFlat).toMatch(/\(código de salida 64\)|\(exit code 64\)/); + // The write commands' credential order (nan-cli cmd/writeapi.go newWriteEnv), not nan run's. + expect(autoBody).toMatch(/`--token-file`[^\n]*`nan auth login`[^\n]*`NAN_TOKEN`/); + expect(autoBody).not.toMatch(/platform token, as `nan run` does/); expect(autoBody).toContain('`/v1/secrets`'); // Rate limits, as in cloud-api (secretWriteBurst/automationWriteBurst). expect(autoFlat).toMatch(/\| (Changes to secrets|Cambios en secretos) \| 30 [^|]*120 [^|]*\|/); @@ -226,6 +227,10 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) }); test('webhooks: the URL and signing secret are shown once, from the CLI and the API too', () => { + // nan-cli 0.1.33 cannot send a Stripe signing secret on rotate: only --url works there. + expect(hooksBody).toMatch(/Stripe[^\n]*`--url`/); + expect(autoBody).toMatch(/Stripe[^\n]*`--url`/); + expect(hooksBody).toContain('"signing_secret": "whsec_'); expect(hooksBody).toContain('nan automations triggers add'); expect(hooksBody).toContain('nan automations triggers rotate'); expect(hooksBody).toContain('`Cache-Control: no-store`'); From c231524ce39f90f0a71169a438c66bfae4967c0e Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 16:13:21 +0200 Subject: [PATCH 6/7] docs(webhooks): rotate a Stripe secret from the CLI with --signing-secret-file/--signing-secret-stdin Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 2 +- src/content/docs-es/webhooks.mdx | 2 +- src/content/docs/automations.mdx | 2 +- src/content/docs/webhooks.mdx | 2 +- src/tests/layouts/automationsDocs.test.ts | 7 ++++--- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index f4cfa3a3..c19c81d9 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -458,7 +458,7 @@ event_filter: `add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. `rotate` da una URL nueva y un secreto de firma nuevo; `--url` o `--secret` reemplaza solo uno, y `--no-grace` hace que el secreto anterior deje de valer al momento en vez de a las 24 horas (si se filtró). -En un webhook de Stripe el secreto de firma es de Stripe, no de NaN: ponlo en el archivo que pasas a `add` como `signing_secret: whsec_...` (pásalo por una tubería con `-f -` para que no se quede en disco), y `add` imprime solo la URL. La CLI solo rota la URL de un webhook de Stripe (`--url`); para cambiar su secreto, rótalo en Stripe y pega el nuevo en el portal, o envíalo a través de la API en `signing_secret`. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). +En un webhook de Stripe el secreto de firma es de Stripe, no de NaN: ponlo en el archivo que pasas a `add` como `signing_secret: whsec_...` (pásalo por una tubería con `-f -` para que no se quede en disco), y `add` imprime solo la URL. Para rotar el secreto de un webhook de Stripe, rótalo primero en Stripe y pásale el nuevo a `rotate` con `--signing-secret-file PATH` o `--signing-secret-stdin` (se lee como el valor de un secreto y nunca se imprime); sin él, `rotate` en un webhook de Stripe se niega salvo que pases solo `--url`. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). ### A través de la API diff --git a/src/content/docs-es/webhooks.mdx b/src/content/docs-es/webhooks.mdx index 22617ec3..b5bb55f6 100644 --- a/src/content/docs-es/webhooks.mdx +++ b/src/content/docs-es/webhooks.mdx @@ -253,7 +253,7 @@ Desde la terminal: `nan automations deliveries `. Desde la API: [`GE - **New webhook URL**: la URL actual deja de funcionar al momento. - **New signing secret** (marcado por defecto): por defecto, el secreto anterior sigue funcionando 24 horas, para que puedas actualizar el emisor sin perder eventos. Desmarca **Keep the old secret valid for 24 hours** si el anterior se ha filtrado: deja de funcionar al momento. Para Stripe, rota antes el secreto en Stripe y pega el nuevo `whsec_...`. -Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo (`--url` o `--secret` para solo uno, `--no-grace` para que el secreto anterior deje de valer al momento) e imprime los valores nuevos una sola vez. En Stripe, la CLI solo rota la URL (`--url`): cambia el secreto en el portal o a través de la API. A través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`; en Stripe, `{"rotate": ["secret"], "signing_secret": "whsec_..."}` con el secreto nuevo de Stripe. +Los valores nuevos se muestran una sola vez, como cuando añadiste el webhook. Desde la CLI, `nan automations triggers rotate ` hace lo mismo (`--url` o `--secret` para solo uno, `--no-grace` para que el secreto anterior deje de valer al momento) e imprime los valores nuevos una sola vez. En Stripe, rota primero el secreto en Stripe y pásalo con `--signing-secret-file PATH` o `--signing-secret-stdin` (o rota solo la URL con `--url`). A través de la API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` con `{"rotate": ["url", "secret"], "grace": true}`; en Stripe, `{"rotate": ["secret"], "signing_secret": "whsec_..."}` con el secreto nuevo de Stripe. ## Bucles diff --git a/src/content/docs/automations.mdx b/src/content/docs/automations.mdx index c3c92f25..cf99f9fa 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -458,7 +458,7 @@ event_filter: `add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. `rotate` gives a new URL and a new signing secret; `--url` or `--secret` replaces only one, and `--no-grace` stops the old secret at once instead of after 24 hours (for a leaked one). -For a Stripe webhook the signing secret is Stripe's, not NaN's: put it in the file you `add` as `signing_secret: whsec_...` (pipe the file with `-f -` so it is not left on disk), and `add` prints only the URL. The CLI rotates only the URL of a Stripe webhook (`--url`); to change its secret, roll it in Stripe and paste the new one in the portal, or send it through the API in `signing_secret`. The details of each field are in [Webhooks and schedules](/docs/webhooks). +For a Stripe webhook the signing secret is Stripe's, not NaN's: put it in the file you `add` as `signing_secret: whsec_...` (pipe the file with `-f -` so it is not left on disk), and `add` prints only the URL. To rotate a Stripe webhook's secret, roll it in Stripe first and give the new one to `rotate` with `--signing-secret-file PATH` or `--signing-secret-stdin` (read like a secret value, never printed); without it, `rotate` on a Stripe webhook refuses unless you pass only `--url`. The details of each field are in [Webhooks and schedules](/docs/webhooks). ### Through the API diff --git a/src/content/docs/webhooks.mdx b/src/content/docs/webhooks.mdx index a2c385eb..1310fc8c 100644 --- a/src/content/docs/webhooks.mdx +++ b/src/content/docs/webhooks.mdx @@ -253,7 +253,7 @@ From the terminal: `nan automations deliveries `. From the API: [`GE - **New webhook URL**: the current URL stops working at once. - **New signing secret** (ticked by default): by default the old secret keeps working for 24 hours, so you can update the sender without losing events. Untick **Keep the old secret valid for 24 hours** if the old one leaked: it stops working at once. For Stripe, roll the secret in Stripe first and paste the new `whsec_...`. -The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same (`--url` or `--secret` for only one, `--no-grace` to stop the old secret at once) and prints the new values once. For Stripe the CLI rotates only the URL (`--url`): change the secret in the portal, or through the API. Through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`; for Stripe, `{"rotate": ["secret"], "signing_secret": "whsec_..."}` with the new secret from Stripe. +The new values are shown once, like when you added the webhook. From the CLI, `nan automations triggers rotate ` does the same (`--url` or `--secret` for only one, `--no-grace` to stop the old secret at once) and prints the new values once. For Stripe, roll the secret in Stripe first and pass it with `--signing-secret-file PATH` or `--signing-secret-stdin` (or rotate only the URL with `--url`). Through the API, `POST /v1/automations/{id}/triggers/{trigger_id}/rotate` with `{"rotate": ["url", "secret"], "grace": true}`; for Stripe, `{"rotate": ["secret"], "signing_secret": "whsec_..."}` with the new secret from Stripe. ## Loops diff --git a/src/tests/layouts/automationsDocs.test.ts b/src/tests/layouts/automationsDocs.test.ts index 16e77ef8..548e86a5 100644 --- a/src/tests/layouts/automationsDocs.test.ts +++ b/src/tests/layouts/automationsDocs.test.ts @@ -227,9 +227,10 @@ describe.each(Object.entries(pages))('automations guides (%s)', (_locale, page) }); test('webhooks: the URL and signing secret are shown once, from the CLI and the API too', () => { - // nan-cli 0.1.33 cannot send a Stripe signing secret on rotate: only --url works there. - expect(hooksBody).toMatch(/Stripe[^\n]*`--url`/); - expect(autoBody).toMatch(/Stripe[^\n]*`--url`/); + // nan-cli 0.1.33: a Stripe secret is rotated with the new whsec_ from a file or a pipe. + for (const body of [hooksBody, autoBody]) { + expect(body).toMatch(/Stripe[^\n]*`--signing-secret-file PATH` (o|or) `--signing-secret-stdin`/); + } expect(hooksBody).toContain('"signing_secret": "whsec_'); expect(hooksBody).toContain('nan automations triggers add'); expect(hooksBody).toContain('nan automations triggers rotate'); From 77ca4904edbc149a9b128f22f60fe9a393c88a75 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 14 Oct 2026 16:17:19 +0200 Subject: [PATCH 7/7] docs(write-api): review nits: triggers credential order, session wins over NAN_TOKEN, Stripe add wording, URL base Co-Authored-By: Claude Opus 5.5 --- src/content/docs-es/automations.mdx | 6 +++--- src/content/docs-es/nan-cli.mdx | 2 +- src/content/docs/automations.mdx | 4 ++-- src/content/docs/nan-cli.mdx | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/content/docs-es/automations.mdx b/src/content/docs-es/automations.mdx index c19c81d9..d16fcd81 100644 --- a/src/content/docs-es/automations.mdx +++ b/src/content/docs-es/automations.mdx @@ -353,7 +353,7 @@ nan secrets ls # nombres y metadatos, nunca valores `nan run`, `nan runs logs -f` y `nan automations run` salen con código **10** cuando el run se para esperando aprobación, e imprimen los comandos para leer y rechazar la propuesta y el enlace para aprobarla. -Para crear y cambiar automatizaciones, triggers y secretos desde la terminal, consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). No hay ningún comando para aprobar: la CLI te dice dónde hacerlo en el portal. `nan automations ls`, `show`, `run` y `deliveries` se autentican igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). Los comandos `nan secrets` y los que cambian automatizaciones eligen la credencial en su propio orden, descrito en [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). +Para crear y cambiar automatizaciones, triggers y secretos desde la terminal, consulta [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). No hay ningún comando para aprobar: la CLI te dice dónde hacerlo en el portal. `nan automations ls`, `show`, `run` y `deliveries` se autentican igual que `nan run` ([Autenticación](/es/docs/runs#autenticación)). Los comandos `nan secrets`, `nan automations triggers` y los que cambian automatizaciones eligen la credencial en su propio orden, descrito en [Gestiónalas desde la CLI o la API](#gestiónalas-desde-la-cli-o-la-api). ### Desde la API @@ -405,7 +405,7 @@ nan secrets rm GITHUB_READ_TOKEN - **El valor nunca va en la línea de comandos**, donde se quedaría en el historial de tu shell y otros programas podrían verlo: `--value-file PATH` lo lee de un archivo y `--value-stdin` de una tubería. `--value-stdin` se niega a leer de la terminal, así que nunca se teclea un valor a la vista. - Se quita un salto de línea al final (los archivos suelen acabar en uno). Aparte de eso, el valor no puede empezar ni terminar con un salto de línea. - `set` crea el secreto o **reemplaza el valor** del que tenga ese nombre. El tipo, el proveedor y el nombre quedan fijos al crearlo. -- `edit` cambia la descripción, o la base URL de una clave de proveedor compatible con OpenAI. +- `edit` cambia la descripción, o la URL base de una clave de proveedor compatible con OpenAI. - `rm` pregunta antes de borrar (si respondes que no, sale con código 1); `--yes` se salta la pregunta en scripts. Se rechaza mientras una automatización use el secreto, y la CLI te dice cuáles. - Con un token, nombrar un secreto que ya existe necesita `secrets:read` para buscar el nombre; un token solo con `secrets:write` puede crear un secreto nuevo. - `--json` imprime los metadatos en JSON. @@ -458,7 +458,7 @@ event_filter: `add` y `rotate` imprimen la URL del webhook y el secreto de firma **una sola vez**: guárdalos en ese momento, por ejemplo directamente en los ajustes del webhook en GitHub o en tu gestor de contraseñas. No se vuelven a mostrar; si los pierdes, rótalos. `--json` los imprime en JSON, para que un script los pase a otro sitio. `rotate` da una URL nueva y un secreto de firma nuevo; `--url` o `--secret` reemplaza solo uno, y `--no-grace` hace que el secreto anterior deje de valer al momento en vez de a las 24 horas (si se filtró). -En un webhook de Stripe el secreto de firma es de Stripe, no de NaN: ponlo en el archivo que pasas a `add` como `signing_secret: whsec_...` (pásalo por una tubería con `-f -` para que no se quede en disco), y `add` imprime solo la URL. Para rotar el secreto de un webhook de Stripe, rótalo primero en Stripe y pásale el nuevo a `rotate` con `--signing-secret-file PATH` o `--signing-secret-stdin` (se lee como el valor de un secreto y nunca se imprime); sin él, `rotate` en un webhook de Stripe se niega salvo que pases solo `--url`. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). +En un webhook de Stripe el secreto de firma es de Stripe, no de NaN: ponlo en el archivo que pasas a `add` como `signing_secret: whsec_...` (o añade el webhook de Stripe en el portal, para que el secreto nunca se escriba en un archivo), y `add` imprime solo la URL. Para rotar el secreto de un webhook de Stripe, rótalo primero en Stripe y pásale el nuevo a `rotate` con `--signing-secret-file PATH` o `--signing-secret-stdin` (se lee como el valor de un secreto y nunca se imprime); sin él, `rotate` en un webhook de Stripe se niega salvo que pases solo `--url`. El detalle de cada campo está en [Webhooks y programaciones](/es/docs/webhooks). ### A través de la API diff --git a/src/content/docs-es/nan-cli.mdx b/src/content/docs-es/nan-cli.mdx index 7f3e8a61..210486e3 100644 --- a/src/content/docs-es/nan-cli.mdx +++ b/src/content/docs-es/nan-cli.mdx @@ -198,7 +198,7 @@ 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. 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. -Estos comandos usan tu sesión de `nan auth login`, 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). +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/automations.mdx b/src/content/docs/automations.mdx index cf99f9fa..465eb18f 100644 --- a/src/content/docs/automations.mdx +++ b/src/content/docs/automations.mdx @@ -353,7 +353,7 @@ nan secrets ls # names and metadata, never values `nan run`, `nan runs logs -f` and `nan automations run` exit with code **10** when the run stops waiting for approval, and print the commands to read and reject the proposal and the link to approve it. -To create and change automations, triggers and secrets from the terminal, see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). There is no command to approve: the CLI tells you where to do it in the portal. `nan automations ls`, `show`, `run` and `deliveries` authenticate like `nan run` ([Authentication](/docs/runs#authentication)). The `nan secrets` commands and the ones that change automations pick their credential in their own order, described in [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). +To create and change automations, triggers and secrets from the terminal, see [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). There is no command to approve: the CLI tells you where to do it in the portal. `nan automations ls`, `show`, `run` and `deliveries` authenticate like `nan run` ([Authentication](/docs/runs#authentication)). The `nan secrets` commands, `nan automations triggers` and the commands that change automations pick their credential in their own order, described in [Manage them from the CLI or the API](#manage-them-from-the-cli-or-the-api). ### From the API @@ -458,7 +458,7 @@ event_filter: `add` and `rotate` print the webhook URL and the signing secret **once**: store them now, for example straight into GitHub's webhook settings or your password manager. They are never shown again; if you lose them, rotate. `--json` prints them as JSON, for a script to pass on. `rotate` gives a new URL and a new signing secret; `--url` or `--secret` replaces only one, and `--no-grace` stops the old secret at once instead of after 24 hours (for a leaked one). -For a Stripe webhook the signing secret is Stripe's, not NaN's: put it in the file you `add` as `signing_secret: whsec_...` (pipe the file with `-f -` so it is not left on disk), and `add` prints only the URL. To rotate a Stripe webhook's secret, roll it in Stripe first and give the new one to `rotate` with `--signing-secret-file PATH` or `--signing-secret-stdin` (read like a secret value, never printed); without it, `rotate` on a Stripe webhook refuses unless you pass only `--url`. The details of each field are in [Webhooks and schedules](/docs/webhooks). +For a Stripe webhook the signing secret is Stripe's, not NaN's: put it in the file you `add` as `signing_secret: whsec_...` (or add the Stripe webhook in the portal, so the secret is never written to a file), and `add` prints only the URL. To rotate a Stripe webhook's secret, roll it in Stripe first and give the new one to `rotate` with `--signing-secret-file PATH` or `--signing-secret-stdin` (read like a secret value, never printed); without it, `rotate` on a Stripe webhook refuses unless you pass only `--url`. The details of each field are in [Webhooks and schedules](/docs/webhooks). ### Through the API diff --git a/src/content/docs/nan-cli.mdx b/src/content/docs/nan-cli.mdx index 7bee534e..a47c1951 100644 --- a/src/content/docs/nan-cli.mdx +++ b/src/content/docs/nan-cli.mdx @@ -198,7 +198,7 @@ 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. 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. -These commands use your `nan auth login` session, 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). +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).