API Reference
Everything you can do in the dashboard you can do with the API. Create projects, trigger deploys, run workflows, manage nodes, rotate secrets — all with a single bearer token. Use it to build CI bots, coding-agent integrations, or whatever else fits your workflow.
All endpoints accept and return JSON unless noted. The base URL for the hosted service is https://api.test.buildingswell.com — endpoint paths below are appended directly to it (there is no /api prefix).
Authentication
BTS supports two authentication methods:
Session cookies
The web dashboard signs in via GitHub OAuth or username/password and receives a JWT stored in an HTTP-only cookie. This is handled automatically when using the dashboard; API clients should use API keys instead.
API keys
For programmatic access, create an API key from Settings > API Keys in the dashboard (or via POST /auth/api-keys). Keys are prefixed with nxs_ and sent via the Authorization header:
curl https://api.test.buildingswell.com/projects \
-H "Authorization: Bearer nxs_your_api_key_here"API keys are hashed with bcrypt before storage. The plaintext key is shown only once at creation time — if you lose it, generate a new one.
Scopes
A key can be narrowed at creation time along three independent axes:
scopes— a list of catalog permissions (below). The key may only perform operations covered by those permissions, and it can never exceed what its owner is allowed to do in a workspace.scopes: null(the default, and the behaviour of every key created before scopes existed) means the key inherits the full access of its owner.teamId— a workspace pin. The key only works against that one workspace (team or personal tenant).projectIds— a project pin. A key with a non-nullprojectIdsis project-scoped: it may only act on those specific projects, and it is denied every workspace-level resource outright — nodes, quotas, teams and members, the activity feed, and creating projects. Endpoints marked with*in the tables below fall into that workspace-level category.
These are the permissions a key's scopes can grant:
| Permission | Label | What it grants |
|---|---|---|
projects:read | Read projects | View projects, environments and stats |
projects:write | Create & delete projects | Add new projects to the workspace and delete existing ones |
project:settings | Change project settings | Edit build config, resource limits, services, nexos.yaml and the webhook secret |
deployments:read | Read deployments | View deployments, builds and logs |
deployments:write | Manage deployments | Deploy, rebuild, restart, pause, resume and destroy environments |
deployments:exec ⚠ | Shell & SSH access | Run commands inside a running container and enable SSH. Grants access to every injected secret. |
env:read ⚠ | Read env vars & secrets | View the decrypted values of env vars, secrets and build args |
env:write ⚠ | Manage env vars & secrets | Create, edit and delete env vars, secrets and build args |
data:manage | Manage databases & data | Enable addons, run SQL/Redis consoles, upload and apply seeds, reset databases, and view addon credentials |
workflows:read | Read workflows | View workflow definitions, runs and job results |
workflows:logs ⚠ | Read workflow logs | View job transcripts in BTS. Workflow steps run with every project secret injected, so a step that echoes its environment exposes them here. While a project puts logs on its GitHub checks (on by default), a masked copy is readable by anyone who can read the repository, with or without this. |
workflows:write | Run workflows | Dispatch, re-run and cancel runs. Dispatching and re-running also need shell access, since a run executes commands with every secret injected. |
endpoints:read | Read endpoints | View external TCP endpoints and their connection strings |
endpoints:manage | Manage endpoints | Create and revoke external TCP endpoints |
nodes:read | Read nodes | View nodes and their capacity |
nodes:write | Manage nodes | Enroll, configure and remove nodes |
members:manage | Manage members | Invite, remove and set permissions for members |
billing:manage | Manage quotas | View and adjust the workspace's resource quotas |
automation:use | Use automation | Create goals, view their tasks, attempts and events, answer requests addressed to you, and draft templates and agents. Members on the role default have it; a member with a saved custom permission list must be granted it explicitly. |
automation:operate | Operate automation | Pause and resume dispatch, cancel and stop work, raise limits, publish templates and agents, set the default template, and manage runner slots, the BTS credential and tool connections |
⚠ marks a sensitive capability — it exposes secret material or grants a path to it (for example, a shell in the app container can read every injected secret).
billing:manage. The name is historical — nothing is billed. It gates the workspace quota surface (GET /quotas/me and the build-concurrency knob).Discovering your access
Three endpoints let a client (human or agent) work out what it can do without trial and error:
GET /auth/me— returns the authenticated user and anauthobject describing the credential in use. For API keys,auth.apiKeycarries the key'sscopes(an array, ornullfor full inherit), itsteamIdworkspace pin, and itsprojectIdsproject pin — so a key can introspect its own grants.GET /auth/permissions— the full permission catalog (the table above), machine-readable.GET /openapi.json— this entire reference as an OpenAPI 3.1 document. Every operation carries its required permission in thex-required-permissionextension, and the catalog is embedded asx-permission-catalog. No authentication required.
Per-project resolution is also available: GET /projects/:id returns access.permissions — exactly what the calling credential may do on that project after every narrowing (member override, key scopes, pins) is applied.
Endpoints
The Required scope column shows the catalog permission the endpoint enforces. auth only means any authenticated caller with access to the resource may call it; public means no authentication. A + joins permissions that are all required. * marks workspace-level endpoints, which are denied to project-scoped API keys regardless of scopes.
Repositories
Linked GitHub repositories of a project (multi-repo projects, Enterprise). Every project has one primary repo; each container builds from one linked repo and an environment resolves, per repo, either its own branch or the repo's fallback branch.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /projects/{id}/github-app/install | Where to install the GitHub App for this project One target per repo owner still lacking the App: `new` opens GitHub's install screen for that account with the project's repos preselected where their ids are readable ("Only select repositories" either way); `configure` is the existing installation's settings page, where the repos are added. Re-probes repos without a cached installation id, so calling it after installing refreshes `repos[].installationId`. | projects:read |
GET | /projects/{id}/repos | List a project's linked repositories Primary first. Every project has at least one (the repo it was created with). | projects:read |
POST | /projects/{id}/repos | Link a repository Linking a SECOND repo requires the Enterprise plan (403 `plan_required`) and at least one container on the project (409 `containers_required`). Other codes: `repo_already_linked` (409), `repo_cap_reached` (422), `repo_inaccessible` (422), `invalid_repo_url` (400). The fallback branch defaults to GitHub's default branch. | project:settings |
PATCH | /projects/{id}/repos/{repoId} | Update a linked repository A new fallbackBranch is verified to exist (422 `branch_not_found`). `isPrimary: true` makes this repo the primary (the previous primary is demoted atomically). | project:settings |
DELETE | /projects/{id}/repos/{repoId} | Unlink a repository Refused while the repo is primary (409 `reassign_primary_first`) or while containers build from it (409 `containers_bound`, body lists `containers`). | project:settings |
GET | /projects/{id}/repos/{repoId}/github-app | Is the GitHub App installed on this repo? (live) Asks GitHub now (`GET /repos/{owner}/{repo}/installation` with the App JWT) instead of trusting the repo's cached `githubInstallationId`, and writes the answer back to the cache — a 404 clears a stale id. `ready` is the one field a "did the install work?" check needs: pushes and PRs on this repo reach this project through the App, with no repo webhook (installed, this project has proven read access — see verify-access — and the platform's App webhook secret is set). Otherwise `reason` says why: `not_configured` (no App on this platform: use a repo webhook), `not_installed` (open `installUrl`, scoped to this repo; `installKind: 'configure'` means add it to the account's existing installation), `access_unverified` (installed, but BTS won't use it for this project until it proves access; installing again changes nothing), or `app_webhook_unconfigured` (the platform's App has no webhook secret, so a repo webhook is still required). `live: false` means GitHub couldn't be asked and the cached answer was returned. When `ready`, don't also add a repo webhook: both together make every push deploy twice. | projects:read |
POST | /projects/{id}/repos/{repoId}/refresh-default-branch | Re-read GitHub's default branch Overwrites the stored fallback branch only when it was auto-detected; a manual choice is left alone. | project:settings |
POST | /projects/{id}/repos/{repoId}/verify-access | Prove this project can read the repo Checks, with the tenant's own credentials only (the repo's PAT, the owner's or a member's GitHub token, or anonymously for a public repo), that the repo is readable, and records the result. BTS uses the GitHub App's installation token for a repo only while such a proof is current: an installation belongs to whoever installed the App, not to every tenant that links the repo. Also verified lazily on first need; fresh for 24h, and a proof via a person lapses when they leave the tenant. `reason` is `no_credential_can_read`, `transient` (GitHub unavailable; any earlier proof is kept), or `identity_mismatch` (the row's clone URL is not the github.com URL of the repo it names, or the repo changed while the check ran — nothing is recorded). | project:settings |
Authentication
Sign-in, sessions, API keys, the CLI device-code flow, and access discovery.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /auth/api-keys | List the caller's API keys | auth only |
POST | /auth/api-keys | Create an API key Optionally scoped: `scopes` narrows the key to a subset of catalog permissions (a key can never exceed its owner), `teamId` pins it to one workspace, and `projectIds` restricts it to specific projects (a project-scoped key is denied every workspace-level resource). The plaintext key is returned exactly once. | auth only |
DELETE | /auth/api-keys/{id} | Revoke an API key With an API key, only that key itself can be revoked (a CLI client's logout); revoking any other key needs a dashboard session. | auth only |
POST | /auth/cli/device-code | Start the CLI device-code flow Mints a device_code + user_code pair. The client polls /auth/cli/device-code/token with the device_code while the user approves the user_code in the dashboard. | public |
POST | /auth/cli/device-code/approve | Approve a device code Mints an API key under the caller's account and stashes it (encrypted) for the polling client. Meant to be called from the dashboard. | auth only |
POST | /auth/cli/device-code/deny | Deny a device code | auth only |
GET | /auth/cli/device-code/lookup | Look up a pending device code | auth only |
POST | /auth/cli/device-code/token | Poll for the approved API key Returns the freshly minted API key exactly once after approval; subsequent polls return expired. | public |
GET | /auth/github/app | GitHub App availability and install URL | public |
POST | /auth/github/callback | Complete GitHub OAuth sign-in Exchanges the OAuth code, upserts the user, and sets the session cookie. Requires the state cookie from /auth/github/start. | public |
POST | /auth/github/link | Link a GitHub account to the current user Also verifies the account's email when it is one of the GitHub account's verified addresses, and fills in a missing email from GitHub (stored verified). | auth only |
GET | /auth/github/start | Start the GitHub OAuth flow Sets a CSRF state cookie and 302-redirects to GitHub. `?flow=link` marks the attach-GitHub-to-existing-account variant. | public |
POST | /auth/login | Log in with username/password | public |
POST | /auth/logout | Clear the session cookie | public |
GET | /auth/me | Current user + auth introspection Returns the authenticated user (tenants, terms status, GitHub link state, and `emailVerified`: whether GitHub verified the account's current email, which accepting a team invite requires) plus an `auth` object describing HOW the request authenticated: `auth.method` is "session" or "apiKey", and for API keys `auth.apiKey` carries { id, name, keyPrefix, scopes, teamId, projectIds, createdAt, lastUsedAt }. `id` is the current key's non-secret immutable UUID, used to bind protected execution to the actual authenticated key; the secret key value is never returned. `scopes: null` means the key inherits the full access of its owner; a non-null `projectIds` marks a project-scoped key. | auth only |
PUT | /auth/me/email | Set or update the account email BTS sends no email, so it cannot verify an address itself. A changed address is stored unverified unless it is one of the verified addresses on the user's linked GitHub account (going back to an earlier address asks GitHub again); re-saving the current, verified address keeps its verification without asking. | auth only |
POST | /auth/me/email/verify | Verify the account email with GitHub Asks GitHub whether the account's current email is a verified address on the linked GitHub account, and records it if so. Never changes the address. `emailVerified: false` when GitHub doesn't vouch for it, the account has no GitHub token, or GitHub can't be reached. 400 `email_missing` when the account has no email. | auth only |
GET | /auth/permissions | The permission catalog Static catalog of every grantable permission, grouped and labeled — the same source of truth the member-permission editor and the API-key scope picker render from. | auth only |
POST | /auth/register | Create a username/password account | public |
POST | /auth/terms/accept | Accept the current terms of service | auth only |
GET | /auth/ws-token | Mint a short-lived JWT for WebSocket auth | auth only |
Projects
Projects, their configuration, nexos.yaml import/export, and stats.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /projects | List projects the caller can see Project rows never carry `githubWebhookSecret` here, only `hasGithubWebhookSecret`; read it from `GET /projects/:id/webhook-secret` with `project:settings`. | projects:read |
POST | /projects | Create a project | projects:write * |
GET | /projects/{id} | Project details Project row plus its linked repos, its environments (with derived status, containers, latest CI run), `recentDeployments` — the latest build of each environment in `environments` (live ones, or every one with `includeArchived=true`), as a summary: `id`, `environmentId`, `status`, `type`, `trigger`, `gitBranch`, `gitCommitSha`, `gitCommitMessage`, `gitAuthor`, `pullRequestNumber`, `buildStartedAt`, `buildFinishedAt`, `deployFinishedAt`, `secretsWithheld`, `createdAt`, `sources[]` — owning team, and `access.permissions` (what THIS caller may do on the project). Summaries carry no build output: read logs from `GET /deployments/:id/logs`, and the failure message and nexos.yaml report from `GET /deployments/:id` (both `deployments:read`). Every caller gets `project.hasGithubWebhookSecret`; the secret itself is never in this response. Read it from `GET /projects/:id/webhook-secret` (`project:settings`). Until spec 012 this response carried `project.githubWebhookSecret` for `project:settings` holders; scripts that read it here must switch. | projects:read |
PATCH | /projects/{id} | Update project settings | project:settings |
DELETE | /projects/{id} | Delete a project | projects:write |
POST | /projects/{id}/detect-framework | Re-run framework detection | project:settings |
GET | /projects/{id}/environments | List a project's environments | projects:read |
GET | /projects/{id}/nexos-yaml | Export the project as nexos.yaml | projects:read |
PUT | /projects/{id}/nexos-yaml | Apply a nexos.yaml to the project Env vars, build args and container env are written only for callers holding env:write; for anyone else those blocks are ignored and the response carries an env_ignored warning. | project:settings |
POST | /projects/{id}/nexos-yaml/validate | Validate a nexos.yaml without applying it Also a dry run against the project: `changes` is what applying would change, `warnings` what it would skip or override (unknown keys, plan-gated addons, unavailable versions, addons left enabled). Env values are never included. | projects:read |
POST | /projects/{id}/regenerate-secret | Regenerate the GitHub webhook secret | project:settings |
GET | /projects/{id}/stats | Deployment stats (daily buckets) | projects:read |
GET | /projects/{id}/webhook-secret | Read the GitHub webhook secret The secret that authenticates the project's legacy repository webhook deliveries (`POST /webhooks/github`), and the only read that returns it. Sent with `Cache-Control: no-store`. | project:settings |
POST | /projects/import-yaml | Create a project from a nexos.yaml document | projects:write * |
GET | /projects/lookup | Look up projects by GitHub repo Used by the MCP/CLI to recognize an already-linked repo. Scoped to the caller's accessible tenants and project pins. | auth only |
Deployments & Environments
Deploy history, build logs, and environment lifecycle actions.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /deployments/{id} | Deployment details | deployments:read |
POST | /deployments/{id}/cancel | Cancel an in-flight deployment | deployments:write |
POST | /deployments/{id}/config/apply | Apply a branch's pending nexos.yaml to the project Only a project's default branch applies nexos.yaml automatically; another branch's file is a dry run recorded on its deployment. This applies that deployment's file for real — project-wide, since config has no per-environment layer — re-read from GitHub at the same commit. Redeploy the environment afterwards to use it. A file with an `env:`, `build.args` or container `env:` block also needs env:write (it writes those keys, and deletes the YAML-managed ones a block no longer lists). | project:settings |
GET | /deployments/{id}/logs | Build logs Persisted log text plus any lines still in the in-memory flush buffer — the same backlog the WebSocket tail starts from. | deployments:read |
DELETE | /environments/{id} | Archive an environment Tears down containers and marks the row archived (terminal — a later push to the branch creates a fresh environment). Deployment history is preserved. | deployments:write |
POST | /environments/{id}/pause | Pause an environment Stops every per-environment container (and its node's copy of the addon side-cars when this is the project's last active environment on that node). State persists; /resume normally restarts it without a rebuild. | deployments:write |
POST | /environments/{id}/rebuild | Rebuild an environment from its latest commit Zero-downtime: the old container keeps serving until the new build passes its listening probe. Alias: POST /environments/:id/restart (deprecated). | deployments:write |
POST | /environments/{id}/reset-db | Reset the environment's database Drops and recreates the env's logical database, then re-applies the configured seed. The app is quiesced for the duration; progress is reported via the seed-apply status. | deployments:write |
POST | /environments/{id}/restart | Legacy alias of /environments/:id/rebuild (deprecated) | deployments:write |
POST | /environments/{id}/resume | Resume a paused environment Normally a plain restart of the stopped containers. If an addon changed while the environment was paused — added, switched back on (switching off removes every copy) or moved to another version or variant, or its data reset by a major version change — the containers would not come back to the database they expect, so the environment is rebuilt instead: the response has `rebuilding: true` and a `message`, and the preview is back when that deploy finishes. 400 when there is nothing to resume, 429 over the concurrent-environment quota, 409 `capacity_busy` (builds or CI jobs in flight hold the room; `retryable: true`) or `capacity_exceeded` (running environments alone fill it) when the tenant's resource pool — or, for an environment on the shared pool, the plan quota (`ceiling: "shared"`) — has no room for its containers, 502 when the node can't be reached. Refused resumes leave the environment paused. | deployments:write |
POST | /environments/{id}/trigger-auto-resume | Wake an auto-paused environment Public by design: called by the preview proxy / landing page when a sleeping preview gets traffic. Only environments the auto-pause sweeper put to sleep can be woken this way — manually paused environments are refused. Rate limited per environment. Judged against the resource pool like a manual resume: 409 `capacity_busy` (`retryable: true`) / `capacity_exceeded` when there is no room, and the environment stays paused. Being public, it names no plan, pool or usage figure; the manual resume gives the reason. | public |
GET | /environments/check-name | Check environment-name availability Names become preview subdomains, so availability is platform-global. | auth only |
GET | /projects/{id}/deployments | List deployments for a project The newest 50 deployments of a project, or of one environment. Rows carry every deployment field except the build log: read it from `GET /deployments/:id/logs`. | deployments:read |
POST | /projects/{id}/deployments | Trigger a deployment (CLI/API deploy) Creates the environment on first deploy of a branch (optionally on a user-chosen subdomain via `name`) and enqueues a build. Supersedes any in-flight deployment of the same environment. | deployments:write |
Environment Variables & Build Args
The project's secret surface: env vars, per-service env vars, and Docker build args.
| Method | Path | Description | Required scope |
|---|---|---|---|
PATCH | /build-args/{id} | Update a build arg | env:write |
DELETE | /build-args/{id} | Delete a build arg | env:write |
PATCH | /env-vars/{id} | Update an env var | env:write |
DELETE | /env-vars/{id} | Delete an env var | env:write |
GET | /env-vars/variables | List supported interpolation variables | public |
GET | /projects/{id}/build-args | List build args | projects:read |
POST | /projects/{id}/build-args | Create a build arg | env:write |
GET | /projects/{id}/env-vars | List project env vars | projects:read |
POST | /projects/{id}/env-vars | Create an env var | env:write |
POST | /projects/{id}/env-vars/import | Bulk import from .env format | env:write |
PATCH | /service-env-vars/{id} | Update a per-service env var | env:write |
DELETE | /service-env-vars/{id} | Delete a per-service env var | env:write |
GET | /services/{id}/env-vars | List per-service env vars | projects:read |
POST | /services/{id}/env-vars | Create a per-service env var | env:write |
Services
Multi-container service definitions per project.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /projects/{id}/services | List a project's services | projects:read |
POST | /projects/{id}/services | Add a service | project:settings |
PATCH | /services/{id} | Update a service | project:settings |
DELETE | /services/{id} | Delete a service | project:settings |
Addons & Data
Managed addons (Postgres, Redis, …), seeds/backups, dumps, consoles, inbox, and queue browsers.
| Method | Path | Description | Required scope |
|---|---|---|---|
PATCH | /addons/{id} | Update an addon (version / variant / enabled / storage / app login) enabled=false stops the addon on every node now (its data is kept). A version/variant in the same major is applied by each node on its next deploy. A major version change is refused with 409 `addon_data_reset_required` (body.environments = environments with its data; body.fromVersion / body.toVersion = the current and requested versions) unless resetData=true, which starts every environment's data for the addon fresh and rebuilds the live ones. | data:manage |
DELETE | /addons/{id} | Remove an addon from a project and delete its data Deletes the addon's side-car and data on every node, and every environment's slice of it. Requires destroyData=true (400 `confirmation_required` otherwise); switch the addon off instead to keep the data. | data:manage |
GET | /addons/catalog | Addon catalog Available addon types, versions, variants, per-addon capabilities (consoles, seeds, dumps) and the env var names each injects (`injectedEnv`; a named instance injects the same names prefixed with its name). | public |
GET | /environments/{id}/addons | Per-environment addon connection details In-cluster hostnames plus the decrypted login the environment's app holds: its own (credentialScope "environment") for Postgres/MySQL/MongoDB with per-environment logins, otherwise the project's shared admin account ("project"; credentialPending: true when the environment switches to its own login on its next deploy). | data:manage |
POST | /environments/{id}/addons/{type}/console | Run an addon console command Executes one console input (SQL, redis-cli, …) inside the addon side-car, scoped to this environment's slice. Which consoles exist comes from GET /addons/catalog. | data:manage |
GET | /environments/{id}/addons/{type}/dump | Data-export (dump) state | data:manage + deployments:read |
POST | /environments/{id}/addons/{type}/dump | Start a data export (pg_dump) | data:manage + deployments:read |
DELETE | /environments/{id}/addons/{type}/dump | Discard a dump artifact | data:manage + deployments:read |
GET | /environments/{id}/addons/{type}/dump/download | Download the finished dump | data:manage + deployments:read |
GET | /environments/{id}/addons/{type}/seed | Per-environment seed state Override + project-default metadata, applied stamp, and live apply status (the dashboard polls this while a restore runs). | data:manage |
PUT | /environments/{id}/addons/{type}/seed | Set a per-environment SQL seed override | data:manage |
DELETE | /environments/{id}/addons/{type}/seed | Clear a per-environment seed override | data:manage |
POST | /environments/{id}/addons/{type}/seed/apply | Force-apply the seed to this environment DROP DATABASE + restore of the effective seed (override → project default), run in the background. Poll GET …/seed for status. | data:manage + deployments:write |
PUT | /environments/{id}/addons/{type}/seed/backup | Upload a backup to one environment Stores the backup as the env's seed override. With ?applyNow=true (or ?restoreNow=true) it is restored immediately over the env's live database. | data:manage |
GET | /environments/{id}/connection-info | Environment connection info Preview URL, status, SSH state, and every addon's in-cluster connection details — the login the environment's app holds (credentialScope "environment": its own, reaching only its database; "project": the shared admin account), with a one-line credentialNote. Addon passwords are included only for callers holding data:manage (otherwise passwordRedacted: true). | deployments:read |
POST | /environments/{id}/db/query | Legacy alias: Postgres SQL console (deprecated) | data:manage |
GET | /environments/{id}/email | List/search the environment's inbox (Mailpit) | data:manage |
GET | /environments/{id}/email/chaos | SMTP chaos-injection state | data:manage |
PUT | /environments/{id}/email/chaos | Toggle SMTP chaos injection The Mailpit side-car is shared per project, so this affects every environment of the project. | data:manage + deployments:write |
GET | /environments/{id}/email/latest | Newest email for a recipient (E2E hook) One call fetches magic links / OTP codes out of preview emails in CI. | data:manage |
GET | /environments/{id}/email/message/{messageId} | Full email message detail | data:manage |
GET | /environments/{id}/queues | List RabbitMQ queues (this env's vhost) | data:manage |
POST | /environments/{id}/queues/purge | Purge a RabbitMQ queue | deployments:write + data:manage |
POST | /environments/{id}/redis/command | Legacy alias: Redis CLI console (deprecated) | data:manage |
GET | /environments/{id}/topics | List Redpanda topics (this env's prefix) | data:manage |
POST | /environments/{id}/topics/delete | Delete an env-prefixed Redpanda topic | deployments:write + data:manage |
GET | /projects/{id}/addons | List addons on a project | data:manage |
POST | /projects/{id}/addons | Enable an addon on a project 409 when the instance already exists (a name is unique across types), and 409 `addon_env_collision` when the instance would inject a variable another live instance already injects (e.g. a MinIO instance named AWS vs the default MinIO's AWS_S3_ENDPOINT). Adding a removed instance back revives it on a fresh data generation. | data:manage |
GET | /projects/{id}/addons/{type}/seed | Default seed metadata (project-level) | data:manage |
PUT | /projects/{id}/addons/{type}/seed | Set the project's default SQL seed | data:manage |
DELETE | /projects/{id}/addons/{type}/seed | Remove the project's default seed | data:manage |
POST | /projects/{id}/addons/{type}/seed/apply-to-environments | Force-apply the default seed to existing environments | data:manage + deployments:write |
PUT | /projects/{id}/addons/{type}/seed/backup | Upload a pg_dump backup as the default seed | data:manage |
Workflows
Workflow definitions, runs, job logs, dispatch/cancel/re-run, schedules, and per-workflow variables.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /environments/{envId}/workflow-runs | Run history for an environment Each run carries `attempt` (1 = first), `rerunOfRunId` (the run it re-ran, or null) and `supersededBy` ({ runId, attempt } of the newest run of its chain — same environment, workflow, trigger, commit and inputs — when that is another run, else null); each job carries `reusedFromJobId` (the job whose result and log a re-run reused, or null). | workflows:read |
GET | /environments/{envId}/workflows | Workflow definitions at an environment's commit | workflows:read |
POST | /environments/{envId}/workflows/{name}/dispatch | Manually dispatch a workflow Runs a workflow that declares an `on.dispatch` trigger against a deployed environment. Requires BOTH workflows:write and deployments:exec — a dispatched run executes shell steps with every project secret injected, the same capability a container shell gives. Rate limited per environment and per user. | workflows:write + deployments:exec |
GET | /projects/{id}/workflow-env-vars | List per-workflow variables | projects:read |
POST | /projects/{id}/workflow-env-vars | Create or replace one per-workflow variable | env:write |
GET | /projects/{id}/workflow-runs | Project-wide run feed (filterable, paged) Run and job fields as GET /environments/:envId/workflow-runs: `attempt`, `rerunOfRunId`, `supersededBy`, and each job's `reusedFromJobId`. | workflows:read |
GET | /projects/{id}/workflow-schedules | List cron schedules Schedules are derived from the repo's workflow files; this surface is read-only apart from the per-schedule on/off toggle. | workflows:read |
GET | /projects/{id}/workflows | Project workflows + dispatchable environments Definitions read from the project's reference environment (default branch, or the most recently deployed), the per-workflow enabled flag, and every environment a run can be dispatched against. | workflows:read |
PUT | /projects/{id}/workflows/{name}/enabled | Switch a workflow off or back on Off means off for every trigger, dispatch included. Does not cancel runs already in flight. | workflows:write |
POST | /projects/{projectId}/runtime-preflights | Prepare one native runtime image on a private node without an AI task Default-off operator onboarding for claude-agent-v1 or codex-agent-v1 only. Requires explicit project enrollment, canonical runtime repository, a stopped non-fork context and a same-team pinned private node advertising automation_runtime_preflight_v1 and automation_registry_pull_v1. A caller UUIDv4 is an immutable idempotency key: exact retry returns the original workflow and never renews or resends committed delivery authority. Centrally encrypted, read-only GHCR credentials are delivered only to the node's host pull client; no credential is returned. No Git checkout, model token, provider call, gateway or Control Plane attempt. Prepared means delivery authority committed, not image readiness. Cached private images remain; unknown pull or cleanup keeps the private-node hold through cancellation, history removal and API rollback. | workflows:write + deployments:exec |
GET | /projects/{projectId}/runtime-preflights/{requestId} | Read durable native image preflight evidence Returns authenticated fixed-probe evidence and independent registry-pull/owned-cleanup facts. A successful offline preflight proves only the exact image, kernel restrictions, executable workspace and container lifecycle. It does not prove gateway transport, provider authentication/inference, a Control Plane goal or capacity release. Cancellation does not erase uncertainty. Readback outlives workflow history and grants no new execution authority. | workflows:read |
DELETE | /workflow-env-vars/{id} | Delete one per-workflow variable | env:write |
GET | /workflow-jobs/{id}/logs | A job's transcript, segmented per step Workflow steps run with every project secret injected, so a step that echoes its environment writes a secret into this log — which is why the transcript is its own permission (workflows:logs), separate from workflows:read. | workflows:logs |
GET | /workflow-runs/{id} | One run with its jobs Each run carries `attempt` (1 = first), `rerunOfRunId` (the run it re-ran, or null) and `supersededBy` ({ runId, attempt } of the newest run of its chain — same environment, workflow, trigger, commit and inputs — when that is another run, else null); each job carries `reusedFromJobId` (the job whose result and log a re-run reused, or null). | workflows:read |
POST | /workflow-runs/{id}/cancel | Cancel an in-flight run | workflows:write |
POST | /workflow-runs/{id}/rerun | Re-run a finished run (same workflow, env, commit and trigger) Works on any run, `on.dispatch` or not — like GitHub's Re-run button. Creates a new run (attempt N+1) with the original trigger, so each re-run job's GitHub check replaces the failed one. Re-runs the chosen jobs plus every job that depends on them (and any dependency of theirs that didn't pass); the other jobs are reused as they ended. Always starts from the newest attempt of the run (same environment, workflow, trigger, commit and inputs), whichever attempt is named; a jobId is matched by name there. Nothing is cancelled. Every refusal carries a `code`: 400 `invalid_request`, `environment_gone`, `environment_archived`, `no_node`; 404 `run_not_found`, `job_not_found`; 409 `run_in_progress` (that newest attempt is still in progress, or another re-run of it just started), `nothing_failed`, `nothing_to_rerun`, `workflow_disabled`, `workflow_not_found`, `fork_environment`, `environment_mismatch`, `invalid_commit`, `no_repository`, `workflows_disabled`; 429 `rate_limited`. A missing permission is a 403 that names it (`requiredPermission`), like every route. Gated exactly like dispatch (workflows:write AND deployments:exec) and shares its rate limits. | workflows:write + deployments:exec |
PATCH | /workflow-schedules/{id} | Turn one schedule on or off | workflows:write |
AI Execution Runner
Default-off, attempt-scoped runner lifecycle. Requires a bootstrap/runtime capability; user sessions and API keys cannot grant execution authority.
| Method | Path | Description | Required scope |
|---|---|---|---|
POST | /ai/execution-poc/owner/credentials | Enroll the authenticated owner's project-scoped credential Default-off owner pilot. Requires current project/tenant membership, authenticated interactive owner and trusted project enrollment. API keys and caller-selected owner identities are refused. Material is AES-GCM encrypted; no credential read endpoint exists. | workflows:write + deployments:exec |
POST | /ai/execution-poc/owner/credentials/{credentialId}/revoke | Revoke the authenticated owner's enrolled credential Blocks new claims, redemption and lease renewal. Does not revoke the vendor login or clear an active seat fence. Late refresh remains write-only encrypted recovery. Wrong-owner/inaccessible IDs return credential_not_found 404. | workflows:write + deployments:exec |
POST | /ai/execution-poc/owner/provision | Provision an already bound owner-operated attempt Requires authenticated owner, immutable privileged dispatch binding and owner spec v2. Owner v2 artifact handoffs are disabled until source-project read authorization is integrated. Production fails closed with egress_not_ready: this release implements no live provider egress profile. | workflows:write + deployments:exec |
POST | /ai/execution-poc/runner/v1/claim | Consume one bound execution bootstrap Default-off execution transport. Atomically consumes a short-lived bootstrap for the recorded tenant/attempt/session/run/job/node; repeated claims cannot authorize another start. JSON body capped at 2 MiB. Errors are sanitized {error,code}. | public |
POST | /ai/execution-poc/runner/v1/cleanup | Record runner process cleanup Default-off attempt-scoped transport, with strict operation fields and a 2 MiB JSON body cap. Invalid capabilities and mismatched identities are uniformly denied; raw errors/body text are never returned. Runner cleanup does not prove outer container removal or release capacity by itself. | public |
POST | /ai/execution-poc/runner/v1/credential-redeem | Redeem the claimed attempt's owner credential Requires active lease, owner authorization and ready provider egress. No production egress profile is implemented. | public |
POST | /ai/execution-poc/runner/v1/credential-rotate | Persist a version-fenced owner credential refresh Write-only facts/recovery are accepted after lease expiry or execution disablement. They grant no new execution authority. Rotation uses CAS and encrypted conflict recovery; K6 is immutable per phase and does not double-count usage. | public |
POST | /ai/execution-poc/runner/v1/failure | Record sanitized execution failure Default-off attempt-scoped transport, with strict operation fields and a 2 MiB JSON body cap. Invalid capabilities and mismatched identities are uniformly denied; raw errors/body text are never returned. Runner cleanup does not prove outer container removal or release capacity by itself. | public |
POST | /ai/execution-poc/runner/v1/gateway | Read an explicitly admitted artifact Default-off attempt-scoped transport, with strict operation fields and a 2 MiB JSON body cap. Invalid capabilities and mismatched identities are uniformly denied; raw errors/body text are never returned. Runner cleanup does not prove outer container removal or release capacity by itself. | public |
POST | /ai/execution-poc/runner/v1/heartbeat | Renew existing runner authority Default-off attempt-scoped transport, with strict operation fields and a 2 MiB JSON body cap. Invalid capabilities and mismatched identities are uniformly denied; raw errors/body text are never returned. Runner cleanup does not prove outer container removal or release capacity by itself. | public |
POST | /ai/execution-poc/runner/v1/outcome | Record idempotent execution/final entitlement observations Write-only facts/recovery are accepted after lease expiry or execution disablement. They grant no new execution authority. Rotation uses CAS and encrypted conflict recovery; K6 is immutable per phase and does not double-count usage. Owner outcomes and accepted receipts must agree on report identity, including explicit absence; mismatches return outcome_report_conflict. Known literal, JSON-escaped and URI-encoded credential material is refused as unsafe_evidence. | public |
POST | /ai/execution-poc/runner/v1/result | Accept one bounded structured result Default-off attempt-scoped transport, with strict operation fields and a 2 MiB JSON body cap. Invalid capabilities and mismatched identities are uniformly denied; raw errors/body text are never returned. Runner cleanup does not prove outer container removal or release capacity by itself. For owner v2, previously recorded outcome report identity must match or result_conflict is returned. A first result remains compatible when no phase facts exist; identical delivery replays its receipt. | public |
POST | /ai/execution/dispatch | Prepare one CP-authorized automation workflow attempt Default-off versioned automation transport. Requires a current team/project-scoped API key pinned to exactly one project, explicit project enrollment, a pinned private node with the execution capability, canonical digest-pinned runtime workflow and independent server-to-server CP authorization. Native Claude and Codex require distinct claude-agent-v1/codex-agent-v1 profiles and automation_native_execution_v1; native Codex additionally requires automation_native_codex_v1 so older Claude-only nodes are refused. Native capabilities are advertised only after a separate operator opt-in; legacy profiles keep their resource and workspace policy. A machine key or request fields alone never grant model execution. Identical retries retain one durable run/job/launch; conflicting content is refused. Project/repository policy failures return 403 automation_context_refused; refusal of the selected private node returns 403 automation_node_context_refused, so the engine only backs off that node. No ordinary inputs, project secrets, clone credentials or provider material are sent through this route. Registering a release does not prove node readiness. | workflows:write + deployments:exec |
POST | /ai/execution/reports | Durably receipt the original automation result envelope CP-to-core report bridge with the same exact project-scoped machine permission. Hashes/stores the canonical original runtime envelope, separately from CP logical acceptance and physical cleanup. Exact replay returns the same receipt; changed bytes conflict. Existing receipt replay survives explicit tombstoning. Late reports remain writable after dispatch is disabled; no execution/read/model authority is restored. Reports are encrypted, not published to GitHub. | workflows:write + deployments:exec |
Endpoints & SSH
External TCP endpoints on the SNI gateway, connection info, container exec, and SSH access.
| Method | Path | Description | Required scope |
|---|---|---|---|
DELETE | /endpoints/{id} | Revoke an endpoint An environment's SSH endpoint (`protocol: ssh`) is not revoked here: 409, switch SSH off with PUT /environments/:id/ssh instead. | endpoints:manage |
GET | /environments/{id}/endpoints | List external TCP endpoints | endpoints:read |
POST | /environments/{id}/endpoints | Create an external TCP endpoint Allocates an SNI-routed slug on the TLS gateway and returns a paste-ready connection string. Target is exactly one of a service container or an environment addon. Plan-gated (Pro and above); rate limited per env and per user. | endpoints:manage |
GET | /environments/{id}/endpoints/targets | What can be exposed on this environment | endpoints:read |
POST | /environments/{id}/run | Run a one-off command in a container Executes a shell command inside the app, a named service, or an addon side-car and returns exit code + output (plus which shell ran it). Runs via `bash -lc`, falling back to `sh -lc` when the image ships no bash (Alpine images). Requires BOTH deployments:write and deployments:exec — a shell trivially reads every injected secret. | deployments:write + deployments:exec |
GET | /environments/{id}/ssh | SSH state for an environment | deployments:exec |
PUT | /environments/{id}/ssh | Toggle SSH for an environment Sets the per-environment override (null = inherit the project default) and provisions sshd in the running container immediately. With BTS_SSH_AUTHORIZED_KEYS set on the project, login is by key only; BTS_SSH_HOST_KEY fixes the host key. sshd is restarted only when its config changed, and open sessions survive a restart. | deployments:exec |
Nodes
Compute nodes, join tokens, and capacity/usage reporting.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /nodes | List nodes agentCapabilities contains only supported capability names observed on authenticated node heartbeats. Missing support clears the list; agentVersion alone does not prove protocol support. Each node also carries `joinTokenExpiresAt` — when its join command stops working, or null once the node has connected (its token is then its credential and does not expire) — and `joinTokenExpired`, true for a node that never connected before then. Such a node can't connect any more: delete it, generate a new join command, run it on the server and restart the agent. | nodes:read * |
GET | /nodes/{id} | Node details Includes agentCapabilities from authenticated heartbeats. Protected execution also checks the actual destination stream immediately before delivery; this persisted snapshot alone is not execution authority. The node carries `joinTokenExpiresAt` and `joinTokenExpired`, as in the list. | nodes:read * |
PATCH | /nodes/{id} | Update a node | nodes:write * |
DELETE | /nodes/{id} | Delete a node | nodes:write * |
PATCH | /nodes/{id}/ownership | Reassign a node to another tenant | nodes:write * |
GET | /nodes/{id}/usage | Node capacity + usage breakdown | nodes:read * |
POST | /nodes/join-token | Generate a node join token Creates a node that waits for its agent, and the `nexos-agent join` command for it. The command works until `joinTokenExpiresAt` (24 hours) unless the agent has connected by then; once it has, the token is the node's credential and does not expire. An expired command can't be renewed: generate a new one and delete the node that never connected. | nodes:write * |
POST | /nodes/register | Agent bootstrap (join-token exchange) Legacy bootstrap, authenticated by the join token rather than a user credential. The node agent enrolls over its gRPC Connect stream instead and never calls this. 409 once the node has registered; 410 with code `join_token_expired` once the token is more than 24 hours old — the same rule the gRPC path applies to a node that has never connected. | public |
Teams
Workspaces, members, granular member permissions, and invites.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /teams | List the caller's teams | auth only |
POST | /teams | Create a team Plan-gated: the caller's personal tenant must be on a plan that allows team creation. | auth only |
GET | /teams/{id} | Team details, members, and pending invites | auth only |
PATCH | /teams/{id} | Update a team | members:manage * |
DELETE | /teams/{id} | Delete a team | members:manage * |
POST | /teams/{id}/invites | Invite a member by email BTS sends no email: share the returned token's link yourself. Only a user signed in with this address, verified through GitHub, can accept it. | members:manage * |
DELETE | /teams/{id}/invites/{inviteId} | Revoke a pending invite | members:manage * |
POST | /teams/{id}/leave | Leave a team | auth only |
PATCH | /teams/{id}/members/{userId} | Update a member's role, permissions, or project scope `permissions` is the per-member capability override (array = exactly that set, null = role default, omitted = unchanged); `projectIds` is the per-member project allow-list with the same three-state semantics. Ownership transfer is owner-only. | members:manage * |
DELETE | /teams/{id}/members/{userId} | Remove a member | members:manage * |
GET | /teams/invites/{token} | Preview an invite | auth only |
POST | /teams/invites/{token}/accept | Accept an invite Only the user whose email the invite was issued to may accept it, and only once GitHub has verified that address: it came from GitHub sign-in or linking, or is one of the verified addresses on the user's linked GitHub account (checked here when not yet verified; GitHub unreachable fails closed). A different address is 403 `invite_email_mismatch`; the right but unverified address is 403 `invite_email_unverified` (link GitHub, or verify the address on the linked GitHub account, then retry). Seat cap is re-checked at accept time. | auth only |
Quotas & Activity
Tenant quota ceilings, live usage, and the activity feed.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /activity | Activity feed | deployments:read * |
GET | /quotas/me | Tenant quota limits + live usage `buildResourcePool` is the whole vCPU/vRAM pool — the plan quota plus the capacity of the tenant's online private nodes (an owner's override included) — and `buildResourceUsage` everything holding part of it. `sharedPoolCeiling` is the plan quota alone and `sharedPoolUsage` the part of the usage that runs on the BTS-operated shared pool: shared-pool work must fit both ceilings, since private node capacity only counts for projects placed on private nodes. | billing:manage * |
PATCH | /quotas/me/build-concurrency | Set the tenant's own build-concurrency cap Self-service knob; null = unlimited (the real ceiling is the tenant's resource pool). | billing:manage * |
Telemetry
Build and CI performance history for dashboards. Node, container and control-plane metrics are pushed over OTLP to the operator's metrics backend instead.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /metrics/builds | Build performance timeseries Throughput, outcomes and duration percentiles computed from deployment history, so the series covers everything ever deployed rather than only the period since telemetry was switched on. Build time (compute) and queue wait are reported separately: a slow build and a saturated fleet are different problems, and a single 'time to preview' number hides which one you have. | projects:read * |
GET | /metrics/builds/recent | Recent builds with timings The table behind a build-duration chart: which deployment each spike was, and how its time split between queue wait and compute. | projects:read * |
GET | /metrics/dimensions | Dashboard filter values What a dashboard's filter dropdown can offer, already narrowed to what the caller may see: projects (value = id), or the workflow names in those projects. | projects:read * |
GET | /metrics/workflows | CI performance timeseries Run throughput, outcomes and durations computed from workflow history. Durations and the success rate are over settled runs (success or failure): a cancelled run was superseded and a skipped one never ran. Queue time (created to first job started) is reported apart from run time, as for builds. `job_minutes` is the compute CI consumed, cancelled jobs included. | projects:read * |
GET | /metrics/workflows/jobs | CI performance per job One row per (project, workflow, job), slowest p95 first: which job makes CI slow and which makes it red. `wait_seconds_p95` is job created to started — its `needs:` plus waiting for a runner. | projects:read * |
GET | /metrics/workflows/recent | Recent CI runs with timings Which run each spike was, how its time split between queue wait and running, and which job failed it. | projects:read * |
GET | /metrics/workflows/summary | CI performance per workflow One row per (project, workflow) over the window. | projects:read * |
Webhooks
GitHub-facing webhook receiver (called by GitHub, not by API clients).
| Method | Path | Description | Required scope |
|---|---|---|---|
POST | /webhooks/github | GitHub webhook receiver Called by GitHub, not by API clients. Verifies the HMAC signature against the project's webhook secret (or the GitHub App's secret) and handles push, pull_request, delete, check_run, ping, and App installation events. This is what turns a `git push` into a preview deployment. | public |
Platform
Health, discovery, and other unauthenticated platform endpoints.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /health | Health check | public |
POST | /mcp | Hosted MCP endpoint (Streamable HTTP, stateless) Model Context Protocol endpoint for coding agents. Each POST carries one JSON-RPC message and the caller's own `Authorization: Bearer nxs_…` API key; every tool call is executed through the regular HTTP routes, so key scopes and project pins apply identically to direct API use. | auth only |
POST | /newsletter/subscribe | Newsletter / waitlist signup Public lead-capture endpoint used by the marketing site. Idempotent per (email, source); rate limited per IP. | public |
GET | /openapi.json | This OpenAPI 3.1 document Machine-readable description of the public API, including the required permission for every endpoint (see the `x-required-permission` extension on each operation). | public |
Automation
The Automation Engine: goals, tasks and their graph, attempts run as BTS workflow runs, templates and agent definitions, tool connections, human input, operations and the event log. Off for every workspace until a super admin enables it.
| Method | Path | Description | Required scope |
|---|---|---|---|
GET | /automation/agents | List agents Every agent of the workspace with a summary of each version (status, content hash, who created and published it). | automation:use * |
POST | /automation/agents | Create an agent with a draft v1 Needs `automation:use`, or an engine agent's runner token whose definition declares `agents.author`. The draft is checked for shape only (unknown keys and wrong types: 400 `invalid_request`); full validation happens at publish. The agent and its version record who created them: a person, or an agent with its attempt and goal. 409 `agent_name_taken`. | automation:use * |
GET | /automation/agents/{id} | Read an agent with every version's content Nothing defined is hidden: the full content of every version, draft, published and retired. 404 `agent_not_found`, also for another workspace's agent. | automation:use * |
DELETE | /automation/agents/{id} | Delete an unpublished agent and its drafts Needs `automation:operate` and exact confirmName. Only a never-published agent can be deleted. Published and retired history is retained (409 `agent_has_published_history`). Wrong confirmation is 400 `confirmation_required`; another workspace's id is 404 `agent_not_found`. Delete, save and publish serialize on one definition lock; deletion is audited. | automation:operate * |
PUT | /automation/agents/{id}/draft | Save the agent's draft Creates or replaces the single open draft (one per agent). Same callers as creating. 400 `invalid_request` for unknown keys or wrong types. | automation:use * |
GET | /automation/agents/{id}/draft/diff | Compare the agent's draft with its published version Field changes by path (template tasks matched by key, tool operations by tool) and line diffs of long text fields. Needs `automation:use` or the `catalog.read` capability. 404 `draft_not_found`. | automation:use * |
POST | /automation/agents/{id}/draft/publish | Publish the agent's draft Needs `automation:operate` in an interactive session: an API key or an engine agent's token answers 403 `interactive_session_required`. Runs full validation: 400 `invalid_request` with `reason: 'definition_invalid'` and `issues[]` (each with its JSON path and code, e.g. `owner_not_member`, `output_schema_empty`, `schema_keyword_not_allowed`, `tool_not_grantable`). A save landing while it runs answers 409 `draft_changed`. Published versions never change; running goals keep the versions they pinned. | automation:operate * |
POST | /automation/agents/{id}/draft/validate | Check the agent's draft as publishing would, without publishing it Spec 006 (MCP02). Runs exactly the publish validation on the open draft and answers its issues (each with its JSON path and code, as the publish's 400 would list them), the warnings a publish would answer (`no_compatible_runtime`, `agent_needs_catalog_models`, `model_deprecated`), and the draft's content hash, which an approval to publish binds. Nothing is written: no version, status or event changes. Same callers as saving a draft: `automation:use`, or an engine agent's runner token whose definition declares `agents.author`; for an engine agent the tool connections an agent names aren't looked up (`connectionsChecked: false`), which would tell it which labels exist. 404 `draft_not_found`; 404 `agent_not_found`, also for another workspace's agent. | automation:use * |
GET | /automation/agents/{id}/versions/{v} | Read one agent version 404 `agent_version_not_found`. | automation:use * |
POST | /automation/agents/{id}/versions/{v}/retire | Retire a published agent version Needs `automation:operate`. A retired version can no longer be pinned by new goals; goals that pinned it keep it. 409 `version_not_published`. | automation:operate * |
POST | /automation/approvals | Prepare an interactive-only action for a person to approve Publishing an agent or template draft and connecting a model or tool credential are session-only. An AI client (a person's API key) prepares one here, and the person approves it in the dashboard at the returned `url` within 15 minutes. `action` is `publish_agent`, `publish_template` or (spec 008) `publish_tool` (target `{ definitionId }`; the draft's current content hash is bound), `connect_model` (target `{ provider, label }`) or `connect_tool` (target `{ client, label }`, client a catalog tool key). Nothing here ever holds a credential. Recorded as `approval.prepared`. An engine agent's runner token is refused (403 `capability_missing`). Errors: 400 `action_invalid`, 400 `invalid_request` (target fields; issue `tool_unknown` or `tool_not_grantable`), 400 `provider_unsupported`, 400 `credential_in_ordinary_field`, 404 `agent_not_found` / `template_not_found` (also for another workspace's), 404 `draft_not_found`, 409 `label_taken` / `tool_connection_label_taken` (a connection label already in use). | automation:operate * |
GET | /automation/approvals/{id} | Read an approval For the person who prepared it, or an operator; anyone else gets the 404 a missing one gets. A pending approval past its expiry reads `expired` (recorded as `approval.expired`). `outcome` is the code of the last approve that failed (`draft_changed`, `definition_invalid` …); `result` holds ids only: `{ versionId }` or `{ connectionId }`. Never content or a credential. 404 `approval_not_found`. | automation:use * |
POST | /automation/approvals/{id}/approve | Approve a prepared publish The person who prepared it, in a dashboard session, still holding `automation:operate`: an API key or a runner token gets 403 `interactive_session_required`, anyone else 403 `not_approval_preparer`. The draft is re-checked against the bound content hash and published through the ordinary publish (full validation, `catalog.<kind>_published`); in the same unit of work the approval moves `pending → used` with `result: { versionId }` (`approval.used`). A decline or expiry landing meanwhile rolls the publish back. Errors: 409 `approval_not_pending` (used, declined or expired), 409 `draft_changed` (edited or already published since it was prepared; nothing is published), 400 `invalid_request` (the draft no longer validates), 409 `approval_needs_connect_form` (a connection approval is completed by the connect form with `approvalId`). It answers the publish route's warnings too (an agent no allowed runtime type can run). | automation:operate * |
POST | /automation/approvals/{id}/decline | Decline a prepared action The person who prepared it, in a dashboard session, still holding `automation:operate` (same refusals as approving). Moves `pending → declined` (`approval.declined`); it can't be approved afterwards. 409 `approval_not_pending`. | automation:operate * |
GET | /automation/artifacts/{id} | Artifact metadata | automation:use * |
GET | /automation/artifacts/{id}/content | Artifact bytes, served inert Untrusted bytes from an agent, served so they never run: always `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; sandbox`. `Content-Type` is the stored type only if it is text/plain, text/markdown, application/json, image/png, image/jpeg or image/gif; anything else (HTML, SVG …) is application/octet-stream. | automation:use * |
GET | /automation/attempts/{id} | Read an attempt in full Everything about one attempt: the context it was sent (exactly), its slot and BTS run (id, status, node, link, timings), the capacity selection, the tool grant, the result or rejection, artifacts, usage, cleanup, the stop record, and its step timeline from the event log. A stopped attempt carries `stopLabel: "not confirmed by BTS"`. Spec 005: `transport` (null for attempts from before BTS's merged execution transport) holds the authorization BTS verified (id, verify count, last outcome and refusal code, expiry), the run, job and launch it bound, the report held for BTS's receipt (state, tries, last error code, the engine digest of what was accepted and BTS's receipt for what it holds, the receipt the node's facts named, and whether they agree), the place and what its release still owes, and `issues` (refusals, conflicts, mismatches and stuck transport work, each with its code). Ids, codes, digests and times only. Spec 006 (CP05): read with a runner token (`goal.read`, the runtime contract's read), the answer stays within 1,572,864 bytes. It drops steps newest first, then replaces the context it was sent by `{trimmed, bytes}`, then drops result conflicts newest first, and lists each cut in `truncated` with where to read it (`readVia`: the events read's cursor, or `GET /automation/runner/goal/attempts/:id/input`; conflicts are `unreadable: dashboard_only`). A person gets the whole attempt. Spec 009: a person or an API key also gets `routing`, why the attempt went to its connection (the pool's mode, the reason: `most_headroom`, `next_in_order` or `only_member_left`, each candidate's band, percentage and running attempts, the members skipped and why, and in observe mode what provider readings would have done), with labels for this workspace's connections and "Deleted connection" otherwise, null for attempts from before pools; and `limitReports`, what the attempt reported about its account's usage limits (`provider_limits` observations: when, the reading's source, whether a limit was reached and its reset, and each window). A runner token gets neither. 404 `attempt_not_found`. | automation:use * |
POST | /automation/attempts/{id}/stop | Stop an attempt Starts the stop protocol: the BTS run is cancelled (a 409 from BTS counts), the claim is fenced so the runner can no longer deliver or use tools, and the grant is revoked. The attempt becomes `stopped` only after the runner has been silent for AUTOMATION_HEARTBEAT_SILENCE_MS, labelled "not confirmed by BTS". A result arriving later is kept as evidence only. The task then gets a new attempt if it has attempts left; cancel the goal to end work for good. 409 `attempt_not_running`. | automation:operate * |
GET | /automation/events | The workspace's event log Everything that happened in the workspace, append-only: workspace and settings changes, goals, graph revisions, tasks, every attempt step, slot leases, budget holds, capacity answers, tool grants, BTS run changes, reconciliation, stops, decisions, human input, catalog changes, and every refusal. Each event names who or what caused it (`actor`: user, agent attempt, engine or external) and whether the engine saw it itself (`source: engine`) or execution reported it (`source: execution`). Never holds a secret value. Oldest first by default; `order=desc` pages from the newest. Spec 006 (CP05): read with a runner token, a page stays within 1,572,864 bytes, ending early with `nextCursor` after the last event kept. | automation:use * |
GET | /automation/goals | List goals Newest first, each with its display status (running, waiting and why, stalled, or ended). Deleted goals are excluded by default; use deleted=only or include. Paginated with `cursor`/`limit`. | automation:use * |
POST | /automation/goals | Start a goal Starts a goal from the named template, or from the workspace default when none is named; a refused template is never swapped for another. The goal pins the template version, the orchestrator's and every usable agent's current published version, and its limits, and starts with graph revision 1. Errors: 404 `template_not_found`, 409 `template_not_enabled`, 409 `template_not_published`, 409 `no_default_template`, 409 `agent_not_published`, 400 `invalid_request` with issue `inputs_invalid` (and its path) or `responsible_not_member`, 403 `limit_raise_requires_operate` (raising a limit above the template default needs automation:operate). | automation:use * |
GET | /automation/goals/{id} | Read a goal The goal (including nullable deletedAt/deletedByUserId, also readable when deleted) with its display status and reasons, template version, orchestrator, pinned agent versions, limits and usage, and estimate and forecast. For a person (not a runner token) it also carries the acceptance record (spec 003 FR-056): each item says whether its evidence is present and carries the evidence, never a secret, and `synthetic` says whether any attempt ran against a simulated provider or BTS. 404 `goal_not_found`, also for another workspace's goal. | automation:use * |
DELETE | /automation/goals/{id} | Delete an ended goal Reversibly removes a completed, failed or cancelled goal from the default list. Retains the goal, graph, attempts, artifacts, audit and usage. Idempotent; emits goal.deleted once per deletion. 409 goal_still_active (cancel first), goal_cleanup_pending (trusted cleanup and completed place-release follow-ups required, manual overrides insufficient), or goal_actions_unresolved (reconcile external writes first). 404 goal_not_found for unknown or foreign goals. 400 invalid_request for absent/wrong confirmation or unknown body fields. Runners cannot operate. | automation:operate * |
POST | /automation/goals/{id}/cancel | Cancel a goal Cancels every task that hasn't finished and stops every live attempt through the stop protocol. Nothing is deleted. 409 `goal_not_active`. | automation:operate * |
GET | /automation/goals/{id}/decisions | A goal's orchestrator decisions Decisions oldest first, each naming its goal: the revision it was based on, the proposal exactly as received, its verdict (`applied`, `rejected`, `escalated`, `declined`) with every reason, the revision it produced, the decision items it handled, and the orchestrator attempt whose context it saw (`contextAttemptId`; open it with GET /automation/attempts/:id). Paged by `cursor` (the last decision id seen) and `limit`; a person's call without `limit` gets the whole list. Spec 004 (`goal_decisions_read_v1`): an engine agent with `goal.read` reads its OWN goal's decisions with its runner token, 25 a page by default; another goal answers 404 like a missing one and is recorded as `refusal.cross_goal`. | automation:use * |
GET | /automation/goals/{id}/graph | Read a goal's task graph Tasks and dependency edges as of `revision` (default: current), plus the list of revisions. Edges are never deleted, so every earlier revision can be drawn; tasks show their current state. 400 `invalid_request` with issue `revision_unknown`. | automation:use * |
GET | /automation/goals/{id}/guidance | A goal's guidance Every piece of guidance sent to the goal's orchestrator, oldest first, with its author (FR-070). | automation:use * |
POST | /automation/goals/{id}/guidance | Send guidance to a goal's orchestrator Unprompted guidance from the goal's responsible person or an operator (FR-066). Recorded and delivered to the orchestrator as `guidance` decision work; it never changes the graph, the limits or a task by itself. Errors: 403 `not_goal_responsible`, 404 `goal_not_found`, 409 `goal_not_active`. | automation:use * |
PATCH | /automation/goals/{id}/limits | Raise a goal's limits Needs `automation:operate`. Raise-only: a value below the current limit answers 400 `invalid_request` (issue `limit_not_raised`). Recorded with before and after (`goal.limits_raised`), and the orchestrator gets `limit_event` decision work. An engine limit request the raise resolves is answered by it. 409 `limits_changed` when another raise landed first; 409 `goal_not_active`. | automation:operate * |
GET | /automation/goals/{id}/requests | A goal's requests for human input Every request of the goal in any state (open, answered, cancelled), oldest first, with its answer and who gave it (FR-070). | automation:use * |
POST | /automation/goals/{id}/restore | Restore a deleted goal Returns a deleted goal to the default list, keeping its terminal state and all history. Never restarts execution. Idempotent; emits goal.restored only when visibility changes. Strict empty body. 404 goal_not_found for unknown or foreign goals. Runners cannot operate. | automation:operate * |
GET | /automation/inbox | Open requests for human input Open requests, oldest first, with their age, goal, raising task and addressee (FR-064). `scope=mine` (default): requests addressed to the caller, plus those addressed to operators when the caller holds `automation:operate`. `scope=workspace`: every open request in the workspace, and needs `automation:operate`. | automation:use * |
POST | /automation/mcp | The Automation Engine MCP (stateless Streamable HTTP) One JSON-RPC message per POST. Accepts `Bearer nxs_…` API keys (the key owner's permissions) and `Bearer aer_…` runner tokens of live attempts (the capabilities their agent definition declares, own goal only). Every tool calls this API's own routes with the caller's token, so every check applies. There is no publish tool, and engine agents can't start goals, read the inbox, answer requests, send guidance or operate. Tools: automation_status, automation_list_templates, automation_get_template, automation_create_template, automation_update_template_draft, automation_diff_template_draft, automation_list_agents, automation_get_agent, automation_create_agent, automation_update_agent_draft, automation_diff_agent_draft, automation_list_tools, automation_create_goal, automation_list_goals, automation_get_goal, automation_get_graph, automation_get_attempt, automation_get_artifact, automation_list_events, automation_list_inbox, automation_answer_request, automation_send_guidance; spec 006: automation_validate_draft; discovery automation_list_models, automation_list_model_connections, automation_get_model_usage, automation_get_model_limits, automation_list_runtime_access, automation_get_settings (metadata only: no fingerprint, key prefix or credential value); reads automation_get_request, automation_get_task, automation_list_decisions, automation_read_artifact (bounded, labelled untrusted); operate automation_pause_dispatch, automation_resume_dispatch, automation_cancel_goal, automation_stop_attempt, automation_set_goal_limits, automation_take_over_task (each its REST route's permission and events; a runner token is refused with `interactive_session_required` before any call); approvals automation_prepare_publish, automation_prepare_connection, automation_get_approval (prepare an action for a person to approve in the dashboard, and read its status as ids only: no tool approves, and no argument or result carries a credential). Every tool's input is a strict object: an unknown argument is refused, naming it. | auth only |
GET | /automation/model-connections | The workspace's model provider connections Spec 004: each connection is this workspace's use of one Claude or Codex subscription account, with the catalog models it may fund. A connection's token is stored encrypted under a key of its own and is never returned: a read shows its version, fingerprint and when it was stored. `accountMatch` is `owner_account_id` when a Codex token names an account id and the account is shared only within its owner (spec 006: matched by the owner's own account id, never verified with the provider), and `none` for a Claude token, whose account is never merged. Another person presenting an account id already connected is refused `409 account_owned_elsewhere`. Never shows the opaque account binding id or the shared credential's id. | automation:use * |
POST | /automation/model-connections | Connect a provider account with its subscription token Needs `automation:operate` in a dashboard session: an API key or an agent's token gets 403 `interactive_session_required`. The token is Claude's `setup-token` or the auth.json `codex login` writes; it is checked for its provider's shape (API keys are refused) and stored encrypted, never echoed, logged or put in an event. The caller becomes the connection's owner, and `consent: true` records their consent to the workspace's automation using the account. A Codex account the caller already connected (by the account id its auth.json names, never verified with the provider) reuses its one stored credential (`sharedAccount: true`, naming no workspace); one someone else connected is refused (spec 006). Creating a connection enables nothing. Errors: 400 `consent_required`, 400 `credential_shape_invalid`, 400 `provider_unsupported`, 400 `credential_in_ordinary_field` (a token in the label or hint), 409 `label_taken`, 409 `account_owned_elsewhere` (another person connected that account), 409 `account_shared_legacy` (an account several people connected before ownership was recorded), 503 `model_credentials_unavailable`. Spec 006 (MCP06): with `approvalId`, the form completes an approval an AI client prepared (POST /automation/approvals): it must be the caller's, pending and unexpired, for this kind of connection and exactly this provider and label (409 `approval_target_mismatch`, 409 `approval_not_pending`, 403 `not_approval_preparer`), and it moves to `used` with the new connection's id in the connection's own unit of work. | automation:operate * |
PATCH | /automation/model-connections/{id} | Rename, describe, disable or enable a connection Needs `automation:operate`. A compare-and-set on `expectedPolicyVersion` (409 `policy_changed`). Disabling drains: no new attempt uses it, attempts already holding a lease finish. A lapsed connection is revived with a new token, not here (409 `connection_lapsed`). Errors: 400 `credential_in_ordinary_field`, 409 `connection_revoked`, 409 `label_taken`. | automation:operate * |
DELETE | /automation/model-connections/{id} | Delete a connection Needs `automation:operate`. Revokes it first (stopping its attempts); deleting an account's last connection deletes its stored value. | automation:operate * |
GET | /automation/model-connections/{id}/history | A connection's history Every change to one connection, newest first: created, token stored or replaced, models changed, status changed, selected, lapsed, revoked, each with its actor and its non-secret facts (versions, counts, reasons). Never a token or an account id. Another workspace's connection answers 404 `model_connection_not_found`. | automation:use * |
PUT | /automation/model-connections/{id}/limits | Set a connection's operator limits Spec 009 (FR-009): the whole set of an operator's limit windows on a connection, each a length (60–10,080 minutes, each at most once), a unit (`attempts` or `tokens`) and a limit (1–2,000,000,000); at most 4, and an empty list removes them all. A window applies to every connection of this workspace on the same provider account, and counts this workspace's attempts on all of them; when two set the same length and unit, the lower limit holds. Use outside BTS isn't counted. Emits `model_connection.limits_changed`. Errors: 400 `invalid_request`, 404 `model_connection_not_found`, 409 `connection_revoked`. | automation:operate * |
PUT | /automation/model-connections/{id}/models | Set which catalog models a connection may fund Needs `automation:operate`. The whole set, by catalog id: this workspace's entries of the connection's provider; a retired entry can't be newly added. Advances the policy version; running attempts keep the one they were dispatched with. Errors: 400 `model_not_in_catalog` (also another workspace's id), 400 `model_retired`, 400 `provider_mismatch`, 409 `policy_changed`, 409 `connection_revoked`. | automation:operate * |
POST | /automation/model-connections/{id}/release-hold | Release a provider reading's hold Spec 009 (FR-028): frees this workspace's connections on this connection's account from a provider reading's hold on one window, at once; a reading observed after the release holds them again. Other workspaces using the account aren't affected. Works in both enforcement modes (in observe mode it clears the shown, unenforced hold). Emits `model_connection.hold_released`, and the window's marks clear with cause `released`. Errors: 400 `invalid_request`, 404 `model_connection_not_found`, 409 `not_held` (the window isn't held by a reading this workspace can see, or its hold ran out), 409 `hold_from_operator_limit` (change the limit instead). | automation:operate * |
POST | /automation/model-connections/{id}/revoke | Revoke a connection and stop its attempts Needs `automation:operate` and the connection's label typed as `confirm` (400 `confirmation_mismatch`). In one step the connection and every lease on it are revoked, so nothing redeems or rotates any more; then every live attempt holding one is stopped. A token the runtime already received isn't revoked at the provider by this. Revoking an account's last connection deletes its stored value. | automation:operate * |
PUT | /automation/model-connections/{id}/token | Replace a connection's token, or revive a lapsed connection Needs `automation:operate` in a dashboard session. Only the connection's owner replaces its token (403 `not_connection_owner`), except on a lapsed connection, which an operator revives with `consent: true` and so becomes its owner. A Codex token must name the same account (409 `account_mismatch`). Spec 006 (CP03): a Codex account's stored value is its owner's to replace; a reviver takes the account over only when no other live connection uses it, and is otherwise refused (409 `account_owned_elsewhere`, or 409 `account_shared_legacy` for an account several people connected before ownership was recorded), with nothing replaced. The new value is a new version; the old one is deleted. Errors: 400 `consent_required`, 400 `credential_shape_invalid`, 404 `model_connection_not_found`, 409 `connection_revoked`, 409 `account_owned_elsewhere`, 409 `account_shared_legacy`, 503 `model_credentials_unavailable`. | automation:operate * |
GET | /automation/model-connections/limits | How close each model connection is to its limits Spec 009: every connection's limit windows and each engine's pool. A window is a provider's reading (`source: provider`, reported by the runtime for the account of an attempt's model session) or an operator's limit against this workspace's own usage (`source: operator`, `unit` attempts or tokens; tokens of an attempt that didn't report them count at an estimate, `estimate: true`). Windows apply to the account group: this workspace's connections on the same provider account. `percent` is null when unknown, never 0; a reading whose reset passed, or whose hold ran out (`holdExpired`), is unknown. A reached window is held until its reset, at most 8 days, or 1 hour when the provider gave no reset; `heldUntil` is set for enforced holds only, and in observe mode (`enforcement: observe`, the platform setting) a reading's hold is `unenforcedHoldUntil`. `closest` is the window closest to its limit. A provider window says `reportedHere: false` when another use of the account reported it. `pools.<engine>` gives `state` (the least severe mark among active members), `canTakeWork`, `held` and, when nothing can take work, `blockedBy` (`empty`, `inactive`, `limit_reached`, `at_cap`, `limit_or_cap`) with `earliestResetAt`. Computed from a per-workspace cache of at most 10 s. | automation:use * |
GET | /automation/model-connections/selection | The workspace's connection per engine (deprecated: use the pools) (deprecated) Deprecated by spec 009: each engine's pool's first member, or null for an empty pool. Attempts are routed over the whole pool (GET /automation/model-pools). | automation:use * |
PUT | /automation/model-connections/selection/{engine} | Select the connection an engine uses (deprecated: use the pools) (deprecated) Deprecated by spec 009: the connection becomes its engine's pool's first member, and the other members keep their order and caps; null empties the pool. Needs `automation:operate`. An active connection of this workspace and engine, or null. Errors: 400 `engine_mismatch`, 404 `model_connection_not_found`, 409 `connection_inactive`, 409 `pool_changed`. | automation:operate * |
GET | /automation/model-connections/usage | Each model connection's usage over a period Spec 009: what this workspace's attempts used on each of its connections, by when they were dispatched (`timeBasis: dispatch`), with totals per engine, a split by served model (`null`: the model wasn't reported) and, for one connection or at most 8, hourly (5 h, 24 h) or daily (7 d, 30 d) UTC buckets with empty ones included. Counted from this workspace's own attempts only, so an account another workspace also uses shows this workspace's share. Token sums cover attempts that reported their counts; the rest are `unknownTokenAttempts` (a null count, or no result yet or ever), never zero, and `complete` is false while there are any. An attempt that never ran (a refused dispatch, or one that never started) isn't counted. Revoked connections and those outside the pool keep their history; a deleted connection's attempts are grouped as `id: null`, `label: "Deleted connection"` per engine. This is BTS's own record, not the provider's meter. Errors: 400 `invalid_request` (an unknown period), 404 `model_connection_not_found` (also another workspace's id). | automation:use * |
GET | /automation/model-pools | Each engine's pool of model connections Spec 009: the connections each engine's attempts are spread over, in order, with the mode (`balanced`: the member with the most headroom, by limit band then the account's running attempts then usage; `in_order`: the first member that can take the attempt) and each member's cap on running attempts (`maxRunning`, null for none). A pool never written reads the workspace's selection as a pool of one (`legacy: true`, `revision: null`), or is empty. A member is skipped while it is disabled, lapsed, at its limit (an enforced hold) or at its cap, or when it doesn't allow the model. | automation:use * |
PUT | /automation/model-pools/{engine} | Set an engine's pool Spec 009: the whole pool, in order: at most 16 distinct connections of this workspace and engine, none revoked, each with a cap of 1–10 running attempts or null. A compare-and-set on `expectedRevision` (null when the pool has no row yet): a pool written or a member revoked since it was read is 409 `pool_changed` with `currentRevision`. The first member is mirrored as the engine's selection for older replicas. Emits `model_pool.changed`, and `model_connection.selected` when the first member changes. Errors: 400 `invalid_request` (duplicates, more than 16, a cap outside 1–10), 400 `engine_mismatch`, 404 `model_connection_not_found` (also another workspace's), 409 `connection_revoked`, 409 `pool_changed`. | automation:operate * |
GET | /automation/models | The workspace's model catalog Spec 004: the models this workspace's agents and connections may refer to, maintained by its operators. Each entry has a provider, an immutable provider model id, a state (`active`, `deprecated` or `retired`), the runtime engines it runs on, and a revision. `usage` counts the connections that allow it, the published agent versions that name it, and its live attempts. | automation:use * |
POST | /automation/models | Add a model to the workspace's catalog Needs `automation:operate`. Added by hand only: a model a provider lists is never added or enabled automatically. Only `anthropic` (engine `claude`) and `openai` (engine `codex`) are accepted. No connection allows the new entry until an operator ticks it. Errors: 400 `invalid_request` (a provider model id outside `[A-Za-z0-9._:-]{1,128}`, or an engine the provider doesn't run on), 400 `provider_unsupported`, 409 `model_exists`. | automation:operate * |
PATCH | /automation/models/{id} | Change, deprecate or retire a catalog model Needs `automation:operate`. A compare-and-set on `expectedRevision`; every change takes a new revision, and attempts keep the revision they were dispatched with. Retiring is one-way: new selections skip a retired model and nothing that refers to it changes. Errors: 400 `invalid_request`, 404 `model_not_found` (also another workspace's), 409 `revision_changed`, 409 `model_retired`. | automation:operate * |
GET | /automation/models/{id}/revisions | A catalog model's revisions Every revision of the entry, oldest first, with the state, name and engines it had. | automation:use * |
GET | /automation/operations | The operations view Everything running, waiting (with its reason), stuck, unknown or reconciling, and stalled in the workspace; task counts for every display state; the oldest unknown dispatch's age (reconciliation lag); every runner slot with its attempt; the dispatch budget; and the workspace and platform pause. | automation:use * |
GET | /automation/requests/{id} | Read a request for human input Everything the single answer screen shows (FR-065): question, reason, options, addressee, state and answer; the raising task's result (accepted, else latest) and artifacts plus any artifacts the request cites; and the goal graph reference with the raising task to highlight. `canAnswer` is advisory; the answer route checks again. `optionRequired`: only an option answers it (spec 006). Any state, so a request's link keeps working after it is answered. 404 `request_not_found`, also for another workspace's request. | automation:use * |
POST | /automation/requests/{id}/answer | Answer a request for human input Body `{ optionId?, text? }`, at least one. Who may answer depends on the kind (FR-038): a product question only its addressee; scope and limit questions the addressee or an operator; operational ones only an operator. The answer claims the request once: of two concurrent answers one wins and the other gets 409 `request_not_open`. In the same transaction the engine checks the goal's limits again, applies or declines an escalated proposal (re-validated against the current graph), and records `human_answer` decision work for the orchestrator. Answering an engine request with `cancel` cancels the goal. A request with `optionRequired` (spec 006: the orchestrator's `context too large` request) takes an option; free text alone is refused. Errors: 400 `invalid_request` (issue `option_unknown`, or `option_required`), 403 `not_request_addressee`, 404 `request_not_found`, 409 `request_not_open`, 409 `limit_still_reached` (an engine limit request answered with anything but `cancel` while the limit still holds, or an escalated proposal approved while it still goes beyond the goal's own limits: raise it first). | automation:use * |
GET | /automation/runner/artifacts/{id} | Runner: download an upstream or handed-on artifact Only artifacts the attempt's context lists: an accepted upstream outcome's, or (spec 006) one a hand-off handed on, resolved to its id in `handoff.artifacts`. No goal-read capability is needed. Optional `offset` and `length` (at most 1 MiB) read part of it; without them, the whole artifact (at most 1 MiB) as before. 404 `artifact_not_found` for any other id, exactly like a missing one; 400 `range_invalid`. | public |
PUT | /automation/runner/artifacts/{name} | Runner: upload an artifact Raw bytes. `X-Artifact-Kind: output | evidence | transcript`. 413 `artifact_too_large` above 1 MiB (2 MiB for a transcript) or 5 MiB per attempt, which also fails the attempt. Media types outside the safe list are stored as application/octet-stream. Idempotent on (attempt, name, sha256). | public |
POST | /automation/runner/claim | Runner: claim an attempt Called once by the runner inside the BTS job, before any model work. Authenticated by the single-use claim token from the dispatch inputs, bound to the dispatched BTS run. Check order: token first (an unknown attempt and a wrong token both answer 401 `claim_invalid`), then 410 `claim_expired` (more than 70 minutes after dispatch), 409 `claim_already_used`, 410 `attempt_not_claimable`, 409 `claim_run_mismatch` (a re-run or manual dispatch), 409 `claim_runtime_mismatch` (a runtime-type attempt whose runner presents another image (`runner.image`) or another 003 runtime identity than the engine pinned; both are recorded. Spec 004: neither is required, since authority comes from persisted dispatch state). For a runtime-type attempt the claim token comes sealed through BTS's bootstrap, never a run input. Returns a runner token that expires at claim + the runtime type's (or slot's) timeout + 15 minutes. `claim_invalid` refusals are logged in aggregate (per server and minute), and an address with 30 failed runner authentications in a minute gets 429 `runner_rate_limited` with Retry-After. | public |
POST | /automation/runner/cleanup | Runner: report cleanup Recorded separately; a cleanup failure never changes an accepted result. | public |
GET | /automation/runner/goal | Runner: read its own goal (get_goal_state) The goal's statement, state, limits and usage, its never-truncated brief (plans, answers, guidance), and its graph at the current revision with each task's outcome and hand-off. Native callers additionally receive goal.pinnedAgentVersions, graph task id/agentId/agentVersionId/acceptedResultId, and server-bound caller {attemptId,goalId,kind,taskId,taskKey,agentVersionId}. Legacy shapes omit those fields. Native reads fail 413 read_too_large if the remaining immutable identity/proof fields exceed the response budget. Runner token (`Authorization: Bearer aer_…`) of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). The goal is always the attempt's own: no route takes a goal id, and an id of another goal or workspace answers exactly like a missing one (404) while recording `refusal.cross_goal`. Read only. | public |
GET | /automation/runner/goal-artifacts/{id} | Runner: a chunk of an artifact of its goal (goal_artifact_content_v1) Spec 004, the runtime contract's artifact read. Base64 (padded, standard) in JSON, with the WHOLE artifact's SHA-256 so the runtime checks what it reassembles. `nextOffset` is `offset + len` while bytes remain, else null; `len` is at most `limit`, and at least 1 while `sizeBytes > offset`. Runner token of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). Another goal's artifact answers exactly like a missing one (404 `artifact_not_found`) and is recorded as `refusal.cross_goal`. | public |
GET | /automation/runner/goal/artifacts/{id}/content | Runner: read an artifact of its goal (read_goal_artifact) The raw bytes, with `x-artifact-id`, `x-artifact-name` (URI-encoded), `x-artifact-media-type`, `x-artifact-sha256` and `x-artifact-size` headers. At most 1 MiB per read: page with `offset` and `length`; a larger read is 413 `read_too_large`, never truncated. 404 `artifact_not_found` for an artifact outside the goal. Runner token (`Authorization: Bearer aer_…`) of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). The goal is always the attempt's own: no route takes a goal id, and an id of another goal or workspace answers exactly like a missing one (404) while recording `refusal.cross_goal`. Read only. | public |
GET | /automation/runner/goal/attempts/{id} | Runner: one attempt of its goal (get_attempt_details) What the attempt was sent, its result and hand-off, what execution reported, and its artifacts' metadata. Native callers additionally receive attempt goalId/taskId/agentVersionId, agent {id,versionId}, result id/attemptId, and task {id,key,state,outcomeRelease,acceptedResultId,writes:[{intentId,state,target,reference}]}. Durable writes span retries, with full safe tool/connection binding in target; confirmed/done is distinct from an execution-reported event. Native reads fail 413 read_too_large if untrimmable identity/write proof fields exceed the response budget. No token hashes, credentials or tool values. Spec 006 (CP05): within 1,572,864 bytes. Observations go first, newest first, as one `truncated` entry with a `count` and the events cursor to resume from; then the input, replaced by `{trimmed, bytes}` and read in ranges at `GET /automation/runner/goal/attempts/:id/input`. 404 `attempt_not_found` for an attempt outside the goal. Runner token (`Authorization: Bearer aer_…`) of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). The goal is always the attempt's own: no route takes a goal id, and an id of another goal or workspace answers exactly like a missing one (404) while recording `refusal.cross_goal`. Read only. | public |
GET | /automation/runner/goal/attempts/{id}/input | Runner: the context an attempt of its goal was sent, in ranges Spec 006 (CP05): the attempt's recorded context as its UTF-8 JSON, at most 1 MiB per read. `offset` and `length` (1 – 1,048,576) page it; without them, the first MiB. Headers: `x-input-size` and `x-input-sha256` (the whole input, so the runtime checks what it reassembles), and `x-input-next-offset` while bytes remain. Attempt details point here (`readVia`) when they trim an input. Runner token of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). Another goal's attempt answers exactly like a missing one (404 `attempt_not_found`) and is recorded as `refusal.cross_goal`. 400 `range_invalid` for an offset past the end or a length over 1 MiB. | public |
GET | /automation/runner/goal/decisions | Runner: its goal's decisions, answers and guidance (get_goal_decisions) Decisions oldest first, at most 50 per page; `cursor` is the last decision id seen. Every answer and guidance item comes with each page. Runner token (`Authorization: Bearer aer_…`) of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). The goal is always the attempt's own: no route takes a goal id, and an id of another goal or workspace answers exactly like a missing one (404) while recording `refusal.cross_goal`. Read only. | public |
GET | /automation/runner/goal/events | Runner: its goal's events (list_goal_events) Oldest first, at most 100 per page; `cursor` is the last id seen. Filter by `type` or `taskKey` (a key not in the goal is 404 `task_not_found`). Runner token (`Authorization: Bearer aer_…`) of a LIVE attempt whose agent declares `goal.read` (else 403 `capability_missing`; an ended or fenced attempt gets 410). The goal is always the attempt's own: no route takes a goal id, and an id of another goal or workspace answers exactly like a missing one (404) while recording `refusal.cross_goal`. Read only. | public |
GET | /automation/runner/grant | Runner / tool gateway: the current tool grant Exactly the claim's `toolGrant` (spec 008). 410 `grant_revoked` once the attempt ended or was fenced. | public |
POST | /automation/runner/heartbeat | Runner: heartbeat Every heartbeatIntervalMs. A phase change is an event. 410 `attempt_fenced` (stop work and exit) or 410 `runner_token_expired`. | public |
POST | /automation/runner/model-session/redeem | Runner: redeem the attempt's model session (model_session_v1) Runner token of a LIVE attempt (normal authority). Native attempts also reject normal authority at deadline even while result/cleanup delivery grace remains. Every request field is compared with the attempt's lease, the binding as canonical JSON; any difference is 409 `binding_mismatch`. Returns the account's current credential (a Claude setup token or a Codex auth.json text) with the session echoed exactly, `cache-control: no-store`, never logged. A disabled or lapsed connection drains: its active leases still redeem. Refusals hand out nothing and are recorded: 410 `attempt_fenced`, 409 `lease_inactive`, 409 `binding_mismatch`, 409 `connection_revoked`, 503 `model_credentials_unavailable`. | public |
POST | /automation/runner/model-session/rotate | Runner: persist a refreshed Codex credential (model_session_v1) Runner token (recovery authority: an ended or fenced attempt's token is accepted while its lease is active, or ended less than 24 h ago). Compare-and-set on the account's credential version, under the account's row lock only. `persisted` with `credentialVersion = expectedVersion + 1`; a stale `expectedVersion` is `recovery_required` with the current version. Versions are counted the runner's way, from the version pinned at dispatch: the engine maps them to the account version of the material it handed this lease (spec 006 RUN05), so a refresh after another attempt's rotation persists; an identical repeat of the `rotationId` returns the stored answer. Refusals are recorded: 409 `rotation_conflict` (same id, other content), 409 `rotation_account_mismatch`, 409 `rotation_not_applicable` (Claude never rotates), 409 `lease_inactive`, 409 `binding_mismatch`, 400 `credential_shape_invalid`, 503 `model_credentials_unavailable`. The old value is deleted at once, its fingerprint kept. | public |
POST | /automation/runner/observations | Runner: report observations Up to 50 items per call; each becomes an event labelled as reported by execution (`tool_call` items as `tool.call_reported`). Items with secret-looking fields or over 16 KiB are refused and listed. Spec 008: an `external_action` item must name `tool` and `operation`, a write the attempt's grant holds at its pinned version; otherwise the whole call is 400 `intent_not_granted` with the first such item's `index`, nothing is recorded, and `external_action.refused` names it. A `confirmed` item's `reference` may be the scalar at the operation's reference pointer. Spec 009: a `provider_limits` item reports what the provider says about the account of the attempt's own model session (contracts/runtime-provider-limits-v1.md): `data` is strictly `{ source: codex_rate_limits | claude_refusal, observedAt, windows: [{ bucket?, windowMinutes, usedPercent, limitReached, resetAt }] }` with 1–8 windows. Its refusals: `limits_invalid: <path>`, `limits_no_lease`, `limits_observed_out_of_range`, `limits_reset_out_of_range`, `limits_report_cap` (20 per attempt), besides the general `secret_like_field` and `too_large`. `accepted` is the number of items accepted. Native external_action uses operation native, exact granted connectionId/connectionVersion, and an immutable business target; reserved tool/operation/connectionId/connectionVersion fields in target are refused. Native writes serialize with fencing/result acceptance and reject at deadline. Each accepted native intent adds nativeIntents [{index,intentId,created,state,ownedByCurrentAttempt,target,reference}]; target here is business-only. Only the atomic first insert returns created true. Replays never authorize a fresh write, and an existing not_done intent also requires explicit operator/new-round handling. A publisher must require accepted 1, refused [], created true, ownedByCurrentAttempt true, state intended and the exact target before an external POST. After observing its remote reference it reports confirmed. An uncertain write stays intended for reconciliation; never automatically publish again. | public |
POST | /automation/runner/result | Runner: deliver the result Checked against the pinned agent version's output schema (at most 256 KiB). The receipt is the go-ahead to clean up. Spec 003: a delivery is bound to its CONTENT, a sha256 over the canonical payload, artifacts (name and sha256), usage, outcome, failure and `executionReceipt`. A delivery with the same content returns the same receipt whatever its idempotencyKey; other content answers 409 `result_conflict` with the original receipt, and is kept as evidence (the result never changes). The receipt carries `contentDigest` and `executionReceipt`, which the engine later checks against BTS's run read (`receipt.mismatch` when they differ; the result stays accepted). A result arriving after another path ended the attempt is kept as rejected evidence (`attempt_already_ended`). 400 `result_not_json` for content that isn't plain JSON. | public |
GET | /automation/runner/result-receipt | Runner: its own result's receipt (result_reconciliation_v1) For a runner whose acknowledgement was lost. Runner token, recovery authority: any state, until the attempt's end + 24 h, then 410 `receipt_lookup_expired`. All three of `idempotencyKey`, `digest` and `receipt` are null when the attempt has no result. It reads; it reopens nothing (goal reads, sessions, tool keys and delivery keep their own checks). | public |
POST | /automation/runner/tool-connections/redeem | Runner: redeem one bound tool key (tool_connection_redemption_v1) Runner token (`Authorization: Bearer aer_…`) of a live attempt. Returns the value of exactly one entry of the attempt's live grant, by connection id and version. Answers carry `cache-control: no-store` and are never logged. Refusals deliver nothing and are recorded: 410 `attempt_fenced` (stopped, cancelled or ended), 403 `grant_revoked`, 403 `tool_not_granted` (the pair isn't in the grant, a model account's id included), 409 `credential_unavailable` (the value was deleted), 503 `tool_credentials_unavailable`. Native whole-connection redemption additionally binds the exact native connection ID/version, requires the connection still active, and rejects at the attempt deadline (410 attempt_fenced) under its row lock. PoC limit (D12): revocation stops further retrieval; a key already delivered isn't revoked. | public |
POST | /automation/runner/tool-credentials | Retired: spec 003's tool-credential route Always 410 `redemption_route_retired` (spec 004 FR-053): redeem one grant entry with `POST /automation/runner/tool-connections/redeem` and the runner token. | public |
GET | /automation/runner/upstream/{resultId} | Runner: read an upstream result in full Spec 006: an accepted upstream result the attempt's context lists (`upstream[].resultId`), whole. A context trimmed to fit the runtime's prompt replaces an upstream payload by its summary and lists the cut in `contextBudget.cut` with this route as `readVia`. No goal-read capability is needed. 404 `result_not_found` for any other id, exactly like a missing one. | public |
GET | /automation/runtime-access | Runtime types this workspace may use The runtime types whose BTS runtime project this workspace owns, and any it is allowed: provider, supported models, capabilities (goal reads count only once verified), whether it is enabled and allowed, and how many of its N places are free. Never which attempt or workspace holds a place, and no credential detail. | automation:use * |
PUT | /automation/runtime-access/{runtimeTypeId} | Allow or remove a runtime type for this workspace Only the workspace that owns the type's BTS runtime project may use it, because members there can read every run in it: any other workspace gets 409 `runtime_type_not_shareable`. 404 `runtime_type_not_found` for an unknown type, or a disabled one when allowing. Records `runtime_access.allowed` or `runtime_access.removed`. | automation:operate * |
GET | /automation/settings | Workspace automation settings The default template, the dispatch pause, the hourly dispatch budget and its maximum, the model connections' warning levels (spec 009: percentages at which a connection is marked warning and critical, 80 and 95 by default), and the BTS credential's prefix and owner (never its value). 404 `automation_not_enabled` when the module is off for the workspace. | automation:use * |
PATCH | /automation/settings | Change the default template, the dispatch budget or the warning levels Each change is recorded as an event with the value before and after (`settings.limit_levels_changed` for the levels). The warning levels are whole percentages 1–99, warning below critical; only the levels sent are written, so of two concurrent changes that together would leave warning at or above critical, one is refused. Errors: 404 `template_not_found`, 409 `template_not_enabled`, 409 `template_not_published`, 400 `invalid_request` with issue `budget_above_bts_limit` (above the server's cap or BTS's 60 runs per hour) or `levels_out_of_order`. | automation:operate * |
PUT | /automation/settings/bts-credential | Set the workspace's BTS credential An `nxs_` key of a dedicated BTS user with workflows:read, workflows:write, workflows:logs and deployments:exec, pinned to this workspace (and ideally to the runner project). Checked against BTS, stored encrypted, never returned: responses show its prefix and owner only. Needs an interactive session (403 `interactive_session_required` for an API key). 400 `invalid_request` with issue `bts_credential_invalid`, or `bts_credential_not_pinned` for a key that isn't pinned to this workspace. Warns when another workspace uses the same BTS user, since BTS's 60 dispatches an hour are per user. | automation:operate * |
POST | /automation/settings/pause | Pause dispatch for the workspace Needs `automation:operate`. No new attempt starts in this workspace, orchestrator invocations included; attempts already running continue to their end. Other workspaces are unaffected. Waiting tasks show `dispatch_paused_workspace`. | automation:operate * |
POST | /automation/settings/resume | Resume dispatch for the workspace Needs `automation:operate`. | automation:operate * |
GET | /automation/slots | Runner slots and the dispatch budget Every runner slot (one BTS workflow in one environment, running one attempt at a time) with its state and current attempt, and the dispatch budget: used, ceiling (the workspace setting, never above 60 − 2), remaining and when it frees. | automation:use * |
POST | /automation/slots | Add a runner slot (retired) Retired by spec 003: attempts run on runtime types, which a super admin registers and an operator allows in Settings → Runtime access. Always answers 410 `slots_retired`. Existing slots stay readable, and a stuck one can still be released. | automation:operate * |
PATCH | /automation/slots/{id} | Change, disable or re-enable a runner slot 409 `slot_in_use` when disabling a slot that runs an attempt; 400 `invalid_request` (issue `slot_timeout_invalid`); 404 `slot_not_found`. | automation:operate * |
DELETE | /automation/slots/{id} | Remove a runner slot 409 `slot_in_use` while it runs an attempt. | automation:operate * |
POST | /automation/slots/{id}/release | Free a stuck slot For an operator who checked in BTS that the slot's run has ended. 409 `slot_not_stuck`. | automation:operate * |
GET | /automation/status | Module status for a workspace Whether the module is enabled for the workspace, what the caller may do, how many requests for human input are open for them, and the server's limits (slot timeout range, dispatch budget maximum, artifact sizes) so clients never hard-code them. | auth only |
GET | /automation/tasks/{id} | Read a task with its attempts The task, its accepted result, and every attempt with its state, failure, BTS run (id, status, node, link, timings), capacity selection, tool grant, context snapshot, result or rejection, artifacts, usage and cleanup. 404 `task_not_found`. | automation:use * |
POST | /automation/tasks/{id}/takeover | Record a take-over A person did part of a task by hand (FR-059): what and for how long. Recorded on the task and in the event log; it changes no state. | automation:use * |
GET | /automation/templates | List templates Every template of the workspace with a summary of each version (status, content hash, who created and published it). | automation:use * |
POST | /automation/templates | Create a template with a draft v1 Needs `automation:use`, or an engine agent's runner token whose definition declares `templates.author`. The draft is checked for shape only (unknown keys and wrong types: 400 `invalid_request`); full validation happens at publish. The template and its version record who created them: a person, or an agent with its attempt and goal. 409 `template_name_taken`. | automation:use * |
GET | /automation/templates/{id} | Read a template with every version's content Nothing defined is hidden: the full content of every version, draft, published and retired. 404 `template_not_found`, also for another workspace's template. | automation:use * |
PATCH | /automation/templates/{id} | Enable or disable a template for the workspace Needs `automation:operate`. A disabled template can't start goals (409 `template_not_enabled`). | automation:operate * |
PUT | /automation/templates/{id}/draft | Save the template's draft Creates or replaces the single open draft (one per template). Same callers as creating. 400 `invalid_request` for unknown keys or wrong types. | automation:use * |
GET | /automation/templates/{id}/draft/diff | Compare the template's draft with its published version Field changes by path (template tasks matched by key, tool operations by tool) and line diffs of long text fields. Needs `automation:use` or the `catalog.read` capability. 404 `draft_not_found`. | automation:use * |
POST | /automation/templates/{id}/draft/publish | Publish the template's draft Needs `automation:operate` in an interactive session: an API key or an engine agent's token answers 403 `interactive_session_required`. Runs full validation: 400 `invalid_request` with `reason: 'template_invalid'` and `issues[]` (each with its JSON path and code, e.g. `owner_not_member`, `output_schema_empty`, `schema_keyword_not_allowed`, `tool_not_grantable`, `graph_cycle`, `agent_not_published`). A save landing while it runs answers 409 `draft_changed`. Published versions never change; running goals keep the versions they pinned. | automation:operate * |
POST | /automation/templates/{id}/draft/validate | Check the template's draft as publishing would, without publishing it Spec 006 (MCP02). Runs exactly the publish validation on the open draft and answers its issues (each with its JSON path and code, as the publish's 400 would list them), the warnings a publish would answer (none for a template), and the draft's content hash, which an approval to publish binds. Nothing is written: no version, status or event changes. Same callers as saving a draft: `automation:use`, or an engine agent's runner token whose definition declares `templates.author`; for an engine agent the tool connections an agent names aren't looked up (`connectionsChecked: false`), which would tell it which labels exist. 404 `draft_not_found`; 404 `template_not_found`, also for another workspace's template. | automation:use * |
GET | /automation/templates/{id}/versions/{v} | Read one template version 404 `template_version_not_found`. | automation:use * |
POST | /automation/templates/{id}/versions/{v}/retire | Retire a published template version Needs `automation:operate`. A retired version can no longer be pinned by new goals; goals that pinned it keep it. 409 `version_not_published`. | automation:operate * |
GET | /automation/tool-connections | The workspace's tool connections Each connection to a tool (a built-in or a workspace tool), with its versions and, for basic auth, its username (shown, never secret). A connection's value is stored encrypted under a key of its own and is never returned: a read shows each version's fingerprint (the first 12 hex of an HMAC under that key), when it was created and superseded, and when its value was deleted. A spec 001 connection that holds only a reference into a secret store is `referenceOnly` and can't be redeemed. | automation:use * |
POST | /automation/tool-connections | Connect a tool with its credential Needs `automation:operate` in a dashboard session: an API key or an agent's token gets 403 `interactive_session_required`. The value is encrypted at once and never echoed, logged or put in an event. The label is the connection's binding key: agents name it in `tool_operations[].connection`. Errors: 400 `invalid_request` with issue `tool_unknown` or `tool_not_grantable` (customer data), 400 `tool_credential_value_invalid`, 409 `tool_connection_label_taken`, 409 `credential_shape_mismatch` (spec 008: a basic-auth tool needs `username`, any other refuses one), 409 `connection_not_needed` (a tool with no authentication), 503 `tool_credentials_unavailable` (no tool-credential key on this server). Spec 006 (MCP06): with `approvalId`, the form completes an approval an AI client prepared (POST /automation/approvals): it must be the caller's, pending and unexpired, for this kind of connection and exactly this tool (`tool_key` is the approval's `client`) and label (409 `approval_target_mismatch`, 409 `approval_not_pending`, 403 `not_approval_preparer`), and it moves to `used` with the new connection's id in the connection's own unit of work. | automation:operate * |
PATCH | /automation/tool-connections/{id} | Rename, disable or re-enable a tool connection Needs `automation:operate` in a dashboard session. 404 `tool_connection_not_found`; 409 `tool_connection_label_taken`. | automation:operate * |
DELETE | /automation/tool-connections/{id} | Remove a tool connection Needs `automation:operate` in a dashboard session. Every version's value is deleted at once: a live attempt bound to it can no longer redeem it (409 `credential_unavailable`), and attempts dispatched later get no grant for it. 404 `tool_connection_not_found`. | automation:operate * |
POST | /automation/tool-connections/{id}/rotate | Rotate a tool connection's credential Adds version N+1 and supersedes N. Attempts already dispatched keep the version they bound; the superseded value is deleted once no live attempt binds it. Same session rule, errors and 503 as creating. | automation:operate * |
POST | /automation/tool-definitions | Describe a new tool (draft v1) Spec 008: a workspace tool with a draft v1: blank, with `content` (a tool description: kind, purpose, dataClass, baseUrl, auth, headers, operations), or a copy of a built-in (`copyOf`, its content without model names). Keys are `^[a-z][a-z0-9_-]{1,39}$` (no dots) and unique in the workspace; at most 200 per workspace, counted under the workspace's lock. Recorded as `catalog.tool_created` and `catalog.tool_draft_saved`. Errors: 400 `invalid_request` (issues `key_invalid` / `key_reserved`; a strict content schema), 400 `credential_in_ordinary_field`, 404 `tool_not_found` (unknown `copyOf`), 409 `tool_key_taken`, 409 `tool_limit_reached`. A runner token is refused 403 `capability_missing`. | automation:operate * |
GET | /automation/tool-definitions/{id} | A workspace tool with every version Another workspace's tool answers 404 `tool_not_found`, like a missing one. | automation:use * |
PUT | /automation/tool-definitions/{id}/draft | Save a tool's draft Creates or replaces the single open draft; partial but strict (an unknown key is 400). Recorded as `catalog.tool_draft_saved`. | automation:operate * |
GET | /automation/tool-definitions/{id}/draft/diff | Diff a tool's draft against its latest published version 404 `draft_not_found` without an open draft. | automation:use * |
POST | /automation/tool-definitions/{id}/draft/validate | Check a tool's draft as publishing would The publish checks (contracts/tool-description.md: the host pinned and public by name, the auth scheme, reserved headers, paths filled only by declared inputs, bounded input schemas, writes with an intent and a reference) as data, and the warnings a publish would answer: `operation_removed` (published agents declare it) and `effect_changed`. Writes nothing. | automation:operate * |
POST | /automation/tool-definitions/{id}/publish | Publish a tool's draft Session only: an API key or a runner token answers 403 `interactive_session_required` (an AI client prepares a `publish_tool` approval instead). Publishes exactly the validated draft, conditional on it still holding that content; running attempts keep the version they pinned. Errors: 400 `invalid_request` with issues, 404 `draft_not_found`, 409 `draft_changed` (also when `expectedSha256` differs). | automation:operate * |
POST | /automation/tool-definitions/{id}/try | Try a workspace tool's operation Session only (403 `interactive_session_required` for an API key or a runner token). A READ is sent once, under the runtime's rules: the host pinned, every resolved address public (422 `host_not_public`), no redirect followed (a 3xx comes back as `redirected`), the operation's byte cap and timeout (504 `tool_timeout`), the answer shaped and every form of the credential redacted; only safe response headers are shown. A WRITE is rendered with the credential masked and never sent. Recorded as `tool.tryout` (no body, no header). Errors: 400 `invalid_request` (inputs outside the schema; an unknown operation), 404 `tool_not_found` / `tool_connection_not_found`, 409 `operation_not_http` (a built-in's declared operation), 409 `connection_tool_mismatch`, 409 `connection_not_needed`, 409 `credential_unavailable`, 502 `tool_request_failed`, 503 `tool_credentials_unavailable`. A workspace tool is tried at its latest PUBLISHED version unless `version` names another; a draft only with `expectedSha256`, the hash of the draft the person was shown (400 `invalid_request` without it, 409 `draft_changed` when the draft changed since), so a stored credential never goes to an address nobody looked at. | automation:operate * |
POST | /automation/tool-definitions/{id}/versions/{v}/retire | Retire a published tool version New attempts are granted the newest version still published (or denied `no_published_version`); running attempts keep theirs. 409 `version_not_published`. | automation:operate * |
GET | /automation/tools | The tools agents may declare: built-ins and this workspace's described tools Spec 008: the platform's built-ins (`source: builtin`, read-only, versioned files) and this workspace's described tools (`source: workspace`), each with its purpose, data class and its latest published version's operations, with the name the model sees and the effect. Today's fields stay: `description` (the purpose), `operations` (names), `resourceScopes` (declarative; `[]` for a workspace tool). Tools holding customer data are listed with `grantable: false` and are never granted. A workspace tool not published yet has `latest: null`. Also readable by an engine agent with `catalog.read`. | automation:use * |
GET | /automation/tools/builtin/{key} | A built-in tool's versions Every version of one built-in, with its full description. 404 `tool_not_found`. | automation:use * |
POST | /automation/tools/builtin/{key}/try | Try a built-in tool's operation Session only (403 `interactive_session_required` for an API key or a runner token). A READ is sent once, under the runtime's rules: the host pinned, every resolved address public (422 `host_not_public`), no redirect followed (a 3xx comes back as `redirected`), the operation's byte cap and timeout (504 `tool_timeout`), the answer shaped and every form of the credential redacted; only safe response headers are shown. A WRITE is rendered with the credential masked and never sent. Recorded as `tool.tryout` (no body, no header). Errors: 400 `invalid_request` (inputs outside the schema; an unknown operation), 404 `tool_not_found` / `tool_connection_not_found`, 409 `operation_not_http` (a built-in's declared operation), 409 `connection_tool_mismatch`, 409 `connection_not_needed`, 409 `credential_unavailable`, 502 `tool_request_failed`, 503 `tool_credentials_unavailable`. A workspace tool is tried at its latest PUBLISHED version unless `version` names another; a draft only with `expectedSha256`, the hash of the draft the person was shown (400 `invalid_request` without it, 409 `draft_changed` when the draft changed since), so a stored credential never goes to an address nobody looked at. | automation:operate * |
Errors
Failures return a JSON body { error, code }. error is a sentence to show a person and may be reworded; code is a stable snake_case identifier to branch on (project_not_found, permission_denied, plan_required, …). Every 4xx the API itself raises carries one, and a code is never renamed; the HTTP layer's own answers (an unknown route, an oversized or unsupported body) keep its FST_ codes. A project or environment outside your current workspace membership or principal project scope is the same 404 as a missing one (project_not_found, environment_not_found). A 403 caused by a missing capability is permission_denied and additionally includes requiredPermission. A project pin or member allow-list miss surfaced by a permission check is project_not_accessible (resource lookups hide it with 404 instead); a project-scoped principal calling a workspace route gets project_scope_restricted. Neither restriction can be fixed by granting a permission. Invalid input is invalid_request with an issues list of { path, message }. A rate limit is a 429 rate_limited; other 429s (a quota, a resume already in progress) carry their own code, often with a Retry-After header.
Example: create and use a scoped key
# Create a key that can only read deployments and stream one project
POST /auth/api-keys
{
"name": "ci-status-bot",
"scopes": ["projects:read", "deployments:read"],
"projectIds": ["<project-uuid>"]
}
# → { "key": "nxs_…", "scopes": [...], "projectIds": [...] } (key shown once)
# Verify what the key can do
curl https://api.test.buildingswell.com/auth/me -H "Authorization: Bearer nxs_…"
# → { "auth": { "method": "apiKey", "apiKey": { "scopes": [...], "projectIds": [...] } }, … }