BTSBTS

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-null projectIds is 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:

PermissionLabelWhat it grants
projects:readRead projectsView projects, environments and stats
projects:writeCreate & delete projectsAdd new projects to the workspace and delete existing ones
project:settingsChange project settingsEdit build config, resource limits, services, nexos.yaml and the webhook secret
deployments:readRead deploymentsView deployments, builds and logs
deployments:writeManage deploymentsDeploy, rebuild, restart, pause, resume and destroy environments
deployments:exec ⚠Shell & SSH accessRun commands inside a running container and enable SSH. Grants access to every injected secret.
env:read ⚠Read env vars & secretsView the decrypted values of env vars, secrets and build args
env:write ⚠Manage env vars & secretsCreate, edit and delete env vars, secrets and build args
data:manageManage databases & dataEnable addons, run SQL/Redis consoles, upload and apply seeds, reset databases, and view addon credentials
workflows:readRead workflowsView workflow definitions, runs and job results
workflows:logs ⚠Read workflow logsView 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:writeRun workflowsDispatch, re-run and cancel runs. Dispatching and re-running also need shell access, since a run executes commands with every secret injected.
endpoints:readRead endpointsView external TCP endpoints and their connection strings
endpoints:manageManage endpointsCreate and revoke external TCP endpoints
nodes:readRead nodesView nodes and their capacity
nodes:writeManage nodesEnroll, configure and remove nodes
members:manageManage membersInvite, remove and set permissions for members
billing:manageManage quotasView and adjust the workspace's resource quotas
automation:useUse automationCreate 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:operateOperate automationPause 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).

Note on 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 an auth object describing the credential in use. For API keys, auth.apiKey carries the key's scopes (an array, or null for full inherit), its teamId workspace pin, and its projectIds project 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 the x-required-permission extension, and the catalog is embedded as x-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.

MethodPathDescriptionRequired scope
GET/projects/{id}/github-app/installWhere 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}/reposList a project's linked repositories
Primary first. Every project has at least one (the repo it was created with).
projects:read
POST/projects/{id}/reposLink 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-appIs 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-branchRe-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-accessProve 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.

MethodPathDescriptionRequired scope
GET/auth/api-keysList the caller's API keysauth only
POST/auth/api-keysCreate 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-codeStart 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/approveApprove 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/denyDeny a device codeauth only
GET/auth/cli/device-code/lookupLook up a pending device codeauth only
POST/auth/cli/device-code/tokenPoll for the approved API key
Returns the freshly minted API key exactly once after approval; subsequent polls return expired.
public
GET/auth/github/appGitHub App availability and install URLpublic
POST/auth/github/callbackComplete 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/linkLink 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/startStart 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/loginLog in with username/passwordpublic
POST/auth/logoutClear the session cookiepublic
GET/auth/meCurrent 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/emailSet 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/verifyVerify 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/permissionsThe 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/registerCreate a username/password accountpublic
POST/auth/terms/acceptAccept the current terms of serviceauth only
GET/auth/ws-tokenMint a short-lived JWT for WebSocket authauth only

Projects

Projects, their configuration, nexos.yaml import/export, and stats.

MethodPathDescriptionRequired scope
GET/projectsList 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/projectsCreate a projectprojects: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 settingsproject:settings
DELETE/projects/{id}Delete a projectprojects:write
POST/projects/{id}/detect-frameworkRe-run framework detectionproject:settings
GET/projects/{id}/environmentsList a project's environmentsprojects:read
GET/projects/{id}/nexos-yamlExport the project as nexos.yamlprojects:read
PUT/projects/{id}/nexos-yamlApply 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/validateValidate 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-secretRegenerate the GitHub webhook secretproject:settings
GET/projects/{id}/statsDeployment stats (daily buckets)projects:read
GET/projects/{id}/webhook-secretRead 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-yamlCreate a project from a nexos.yaml documentprojects:write *
GET/projects/lookupLook 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.

MethodPathDescriptionRequired scope
GET/deployments/{id}Deployment detailsdeployments:read
POST/deployments/{id}/cancelCancel an in-flight deploymentdeployments:write
POST/deployments/{id}/config/applyApply 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}/logsBuild 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}/pausePause 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}/rebuildRebuild 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-dbReset 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}/restartLegacy alias of /environments/:id/rebuild (deprecated)deployments:write
POST/environments/{id}/resumeResume 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-resumeWake 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-nameCheck environment-name availability
Names become preview subdomains, so availability is platform-global.
auth only
GET/projects/{id}/deploymentsList 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}/deploymentsTrigger 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.

MethodPathDescriptionRequired scope
PATCH/build-args/{id}Update a build argenv:write
DELETE/build-args/{id}Delete a build argenv:write
PATCH/env-vars/{id}Update an env varenv:write
DELETE/env-vars/{id}Delete an env varenv:write
GET/env-vars/variablesList supported interpolation variablespublic
GET/projects/{id}/build-argsList build argsprojects:read
POST/projects/{id}/build-argsCreate a build argenv:write
GET/projects/{id}/env-varsList project env varsprojects:read
POST/projects/{id}/env-varsCreate an env varenv:write
POST/projects/{id}/env-vars/importBulk import from .env formatenv:write
PATCH/service-env-vars/{id}Update a per-service env varenv:write
DELETE/service-env-vars/{id}Delete a per-service env varenv:write
GET/services/{id}/env-varsList per-service env varsprojects:read
POST/services/{id}/env-varsCreate a per-service env varenv:write

Services

Multi-container service definitions per project.

MethodPathDescriptionRequired scope
GET/projects/{id}/servicesList a project's servicesprojects:read
POST/projects/{id}/servicesAdd a serviceproject:settings
PATCH/services/{id}Update a serviceproject:settings
DELETE/services/{id}Delete a serviceproject:settings

Addons & Data

Managed addons (Postgres, Redis, …), seeds/backups, dumps, consoles, inbox, and queue browsers.

MethodPathDescriptionRequired 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/catalogAddon 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}/addonsPer-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}/consoleRun 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}/dumpData-export (dump) statedata:manage + deployments:read
POST/environments/{id}/addons/{type}/dumpStart a data export (pg_dump)data:manage + deployments:read
DELETE/environments/{id}/addons/{type}/dumpDiscard a dump artifactdata:manage + deployments:read
GET/environments/{id}/addons/{type}/dump/downloadDownload the finished dumpdata:manage + deployments:read
GET/environments/{id}/addons/{type}/seedPer-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}/seedSet a per-environment SQL seed overridedata:manage
DELETE/environments/{id}/addons/{type}/seedClear a per-environment seed overridedata:manage
POST/environments/{id}/addons/{type}/seed/applyForce-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/backupUpload 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-infoEnvironment 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/queryLegacy alias: Postgres SQL console (deprecated)data:manage
GET/environments/{id}/emailList/search the environment's inbox (Mailpit)data:manage
GET/environments/{id}/email/chaosSMTP chaos-injection statedata:manage
PUT/environments/{id}/email/chaosToggle 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/latestNewest 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 detaildata:manage
GET/environments/{id}/queuesList RabbitMQ queues (this env's vhost)data:manage
POST/environments/{id}/queues/purgePurge a RabbitMQ queuedeployments:write + data:manage
POST/environments/{id}/redis/commandLegacy alias: Redis CLI console (deprecated)data:manage
GET/environments/{id}/topicsList Redpanda topics (this env's prefix)data:manage
POST/environments/{id}/topics/deleteDelete an env-prefixed Redpanda topicdeployments:write + data:manage
GET/projects/{id}/addonsList addons on a projectdata:manage
POST/projects/{id}/addonsEnable 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}/seedDefault seed metadata (project-level)data:manage
PUT/projects/{id}/addons/{type}/seedSet the project's default SQL seeddata:manage
DELETE/projects/{id}/addons/{type}/seedRemove the project's default seeddata:manage
POST/projects/{id}/addons/{type}/seed/apply-to-environmentsForce-apply the default seed to existing environmentsdata:manage + deployments:write
PUT/projects/{id}/addons/{type}/seed/backupUpload a pg_dump backup as the default seeddata:manage

Workflows

Workflow definitions, runs, job logs, dispatch/cancel/re-run, schedules, and per-workflow variables.

MethodPathDescriptionRequired scope
GET/environments/{envId}/workflow-runsRun 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}/workflowsWorkflow definitions at an environment's commitworkflows:read
POST/environments/{envId}/workflows/{name}/dispatchManually 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-varsList per-workflow variablesprojects:read
POST/projects/{id}/workflow-env-varsCreate or replace one per-workflow variableenv:write
GET/projects/{id}/workflow-runsProject-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-schedulesList 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}/workflowsProject 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}/enabledSwitch 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-preflightsPrepare 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 variableenv:write
GET/workflow-jobs/{id}/logsA 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}/cancelCancel an in-flight runworkflows:write
POST/workflow-runs/{id}/rerunRe-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 offworkflows:write

AI Execution Runner

Default-off, attempt-scoped runner lifecycle. Requires a bootstrap/runtime capability; user sessions and API keys cannot grant execution authority.

MethodPathDescriptionRequired scope
POST/ai/execution-poc/owner/credentialsEnroll 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}/revokeRevoke 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/provisionProvision 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/claimConsume 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/cleanupRecord 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-redeemRedeem 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-rotatePersist 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/failureRecord 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/gatewayRead 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/heartbeatRenew 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/outcomeRecord 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/resultAccept 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/dispatchPrepare 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/reportsDurably 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.

MethodPathDescriptionRequired 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}/endpointsList external TCP endpointsendpoints:read
POST/environments/{id}/endpointsCreate 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/targetsWhat can be exposed on this environmentendpoints:read
POST/environments/{id}/runRun 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}/sshSSH state for an environmentdeployments:exec
PUT/environments/{id}/sshToggle 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.

MethodPathDescriptionRequired scope
GET/nodesList 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 nodenodes:write *
DELETE/nodes/{id}Delete a nodenodes:write *
PATCH/nodes/{id}/ownershipReassign a node to another tenantnodes:write *
GET/nodes/{id}/usageNode capacity + usage breakdownnodes:read *
POST/nodes/join-tokenGenerate 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/registerAgent 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.

MethodPathDescriptionRequired scope
GET/teamsList the caller's teamsauth only
POST/teamsCreate 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 invitesauth only
PATCH/teams/{id}Update a teammembers:manage *
DELETE/teams/{id}Delete a teammembers:manage *
POST/teams/{id}/invitesInvite 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 invitemembers:manage *
POST/teams/{id}/leaveLeave a teamauth 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 membermembers:manage *
GET/teams/invites/{token}Preview an inviteauth only
POST/teams/invites/{token}/acceptAccept 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.

MethodPathDescriptionRequired scope
GET/activityActivity feeddeployments:read *
GET/quotas/meTenant 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-concurrencySet 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.

MethodPathDescriptionRequired scope
GET/metrics/buildsBuild 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/recentRecent 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/dimensionsDashboard 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/workflowsCI 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/jobsCI 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/recentRecent 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/summaryCI 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).

MethodPathDescriptionRequired scope
POST/webhooks/githubGitHub 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.

MethodPathDescriptionRequired scope
GET/healthHealth checkpublic
POST/mcpHosted 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/subscribeNewsletter / waitlist signup
Public lead-capture endpoint used by the marketing site. Idempotent per (email, source); rate limited per IP.
public
GET/openapi.jsonThis 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.

MethodPathDescriptionRequired scope
GET/automation/agentsList agents
Every agent of the workspace with a summary of each version (status, content hash, who created and published it).
automation:use *
POST/automation/agentsCreate 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}/draftSave 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/diffCompare 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/publishPublish 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/validateCheck 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}/retireRetire 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/approvalsPrepare 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}/approveApprove 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}/declineDecline 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 metadataautomation:use *
GET/automation/artifacts/{id}/contentArtifact 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}/stopStop 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/eventsThe 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/goalsList 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/goalsStart 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}/cancelCancel 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}/decisionsA 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}/graphRead 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}/guidanceA 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}/guidanceSend 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}/limitsRaise 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}/requestsA 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}/restoreRestore 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/inboxOpen 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/mcpThe 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-connectionsThe 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-connectionsConnect 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}/historyA 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}/limitsSet 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}/modelsSet 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-holdRelease 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}/revokeRevoke 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}/tokenReplace 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/limitsHow 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/selectionThe 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/usageEach 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-poolsEach 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/modelsThe 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/modelsAdd 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}/revisionsA catalog model's revisions
Every revision of the entry, oldest first, with the state, name and engines it had.
automation:use *
GET/automation/operationsThe 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}/answerAnswer 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/claimRunner: 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/cleanupRunner: report cleanup
Recorded separately; a cleanup failure never changes an accepted result.
public
GET/automation/runner/goalRunner: 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}/contentRunner: 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}/inputRunner: 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/decisionsRunner: 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/eventsRunner: 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/grantRunner / 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/heartbeatRunner: 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/redeemRunner: 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/rotateRunner: 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/observationsRunner: 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/resultRunner: 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-receiptRunner: 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/redeemRunner: 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-credentialsRetired: 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-accessRuntime 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/settingsWorkspace 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/settingsChange 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-credentialSet 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/pausePause 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/resumeResume dispatch for the workspace
Needs `automation:operate`.
automation:operate *
GET/automation/slotsRunner 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/slotsAdd 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}/releaseFree 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/statusModule 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}/takeoverRecord 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/templatesList templates
Every template of the workspace with a summary of each version (status, content hash, who created and published it).
automation:use *
POST/automation/templatesCreate 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}/draftSave 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/diffCompare 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/publishPublish 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/validateCheck 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}/retireRetire 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-connectionsThe 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-connectionsConnect 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}/rotateRotate 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-definitionsDescribe 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}/draftSave 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/diffDiff 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/validateCheck 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}/publishPublish 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}/tryTry 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}/retireRetire 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/toolsThe 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}/tryTry 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": [...] } }, … }