# HTTP API endpoints

> Every public Shardflux HTTP API operation by resource: method, path, authentication, parameters and request fields, from openapi.json.

Every operation of the public HTTP API, generated from [openapi.json](https://docs.shardflux.dev/openapi.json) (OpenAPI 3.1.0, API version 0.1.0). Base URL: `https://api.shardflux.dev`. Send a project API key on every request: `Authorization: Bearer sfk_<key_id>_<secret>`. Conventions (idempotency, pagination, long-running operations) are on the [HTTP API](https://docs.shardflux.dev/reference/http-api.md) page. Error responses use the `ErrorBody` schema; the codes are listed under [Errors](https://docs.shardflux.dev/reference/errors.md).

Workspace tools (exec, PTY, processes, files, git, browser) are served by the workspace’s cell at its `cell_endpoint`, with a token from `POST /v1/workspaces/{workspace_id}/tool-tokens`; they are not listed here.

## Principal

The API key making the request: its organization, project and tool permissions.

### `GET /v1/me`

The authenticated principal.

**Authentication:** API key

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Organizations

The organization an API key belongs to.

### `GET /v1/organizations`

Organizations visible to the principal.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}`

Get an organization.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Projects

The project an API key belongs to.

### `GET /v1/organizations/{organization_id}/projects`

List projects.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/projects/{project_id}`

Get a project.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Workspaces

Open workspaces by key, and suspend, resume, snapshot, fork, reset or close them.

### `POST /v1/workspaces/open`

Open a workspace by key (create on first use, reconnect or resume afterwards).

New keys resolve `template` to its latest published version; reopening never changes or resets the workspace (the response reports the template version actually used). 200 when the workspace is already running and ready (with `cell_endpoint` and a tool token); otherwise 202 with the operation to poll. A running workspace whose startup failed (`startup.state` failed) is not ready: the open is 202 with an `open` operation (input.startup_retry) that runs the failed step again. Concurrent opens of one key share one workspace and one operation. `secrets` (optional) binds secret names injected into every exec/PTY start: it sets the binding of a new key and replaces it on an existing key (omitted = unchanged); an unknown or unusable name is 422 details.reason secret_not_available with details.names (nothing is created or changed). The template version’s secret inputs join the binding on create and whenever `secrets` is given; a required one this workspace may not use is 422 input_required (details.kind secret); a bound name equal to a template env key or text input is 422 env_collision (details.name). `inputs` (optional): the version’s text inputs {NAME: string}; stored on create (else the declared default) and replaced on an existing key (omitted = unchanged); 422 input_unknown, input_invalid or input_required (details.names). `lifetime`: omitted = the version’s default (else persistent); `session` workspaces are discarded when the session ends (close(), idle timeout), after which the key opens a NEW workspace; reopening a live key with another lifetime is 409 lifetime_mismatch. A new workspace is `layered` when its version supports it and layered opens are enabled (`disk_layout`). Errors: 404 template/key outside scope, 402 `entitlement_required`, 403 `quota_exceeded` (details.limit), 409 operation in progress, deleted key (workspace_deleted) or lifetime_mismatch, 422 reserved_key_prefix (keys starting with sf:). Supports Idempotency-Key. Held open: with `Prefer: wait=<seconds>` (at most 20) an open whose outcome is an operation is held until the operation is terminal or the wait elapses; success answers 200 with the running workspace, the succeeded operation and a tool token (`Preference-Applied: wait=<seconds>`), anything else 202 with the fresh operation. Without `Preference-Applied` the server did not wait: poll the operation. `mode`: omitted = processful for a new key and the stored mode for an existing one; `file_first` creates a workspace whose state is a versioned file tree with no VM between executions: it is ready at once (200 with a tool token, `observed_state` running, `tree_revision` 0, no operation), needs a layered template version (409 layout_unsupported otherwise) and is persistent (`lifetime: session` or `idle_policy` with it are 422 not_supported_for_mode); each open of a file-first key re-resolves the size of its execution VMs from `caps`, the template and the plan. 422 mode_not_available while the deployment does not offer file-first workspaces; reopening a key with another mode is 409 mode_mismatch.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `prefer` | header | string | no | RFC 7240 preference, e.g. `wait=20` (held open). |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | yes | Stable workspace key, unique per organization (e.g. `${customerId}/${projectId}`). |
| `template` | string | yes | Template slug; new workspaces use its latest published version. |
| `caps` | object | no | Optional user caps; the ceiling is min(template, cap, plan). Absent fields add no restriction. |
| `project_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `agent_label` | string | no | Attribution label; one agent session per (workspace, principal, label). |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `secrets` | array of string | no | Secret names bound to the workspace (max 50, unique): injected as environment variables into every exec and PTY start (terminal sessions included), together with the call’s own `secret_refs`. Each must name a live secret this workspace may use (its project, its id, and allowed_tools including exec and pty), else 422 details.reason secret_not_available with details.names. |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |
| `lifetime` | WorkspaceLifetime | no | persistent: kept until deleted (default). session: discarded when the session ends (close(), idle timeout or draft discard;). |
| `idle_policy` | string | no | adaptive (the learned timeout, default), never, or fixed:<seconds> (60..604800). |
| `mode` | WorkspaceMode | no | processful (default): one VM keeps processes, memory and files between calls; it can be suspended, resumed and forked. file_first: the state is a versioned file tree (tree_revision); there is no VM between executions, each exec runs in a fresh VM and publishes the changed files as the next revision, nothing but files survives an execution. A file-first workspace is ready (running) from creation and is never suspended; suspend, resume, snapshot, fork, reset, save-as-template, volumes and idle policies are 409 not_supported_for_mode. Immutable. |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces`

List workspaces (API keys: their project; users: their organizations).

By default only persistent standard workspaces; `lifetime` and `purpose` (each also `any`) show sessions, drafts and test instances. Ended sessions are tombstones: add include_deleted=true.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `organization_id` | query | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `project_id` | query | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `state` | query | string (one of 11) | no | Observed state filter. |
| `desired_state` | query | "running" or "suspended" or "deleted" | no |  |
| `key_prefix` | query | string | no |  |
| `include_deleted` | query | boolean | no |  |
| `lifetime` | query | "persistent" or "session" or "any" | no | persistent (default), session or any. Key lookups pass any. |
| `purpose` | query | "standard" or "template_draft" or "template_test" or "any" | no | standard (default), template_draft, template_test or any. Key lookups pass any. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/workspaces`

List an organization’s workspaces (API keys see only their project).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `project_id` | query | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `state` | query | string (one of 11) | no | Observed state filter. |
| `desired_state` | query | "running" or "suspended" or "deleted" | no |  |
| `key_prefix` | query | string | no |  |
| `include_deleted` | query | boolean | no |  |
| `lifetime` | query | "persistent" or "session" or "any" | no | persistent (default), session or any. Key lookups pass any. |
| `purpose` | query | "standard" or "template_draft" or "template_test" or "any" | no | standard (default), template_draft, template_test or any. Key lookups pass any. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}`

Get a workspace: desired/observed state, cell, template version, caps/ceilings, grants, active operation, pending reason.

Tombstoned workspaces stay readable (deleted_at set) until final cleanup.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` Workspace · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/workspaces/{workspace_id}`

Delete a workspace (tombstone now, storage cleanup by the cell).

Sets desired_state=deleted and deleted_at, revokes tool access immediately (workspace revocation watermark, agent sessions revoked) and creates a `delete` operation for the cell. A file-first workspace’s tree revisions are deleted with the tombstone and the cell deletes its stored files. Repeating returns the same operation. The key is never reused. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/inputs`

The workspace’s text inputs.

The text inputs the workspace was opened with (the given value, else the declared default). Every exec, PTY, start command and service gets them as environment variables, above the template env and below the call’s own env. Secret inputs are bound secrets (GET …/secrets). Set on create; replaced by an open of the key with `inputs`. Deleted workspaces stay readable.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` WorkspaceInputs · `4XX` ErrorBody · `5XX` ErrorBody

### `PUT /v1/workspaces/{workspace_id}/idle-policy`

Set or clear the workspace idle policy (automatic suspend).

idle_policy: adaptive (the learned timeout), never, or fixed:<seconds> (60..604800); null clears it so the template default (else adaptive) applies. Work signals always win: a running command, an attached session or a keepalive keeps the workspace running. Applies from the idle loop’s next evaluation. 409 session_lifetime for session workspaces (they end after their idle timeout), 409 not_supported_for_mode for file-first workspaces (never suspended), 409 workspace_deleted. Returns the workspace.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `idle_policy` | string or null | yes |  |

**Responses:** `200` Workspace · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/suspend`

Suspend a running workspace (durable full-state checkpoint; a session workspace is 409 session_lifetime).

Creates a durable operation executed by the cell (Phase 8); poll GET /operations/{id}. A file-first workspace is 409 not_supported_for_mode. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/resume`

Resume a suspended workspace (admitted like a start).

Creates a `resume` operation executed by the cell, or returns the active resume/open (concurrent wakes join one operation); 202 with it: poll GET /operations/{id}. Errors: 409 already_running or not_suspended, operation_in_progress (a suspend or another operation is active; details.active_operation_id), workspace_deleted, not_supported_for_mode (file-first); 402/403 as for open (admitted like a start). Supports Idempotency-Key. Held resume: with `Prefer: wait=<seconds>` (at most 20) the response is held until the operation is terminal or the wait elapses, exactly like a held open; success answers 200 with the running workspace, the succeeded operation, `cell_endpoint` and a tool token for `agent_label`/`tools` (minted while the restore is in flight, at the new ownership epoch), `Preference-Applied: wait=<seconds>`; anything else is 202 with the fresh operation. With the preference a workspace that is already running answers 200 at once (operation null, a token) instead of 409 already_running. Without `Preference-Applied` the server did not wait: poll the operation. `tools` beyond the principal’s tool permissions are 403 (nothing is created); a token the API cannot issue otherwise is `tool_token: null`.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `prefer` | header | string | no | RFC 7240 preference, e.g. `wait=20` (held resume). |

**Request body** (`application/json`, optional)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `agent_label` | string | no | Attribution label; one agent session per (workspace, principal, label). |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/snapshot`

Snapshot a running or suspended workspace.

Creates a durable operation executed by the cell (Phase 8); poll GET /operations/{id}. A file-first workspace is 409 not_supported_for_mode. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string | no |  |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/suspend-when-idle`

Suspend the workspace if it stays idle for after_seconds from now (e.g. at the end of an agent turn).

Records a deferred suspend for the cell’s idle loop: once the workspace has been idle for after_seconds (30..3600), it is suspended (within a few seconds of activity flush grace), at `not_before` (= now + after_seconds) at the earliest. A tool call after the request (the next turn) or a resume cancels it for good. Other work only defers it: a command still running, an attached exec/PTY stream or a keepalive postpones the suspend until after_seconds after it ends. It applies under every idle policy (`never` included) and only ever shortens the policy’s wait; a policy that suspends sooner still does. Repeating replaces the pending request (the new `requested_at` counts); DELETE cancels it; `idle.suspend_request` on the workspace shows it. 202 {workspace, operation: null, suspend_request}; when a suspend is already in progress, 202 {workspace, operation: that suspend, suspend_request: null} and nothing is recorded. The suspend, when it happens, is a system `suspend` operation with input.reason requested_after_idle (input.requested_at, input.after_seconds). Errors: 409 session_lifetime, workspace_deleted, operation_in_progress (another lifecycle operation is active), not_running, not_supported_for_mode (file-first). Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `after_seconds` | integer | yes | Seconds from now the workspace must stay idle before it is suspended (30..3600). |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/workspaces/{workspace_id}/suspend-when-idle`

Cancel a pending suspend-when-idle request.

Idempotent: 200 with the workspace whether or not a request was pending, in any workspace state (idle.suspend_request is null afterwards). A suspend the request already started is not undone: it shows as the workspace’s active_operation; resume or open the workspace instead.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` Workspace · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/fork`

Fork a workspace into a new key (independent copy of its committed state).

Creates the target workspace (same template version and disk layout) and a `fork` operation on it, admitted like a start. Caps default to the source’s. `lifetime` is the fork’s own (default persistent): forking a session keeps its state. A file-first source is 409 not_supported_for_mode. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | yes | Stable workspace key, unique per organization (e.g. `${customerId}/${projectId}`). |
| `caps` | object | no | Optional user caps; the ceiling is min(template, cap, plan). Absent fields add no restriction. |
| `lifetime` | WorkspaceLifetime | no | persistent: kept until deleted (default). session: discarded when the session ends (close(), idle timeout or draft discard;). |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/close`

Close a session workspace (ends the session: the workspace is deleted).

Sessions only (409 not_session for a persistent workspace; the SDK then only closes local streams). Ends the session exactly like a delete (VM stopped without a snapshot, layer and checkpoints released) with ended_reason closed; the key then opens a NEW workspace. 202 with the `delete` operation (input.reason session_closed). Idempotent: repeating returns the active or last delete operation. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/reset`

Reset a layered workspace to its template (wipes the workspace layer).

Keeps the key, id, template version, caps, secret bindings, volume attachments and history; wipes every change. Running: restarted on a blank layer (processes are gone, epoch + 1, old tool tokens get 409 stale_epoch). Suspended: stays suspended; the next resume boots blank. The previous checkpoint stays restorable for 7 days (result.recovery_checkpoint_id). 202 with the `reset` operation. Errors: 422 confirm_destructive_required, 409 legacy_disk_layout, not_resettable, operation_in_progress, not_supported_for_mode (file-first). Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required, `ResetWorkspaceBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `confirm_destructive` | boolean | no | Must be true: reset wipes every change in the workspace layer (422 confirm_destructive_required otherwise). |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/save-as-template`

Save a layered workspace as the next version of an organization template.

Everything in the workspace becomes template content (its whole filesystem, minus the sf-scrub.v1 list, the contents of /proc, /sys, /dev, /run and /tmp, and shared-volume contents), stored as one new org layer on the workspace’s template chain. A running workspace is captured briefly (`operation`, layer_snapshot); a suspended one uses its current checkpoint; `checkpoint_id` saves a committed checkpoint instead. 202 SaveAsTemplateResponse: poll the build. Owners/admins and API keys with a tool permission (403 otherwise). Errors: 409 legacy_disk_layout, workspace_not_running, operation_in_progress, not_supported_for_mode (file-first), template_archived, 422 platform_template_slug, invalid_defaults, update_policy_not_available, too_many_acknowledged_findings, invalid_path, 403 quota_exceeded (concurrent_template_builds). Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required, `SaveAsTemplateBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `template_slug` | string | yes | Organization template to save into (created when absent; platform slugs are refused). |
| `display_name` | string | no | Template name when this save creates the template. |
| `description` | string | no | The version description (default: the source version’s). |
| `defaults` | object | no | Defaults of the new version (omitted fields: persistent lifetime, platform idle timeout, no limits). |
| `checkpoint_id` | string (uuid) | no | A committed checkpoint of this workspace to save instead of its current state. |
| `auto_publish` | boolean | no |  |
| `acknowledged_scan_findings` | array of string | no | Up to 200 absolute paths the credential scan may report without failing the build (recorded in the manifest). |
| `settings` | TemplateSettingsInput | no | What a workspace of the version gets when it opens (). Omitted fields are empty (recipes do not carry settings forward). |

**Responses:** `202` SaveAsTemplateResponse · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/secrets`

Secret names bound to a workspace, with per-name status (never values).

Bound secrets are injected into every exec and PTY start of the workspace together with the call’s `secret_refs`. status: available | not_allowed | deleted. Deleted workspaces stay readable.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `PUT /v1/workspaces/{workspace_id}/secrets`

Replace the secret names bound to a workspace.

Replaces the whole binding (`names: []` clears it); applies from the next exec/PTY start (running processes keep their environment). Every name must be a live secret this workspace may use (its project, its id, and allowed_tools including exec and pty), else 422 details.reason secret_not_available with details.names and nothing changes. A name equal to a key of the template version’s env or one of its text inputs is 422 env_collision (details.name;). Exactly the given names are bound (the version’s secret inputs join the binding only through open). Audited when the binding changes. 409 workspace_deleted.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `names` | array of string | yes | Secret names bound to the workspace (max 50, unique): injected as environment variables into every exec and PTY start (terminal sessions included), together with the call’s own `secret_refs`. Each must name a live secret this workspace may use (its project, its id, and allowed_tools including exec and pty), else 422 details.reason secret_not_available with details.names. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Operations

Lifecycle operations (open, suspend, resume, fork, ...) and their progress.

### `GET /v1/operations/{operation_id}`

Poll an asynchronous operation.

Stays readable after its workspace is tombstoned. Bounded wait: with `Prefer: wait=<seconds>` (at most 20) and an operation that is not terminal, the response is held until its `state` or `state_reason` changes or the wait elapses, then carries the fresh operation and `Preference-Applied: wait=<seconds>`. Without `Preference-Applied` the server did not wait: poll with backoff.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `prefer` | header | string | no | RFC 7240 preference, e.g. `wait=20`. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/operations`

List a workspace’s operations, newest first.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `state` | query | "queued" or "capacity_pending" or "running" or "succeeded" or "failed" or "canceled" | no |  |
| `kind` | query | string (one of 15) | no |  |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Tool tokens

Short-lived tokens for the workspace tools (exec, PTY, files, git, browser) served at the workspace’s cell_endpoint, and agent sessions.

### `POST /v1/workspaces/{workspace_id}/tool-tokens`

Issue a workspace tool token (ES256, <= 15 min) for the cell gateway.

Creates or reuses the agent session for (workspace, principal, agent_label) and signs a token with claims iss, aud, sub, pty, org, prj, ws, epoch, tools, sid, iat, exp, jti (verify with GET /v1/.well-known/tool-token-keys). `tools` must be a subset of the API key’s tool permissions (users: of their role). A suspended workspace gets a token too: the cell serves it only the reads of its disk (`files` read, stat, list and search, `X-Served-From: disk`) and answers every other call 409 `workspace_not_running` (wake the workspace; its resume moves the ownership epoch). 409 with the active operation when the workspace is neither running nor suspended; on 409 `stale_epoch` from the cell, request a new token.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `agent_label` | string | no | Attribution label; one agent session per (workspace, principal, label). |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |

**Responses:** `201` ToolToken · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/agent-sessions`

List the attributed agent sessions of a workspace.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Volumes

Persistent volumes shared between workspaces of a project.

### `GET /v1/projects/{project_id}/volumes`

List a project’s shared volumes (optionally with the organization’s org-shared volumes).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_org_shared` | query | boolean | no | Also list org-shared volumes owned by other projects of the organization (attachable here). |
| `include_deleted` | query | boolean | no |  |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/projects/{project_id}/volumes`

Create a shared volume (EFS access point created by the cell).

202 with the volume (state `creating`) and its `volume_create` operation; the volume becomes `available` when the cell has created its storage (poll the operation or the volume). Quotas from the plan: limit.shared_volumes_max (live volumes per organization) and limit.shared_volume_gib_max (quota_gib of one volume) -> 403 quota_exceeded (details.limit); a plan without them -> 402 entitlement_required (details.reason limits_missing). `org_shared: true` (owners/admins only) makes it attachable from every project of the organization. 409 name_in_use. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Unique among the project’s live volumes. |
| `quota_gib` | integer | yes |  |
| `org_shared` | boolean | no |  |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/projects/{project_id}/volumes/{volume_id}`

Get a shared volume (the project’s own, or an org-shared volume of its organization).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `volume_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` Volume · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/projects/{project_id}/volumes/{volume_id}`

Delete a shared volume and its data (executed by the cell).

202 with the volume (state `deleting`) and its `volume_delete` operation; `deleted` once the cell removed the data and the access point. Refused with 409 volume_attached (details.attachments) while it is attached, unless `force=true&confirm_name=<volume name>`: attachments are then detached by the delete (running guests see I/O errors on the mount). Only the owning project; org-shared volumes only by owners/admins. Idempotent (a deleting/deleted volume returns its delete operation). Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `force` | query | boolean | no |  |
| `confirm_name` | query | string | no |  |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `volume_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/projects/{project_id}/volumes/{volume_id}/attachments`

List a volume’s attachments (API keys: only to their project’s workspaces).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_detached` | query | boolean | no |  |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `volume_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/volumes`

List the volumes attached to a workspace.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_detached` | query | boolean | no |  |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/workspaces/{workspace_id}/volumes`

Attach a shared volume to a workspace at mount_path (ro or rw).

202 with the attachment (state `attaching`) and its `volume_attach` operation; the cell mounts it in the running guest (or records it for the next start when the workspace has no VM) and reports `attached`. The volume must be `available`, of the same organization and of the workspace’s project unless org-shared (409 volume_not_shared). One attachment per (volume, workspace); mount paths may not nest (409 mount_path_conflict); at most 8 per workspace (403 quota_exceeded). Attach/detach share the workspace’s single active lifecycle operation: 409 operation_in_progress while another one runs. Re-attaching an attached volume with the same mount_path/mode returns 200 without a new operation. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `volume_id` | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `mount_path` | string | yes | Absolute guest path (e.g. /mnt/data); segments of A-Z a-z 0-9 . _ - not starting with "."; system directories (/etc, /usr, /proc, /tmp, /var, ...) and nesting with another mount are refused. |
| `mode` | "ro" or "rw" | no |  |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/workspaces/{workspace_id}/volumes/{volume_id}`

Detach a shared volume from a workspace.

202 with the attachment (state `detaching`) and its `volume_detach` operation; the cell unmounts it (the guest sees I/O errors on the path afterwards, never stale writes) and reports `detached`. Idempotent while detaching. 404 when the volume is not attached. 409 operation_in_progress while another lifecycle operation of the workspace runs. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `volume_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

## Templates

Published templates and their versions, files, recipes and diffs.

### `GET /v1/organizations/{organization_id}/templates`

List the templates an organization can use (platform + its own), with the version `open` picks.

Archived templates only with include_archived=true. Ordered by id; cursor pagination.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_archived` | query | boolean | no | Include archived templates and versions. |
| `owner` | query | "platform" or "organization" | no |  |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}`

Get a template by slug with its versions, compatibility, caps, installed tools and plan clamping.

The organization’s own template shadows a platform template of the same slug (like `open`); `open_resolves_to` is what `open` would use now. Unpublished versions appear only for owners/admins of the owning organization. 404 outside the organization.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `include_archived` | query | boolean | no | Include archived templates and versions. |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates`

List the templates an organization can use (platform + its own), with the version `open` picks (the API key’s organization).

Archived templates only with include_archived=true. Ordered by id; cursor pagination.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_archived` | query | boolean | no | Include archived templates and versions. |
| `owner` | query | "platform" or "organization" | no |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}`

Get a template by slug with its versions, compatibility, caps, installed tools and plan clamping (the API key’s organization).

The organization’s own template shadows a platform template of the same slug (like `open`); `open_resolves_to` is what `open` would use now. Unpublished versions appear only for owners/admins of the owning organization. 404 outside the organization.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `include_archived` | query | boolean | no | Include archived templates and versions. |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `slug` | path | string | yes |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}/versions/{version}/files`

List one directory of a template version’s file tree.

Entries directly inside `path` (default `/`), sorted by name bytes, keyset-paginated. The tree covers the whole filesystem (only the contents of /proc, /sys, /dev, /run and /tmp are left out). 409 file_list_unavailable (no file list) or file_list_indexing (retryable); 404 path_not_found; 422 invalid_path.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `path` | query | string | no | Absolute path (`/`, `/home/user`, no trailing slash). |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Responses:** `200` TemplateFilePage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/versions/{version}/files`

List one directory of a template version’s file tree.

Entries directly inside `path` (default `/`), sorted by name bytes, keyset-paginated. The tree covers the whole filesystem (only the contents of /proc, /sys, /dev, /run and /tmp are left out). 409 file_list_unavailable (no file list) or file_list_indexing (retryable); 404 path_not_found; 422 invalid_path.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `path` | query | string | no | Absolute path (`/`, `/home/user`, no trailing slash). |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Responses:** `200` TemplateFilePage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}/versions/{version}/files/entry`

Get one entry of a template version’s file tree.

404 path_not_found; 409 file_list_unavailable / file_list_indexing; 422 invalid_path.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `path` | query | string | yes | Absolute path (`/`, `/home/user`, no trailing slash). |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Responses:** `200` TemplateFileEntry · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/versions/{version}/files/entry`

Get one entry of a template version’s file tree.

404 path_not_found; 409 file_list_unavailable / file_list_indexing; 422 invalid_path.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `path` | query | string | yes | Absolute path (`/`, `/home/user`, no trailing slash). |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Responses:** `200` TemplateFileEntry · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}/diff`

Diff two versions of a template (path, change, before, after).

`from` is a version number of this template or `base` (the `to` version’s build base, which may be a platform version). Keyset-paginated by path; `path_prefix` narrows it (string prefix), `change` filters one kind. The first page (no cursor) carries `summary`. 409 file_list_unavailable / file_list_indexing when either version has no loaded file list.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string | yes | A version number, or `base`. |
| `to` | query | integer | yes |  |
| `path_prefix` | query | string | no |  |
| `change` | query | "added" or "removed" or "changed" or "type_changed" or "metadata" | no |  |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Responses:** `200` TemplateDiffPage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/diff`

Diff two versions of a template (path, change, before, after).

`from` is a version number of this template or `base` (the `to` version’s build base, which may be a platform version). Keyset-paginated by path; `path_prefix` narrows it (string prefix), `change` filters one kind. The first page (no cursor) carries `summary`. 409 file_list_unavailable / file_list_indexing when either version has no loaded file list.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string | yes | A version number, or `base`. |
| `to` | query | integer | yes |  |
| `path_prefix` | query | string | no |  |
| `change` | query | "added" or "removed" or "changed" or "type_changed" or "metadata" | no |  |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `slug` | path | string | yes |  |

**Responses:** `200` TemplateDiffPage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}/versions/{version}/recipe`

Export the recipe and settings of a template version (request form, ready to build again).

recipe: v1 {base, dockerfile, network} or the recipe v2 document (base as <slug>@<version>, languages as {id, version}, files without size, settings as stored); building it again from the same base with the same API release, while its uploads exist, gives the same recipe_sha256. null for versions saved from a workspace or published by the platform. settings: the version’s TemplateSettings. Same visibility as the version (unpublished only for owners/admins of the owning organization).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Responses:** `200` TemplateVersionRecipe · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/versions/{version}/recipe`

Export the recipe and settings of a template version (request form, ready to build again).

recipe: v1 {base, dockerfile, network} or the recipe v2 document (base as <slug>@<version>, languages as {id, version}, files without size, settings as stored); building it again from the same base with the same API release, while its uploads exist, gives the same recipe_sha256. null for versions saved from a workspace or published by the platform. settings: the version’s TemplateSettings. Same visibility as the version (unpublished only for owners/admins of the owning organization).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `owner` | query | "platform" or "organization" | no | Pick the platform template even when an organization template shadows its slug. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Responses:** `200` TemplateVersionRecipe · `4XX` ErrorBody · `5XX` ErrorBody

## Template builds

Build template versions from a recipe or an uploaded context, and look up packages and languages.

### `GET /v1/organizations/{organization_id}/template-builds`

List an organization’s template builds, newest first.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `state` | query | string (one of 8) | no |  |
| `template` | query | string | no | Organization template slug. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/template-builds`

Request a custom template build (queued for the isolated builder).

202 with the queued build. Takes recipe v1 (a Dockerfile) or recipe v2 (: validated against the base, compiled to the builder’s steps, uploads locked). Records the canonical recipe and its SHA-256 (provenance input), pins the base version, clamps resources to the plan and assigns `target_version`. The cell executes it (building -> testing -> publishing -> published) and the API then registers the version (published at once unless auto_publish=false); poll GET .../template-builds/{id} until `registration.state` is registered (or the build failed/was canceled). `builder_availability` says whether a builder is running. Errors: 422 when the recipe is outside the host builder Dockerfile dialect or the slug is not a builder slug (`details.reason`: multi_stage_not_supported, from_not_template_base, stage_names_not_supported, from_flags_not_supported, base_mismatch, run_flags_not_supported, heredoc_not_supported, instruction_not_supported, no_build_context, env_invalid, user_invalid, too_many_steps, recipe_too_large, slug_not_supported_by_builder, platform_template_slug, reserved_template_slug, base_not_found, base_not_published, base_archived, architecture_not_supported, ...; `line` for line errors; recipe v2: invalid_recipe (details.field, details.detail), base_not_layered, language_unavailable, language_conflict, invalid_package, too_many_files, platform_owned_path, invalid_path, upload_required, upload_missing, upload_digest_mismatch, upload_too_large, too_many_steps, recipe_too_large, allowlist_empty, allow_hosts_without_allowlist, extra_hosts_without_auto, too_many_hosts, invalid_host, ip_literal_not_allowed, host_not_allowed, invalid_settings, services_unsupported, too_many_acknowledged_findings), 503 dependency_unavailable (uploads_not_configured), 402 entitlement_required, 403 quota_exceeded (concurrent_template_builds), 409 template_archived. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `template_slug` | string | yes | Organization template to build into (created by the first build). Platform slugs and the reserved slugs new and edit are refused for new templates. |
| `auto_publish` | boolean | no | Publish the produced version as soon as it is registered (new workspaces of the slug then use it). false: it stays unpublished until an owner/admin publishes it. |
| `display_name` | string | no | Template name when this build creates the template. |
| `recipe` | TemplateRecipeV1 or TemplateRecipeV2 | yes | Recipe v1 (TemplateRecipeV1: a Dockerfile, no `schema` field) or recipe v2 (TemplateRecipeV2, `schema: "shardflux.template-recipe.v2"`: languages, packages, uploaded files, build steps, auto network and settings;). |
| `description` | string | no | The version description (manifest `description`). |
| `acknowledged_scan_findings` | array of string | no | Recipe v2 only: up to 200 absolute paths the credential scan may report without failing the build (recorded in the manifest). |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/template-builds/{build_id}`

Get a template build: state, timestamps, bounds, provenance, results, failure, log tail and builder availability.

Bounded wait: with `Prefer: wait=<seconds>` (at most 20) and a build that is not settled (settled = failed, canceled, legacy succeeded, or published with registration registered/failed), the response is held until `state` or `registration.state` changes or the wait elapses, then carries the fresh build and `Preference-Applied: wait=<seconds>`. Without `Preference-Applied` the server did not wait: poll with backoff.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `build_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `prefer` | header | string | no | RFC 7240 preference, e.g. `wait=20`. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/template-builds/{build_id}/cancel`

Cancel a template build.

queued -> canceled (200); building/testing/publishing -> cancellation requested (202; honoured until the artifact upload starts, so a build in `publishing` may still end `published`); canceled -> unchanged (200); published/succeeded/failed -> 409 build_finished. API keys may cancel only builds they requested.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `build_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/template-builds/{build_id}/log-url`

Get a short-lived download URL for the full build log.

Presigned S3 GET of `builds/<build_id>.log` (served as an attachment `build-<id>.log`, text/plain), valid until `expires_at` (at most 15 minutes). Same access as reading the build. 404 `not_found` with `details.reason` log_not_available (no full log recorded for this build, e.g. still running) or log_expired (past its retention); 503 `dependency_unavailable` when this deployment has no build-log bucket. Audited; never cached.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `build_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/template-uploads`

Request an upload of a recipe build input (a file, or a folder as an uncompressed tar), by content.

200 {upload, put: null} when the organization already has these bytes (a pending upload is checked in the bucket first). 201 with a presigned S3 PUT otherwise (valid 900 s): send the bytes with every header of `put.headers` (x-amz-checksum-sha256 and content-length are signed, so S3 refuses other bytes), then reference `sha256:<hex>` in the recipe’s build.files. Idempotent by content (no Idempotency-Key needed). Errors: 422 upload_too_large (details.limit upload_bytes_max: at most 5 GiB), 422 upload_digest_mismatch (the same sha256 is available with another size), 503 dependency_unavailable (uploads_not_configured). Owners/admins and API keys with a tool permission.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required, `TemplateUploadRequest`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sha256` | string | yes | Lower-case hex SHA-256 of the bytes. |
| `size` | integer | yes | Bytes, at most 5368709120 (5 GiB; more is 422 upload_too_large). |
| `kind` | "file" or "tar" | yes | file, or tar (an uncompressed ustar/pax archive of a folder). Recorded for display; the recipe entry decides. |

**Responses:** `200` TemplateUploadResponse · `201` TemplateUploadResponse · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/template-uploads`

Request an upload of a recipe build input (a file, or a folder as an uncompressed tar), by content (the API key’s organization).

200 {upload, put: null} when the organization already has these bytes (a pending upload is checked in the bucket first). 201 with a presigned S3 PUT otherwise (valid 900 s): send the bytes with every header of `put.headers` (x-amz-checksum-sha256 and content-length are signed, so S3 refuses other bytes), then reference `sha256:<hex>` in the recipe’s build.files. Idempotent by content (no Idempotency-Key needed). Errors: 422 upload_too_large (details.limit upload_bytes_max: at most 5 GiB), 422 upload_digest_mismatch (the same sha256 is available with another size), 503 dependency_unavailable (uploads_not_configured). Owners/admins and API keys with a tool permission.

**Authentication:** API key

**Request body** (`application/json`, required, `TemplateUploadRequest`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `sha256` | string | yes | Lower-case hex SHA-256 of the bytes. |
| `size` | integer | yes | Bytes, at most 5368709120 (5 GiB; more is 422 upload_too_large). |
| `kind` | "file" or "tar" | yes | file, or tar (an uncompressed ustar/pax archive of a folder). Recorded for display; the recipe entry decides. |

**Responses:** `200` TemplateUploadResponse · `201` TemplateUploadResponse · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/template-packages`

Search apt, pip or npm packages for a recipe.

apt: the base’s package index (409 package_index_unavailable when the base has none); pip: the daily PyPI name list (names only; 409 package_index_unavailable before its first refresh); npm: the registry search. Upstreams are bounded (503 dependency_unavailable, retryable, when they do not answer within 2.5 s) and each organization may make 120 lookups a minute (429 rate_limited). Owners/admins and API keys with a tool permission.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `ecosystem` | query | "apt" or "pip" or "npm" | yes |  |
| `q` | query | string | yes |  |
| `base` | query | string | no | `<slug>@<version>` (apt: the base whose apt index is searched). |
| `limit` | query | integer | no |  |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` TemplatePackagePage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/template-packages/{ecosystem}/{name}`

Get one apt, pip or npm package: latest version, summary and versions.

404 not_found (package_not_found). apt needs base=<slug>@<version>. Same limits as the search.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `base` | query | string | no | `<slug>@<version>` (apt: the base whose apt index is searched). |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `ecosystem` | path | "apt" or "pip" or "npm" | yes |  |
| `name` | path | string | yes |  |

**Responses:** `200` TemplatePackage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/template-packages`

Search apt, pip or npm packages for a recipe (the API key’s organization).

apt: the base’s package index (409 package_index_unavailable when the base has none); pip: the daily PyPI name list (names only; 409 package_index_unavailable before its first refresh); npm: the registry search. Upstreams are bounded (503 dependency_unavailable, retryable, when they do not answer within 2.5 s) and each organization may make 120 lookups a minute (429 rate_limited). Owners/admins and API keys with a tool permission.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `ecosystem` | query | "apt" or "pip" or "npm" | yes |  |
| `q` | query | string | yes |  |
| `base` | query | string | no | `<slug>@<version>` (apt: the base whose apt index is searched). |
| `limit` | query | integer | no |  |

**Responses:** `200` TemplatePackagePage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/template-packages/{ecosystem}/{name}`

Get one apt, pip or npm package: latest version, summary and versions (the API key’s organization).

404 not_found (package_not_found). apt needs base=<slug>@<version>. Same limits as the search.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `base` | query | string | no | `<slug>@<version>` (apt: the base whose apt index is searched). |
| `ecosystem` | path | "apt" or "pip" or "npm" | yes |  |
| `name` | path | string | yes |  |

**Responses:** `200` TemplatePackage · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/template-languages`

The recipe languages a base offers: version, default, whether the base already has it, and the hosts its install needs.

The language table read for the chain’s platform base of `base`, resolved as a build resolves `recipe.base` (422 validation_failed with details.field "base" and the build’s reasons: base_not_found, base_archived, base_not_published, architecture_not_supported). `included`: the base already has that version (python-node-browser: python and node), so the build installs nothing for it. A version the base has another version of is left out (a build would refuse it with language_conflict). Owners/admins and API keys with a tool permission.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `base` | query | string | yes | `<slug>@<version>` (apt: the base whose apt index is searched). |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` TemplateLanguages · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/template-languages`

The recipe languages a base offers: version, default, whether the base already has it, and the hosts its install needs (the API key’s organization).

The language table read for the chain’s platform base of `base`, resolved as a build resolves `recipe.base` (422 validation_failed with details.field "base" and the build’s reasons: base_not_found, base_archived, base_not_published, architecture_not_supported). `included`: the base already has that version (python-node-browser: python and node), so the build installs nothing for it. A version the base has another version of is left out (a build would refuse it with language_conflict). Owners/admins and API keys with a tool permission.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `base` | query | string | yes | `<slug>@<version>` (apt: the base whose apt index is searched). |

**Responses:** `200` TemplateLanguages · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/template-builder-availability`

Whether a template builder is running (heartbeat within 60 s).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Template drafts

Draft templates: iterate on states and test instances, then publish a version.

### `GET /v1/organizations/{organization_id}/templates/{slug}/draft`

Get the template’s draft.

404 draft_not_found when the template has no live draft.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Responses:** `200` TemplateDraft · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/templates/{slug}/draft`

Create the template’s draft (a layered workspace on the draft base).

Opens the organization template’s single live draft (key sf:draft:<slug>:<8 hex>, purpose template_draft, persistent, layered) on `base` (default: the latest published version; required when the template has none, which creates the organization template named `display_name`, default the slug). 202 with the open operation and the draft. Errors: 409 template_not_layered (the base is not layered-capable, or layered opens are off), 409 draft_exists (details.workspace_id), 403 template_dev_mode_role, 422 platform_template_slug / reserved_template_slug (new, edit) / base_required. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required, `CreateDraftBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `base` | string | no | `<slug>@<version>`: a published, layered-capable version (default: the template’s latest published version; required when the template has none, which creates the organization template). |
| `project_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `display_name` | string | no | Template name when this draft creates the organization template (default: the slug). |
| `caps` | object | no |  |
| `agent_label` | string | no |  |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/organizations/{organization_id}/templates/{slug}/draft`

Discard the draft (delete it and end its live test instances).

Deletes the draft (operation `delete`, input.reason draft_discarded) and ends each live test instance (ended_reason draft_discarded). 202 with the draft’s delete operation. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/draft`

Get the template’s draft.

404 draft_not_found when the template has no live draft.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |

**Responses:** `200` TemplateDraft · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/templates/{slug}/draft`

Create the template’s draft (a layered workspace on the draft base).

Opens the organization template’s single live draft (key sf:draft:<slug>:<8 hex>, purpose template_draft, persistent, layered) on `base` (default: the latest published version; required when the template has none, which creates the organization template named `display_name`, default the slug). 202 with the open operation and the draft. Errors: 409 template_not_layered (the base is not layered-capable, or layered opens are off), 409 draft_exists (details.workspace_id), 403 template_dev_mode_role, 422 platform_template_slug / reserved_template_slug (new, edit) / base_required. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required, `CreateDraftBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `base` | string | no | `<slug>@<version>`: a published, layered-capable version (default: the template’s latest published version; required when the template has none, which creates the organization template). |
| `project_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `display_name` | string | no | Template name when this draft creates the organization template (default: the slug). |
| `caps` | object | no |  |
| `agent_label` | string | no |  |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/templates/{slug}/draft`

Discard the draft (delete it and end its live test instances).

Deletes the draft (operation `delete`, input.reason draft_discarded) and ends each live test instance (ended_reason draft_discarded). 202 with the draft’s delete operation. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}/draft/states`

List the draft’s states, newest first.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/templates/{slug}/draft/states`

Capture a draft state (disk-only layer_snapshot of the running draft).

202 with the `layer_snapshot` operation; its result carries checkpoint_id. A suspended draft is 409 workspace_not_running (its current checkpoint already serves as a state). Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string | no |  |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/draft/states`

List the draft’s states, newest first.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `slug` | path | string | yes |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/templates/{slug}/draft/states`

Capture a draft state (disk-only layer_snapshot of the running draft).

202 with the `layer_snapshot` operation; its result carries checkpoint_id. A suspended draft is 409 workspace_not_running (its current checkpoint already serves as a state). Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | string | no |  |

**Responses:** `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/templates/{slug}/draft/test-instances`

List the draft’s test instances.

Live instances; include_ended=true adds ended ones (tombstones with ended_reason). API keys see only their own project’s instances.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_ended` | query | boolean | no |  |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/templates/{slug}/draft/test-instances`

Open a test instance of a draft state (a disposable session workspace).

Opens a layered session workspace (purpose template_test) on the draft base whose layer is a copy of the draft state; its own writes never reach the draft. Without state_id the running draft is captured first (the instance’s open depends on that layer_snapshot; origin.checkpoint_id is filled when the cell places it); a suspended draft’s current checkpoint is used instead. The instance ends on close(), idle or draft discard. 202 OpenResponse. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required, `CreateTestInstanceBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `state_id` | string (uuid) | no | A draft state (GET …/draft/states). Omitted: a fresh capture of the running draft (its current checkpoint when suspended). |
| `key` | string | no | Workspace key (default sf:test:<slug>:<8 hex>); keys starting with sf: are reserved. |
| `caps` | object | no |  |
| `agent_label` | string | no |  |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/templates/{slug}/draft/test-instances`

List the draft’s test instances.

Live instances; include_ended=true adds ended ones (tombstones with ended_reason). API keys see only their own project’s instances.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_ended` | query | boolean | no |  |
| `slug` | path | string | yes |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/templates/{slug}/draft/test-instances`

Open a test instance of a draft state (a disposable session workspace).

Opens a layered session workspace (purpose template_test) on the draft base whose layer is a copy of the draft state; its own writes never reach the draft. Without state_id the running draft is captured first (the instance’s open depends on that layer_snapshot; origin.checkpoint_id is filled when the cell places it); a suspended draft’s current checkpoint is used instead. The instance ends on close(), idle or draft discard. 202 OpenResponse. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required, `CreateTestInstanceBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `state_id` | string (uuid) | no | A draft state (GET …/draft/states). Omitted: a fresh capture of the running draft (its current checkpoint when suspended). |
| `key` | string | no | Workspace key (default sf:test:<slug>:<8 hex>); keys starting with sf: are reserved. |
| `caps` | object | no |  |
| `agent_label` | string | no |  |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/templates/{slug}/draft/publish`

Publish the draft as the template’s next version (save-as-template from the draft).

Builds one new org layer holding every change since the draft base, from `state_id` or the draft’s current state (a fresh capture when running). `settings` (TemplateSettingsInput): each given field replaces that field of the draft base’s settings, each absent one is carried forward (settings.defaults and defaults together: 422 invalid_settings). Refused with 409 draft_stale (details latest_version, draft_base_version) when the template has a version newer than the draft base that this draft did not produce, and 409 build_in_progress while a build from another source is unfinished. 202 SaveAsTemplateResponse. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required, `PublishDraftBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `state_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `description` | string | no |  |
| `defaults` | object | no | Defaults of the new version (omitted fields: persistent lifetime, platform idle timeout, no limits). |
| `settings` | TemplateSettingsInput | no | What a workspace of the version gets when it opens (). Omitted fields are empty (recipes do not carry settings forward). |
| `auto_publish` | boolean | no |  |
| `acknowledged_scan_findings` | array of string | no | Up to 200 absolute paths the credential scan may report without failing the build (recorded in the manifest). |

**Responses:** `202` SaveAsTemplateResponse · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/templates/{slug}/draft/publish`

Publish the draft as the template’s next version (save-as-template from the draft).

Builds one new org layer holding every change since the draft base, from `state_id` or the draft’s current state (a fresh capture when running). `settings` (TemplateSettingsInput): each given field replaces that field of the draft base’s settings, each absent one is carried forward (settings.defaults and defaults together: 422 invalid_settings). Refused with 409 draft_stale (details latest_version, draft_base_version) when the template has a version newer than the draft base that this draft did not produce, and 409 build_in_progress while a build from another source is unfinished. 202 SaveAsTemplateResponse. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |

**Request body** (`application/json`, required, `PublishDraftBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `state_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `description` | string | no |  |
| `defaults` | object | no | Defaults of the new version (omitted fields: persistent lifetime, platform idle timeout, no limits). |
| `settings` | TemplateSettingsInput | no | What a workspace of the version gets when it opens (). Omitted fields are empty (recipes do not carry settings forward). |
| `auto_publish` | boolean | no |  |
| `acknowledged_scan_findings` | array of string | no | Up to 200 absolute paths the credential scan may report without failing the build (recorded in the manifest). |

**Responses:** `202` SaveAsTemplateResponse · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/organizations/{organization_id}/templates/{slug}/versions/{version}/test-instances`

Open a test instance of a template version, published or not.

Opens a layered session workspace (purpose template_test) on that version of the organization’s template: registered and not archived, published or not. It is a fresh workspace (a `create` startup run) with origin {kind: template_version, template_id, version}; it ends on close() or idle. `inputs` as for open (422 input_unknown, input_invalid, input_required). 202 OpenResponse (200 when an existing key is running and ready). Errors: 403 template_dev_mode_role (owners, admins and API keys with a tool permission only), 404 version_not_found, 409 template_not_layered, template_archived, 422 reserved_key_prefix. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Request body** (`application/json`, required, `CreateVersionTestInstanceBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | no | Workspace key (default sf:test:<slug>:<8 hex>); caller keys starting with sf: are reserved. |
| `caps` | object | no |  |
| `agent_label` | string | no |  |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |
| `project_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/templates/{slug}/versions/{version}/test-instances`

Open a test instance of a template version, published or not.

Opens a layered session workspace (purpose template_test) on that version of the organization’s template: registered and not archived, published or not. It is a fresh workspace (a `create` startup run) with origin {kind: template_version, template_id, version}; it ends on close() or idle. `inputs` as for open (422 input_unknown, input_invalid, input_required). 202 OpenResponse (200 when an existing key is running and ready). Errors: 403 template_dev_mode_role (owners, admins and API keys with a tool permission only), 404 version_not_found, 409 template_not_layered, template_archived, 422 reserved_key_prefix. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `slug` | path | string | yes |  |
| `version` | path | integer | yes |  |

**Request body** (`application/json`, required, `CreateVersionTestInstanceBody`)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `key` | string | no | Workspace key (default sf:test:<slug>:<8 hex>); caller keys starting with sf: are reserved. |
| `caps` | object | no |  |
| `agent_label` | string | no |  |
| `tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |
| `inputs` | object | no | Open-time inputs of the template version: {NAME: string} for its declared text inputs. A new workspace stores each given value, else the declared default; on an existing key `inputs` replaces them all (omitted = unchanged). Secret inputs are not passed here: they bind the stored secret of the same name. 422 input_unknown (undeclared name, details.names), input_invalid (a secret input, a non-string, or a value over 4096 bytes or with CR, LF or NUL; details.names), input_required (details {names, kind}). |
| `project_id` | string (uuid) | no | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `202` · `4XX` ErrorBody · `5XX` ErrorBody

## Secrets

Project secrets, their versions, and the secrets bound to a workspace.

### `GET /v1/projects/{project_id}/secrets`

List a project’s secrets (metadata only).

Project-scoped secrets only; organization secrets are listed under the organization.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `include_deleted` | query | boolean | no |  |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/projects/{project_id}/secrets`

Create a project secret (owner/admin, or an API key of this project).

The value is encrypted (KMS envelope) and never returned. `allowed_tools` defaults to [exec, pty]. 409 when the name exists in this project. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Environment-variable-safe name, unique per scope (`SHARDFLUX_` prefix reserved). |
| `description` | string | no |  |
| `value` | string | yes | UTF-8 text, at most 65536 bytes, no NUL characters. Write-only: never returned by any management API. |
| `allowed_workspace_ids` | array of string (uuid) or null | no | Default null (any workspace of the project). |
| `allowed_tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |

**Responses:** `201` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/secrets/{secret_id}`

Get secret metadata (never the value).

Deleted secrets stay readable (deleted_at set).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `secret_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `PATCH /v1/secrets/{secret_id}`

Update a secret’s description or usage permissions.

Takes effect for the next session start that resolves it. Names are immutable (delete and recreate).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `secret_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | string | no |  |
| `allowed_project_ids` | array of string (uuid) or null | no |  |
| `allowed_workspace_ids` | array of string (uuid) or null | no |  |
| `allowed_tools` | array of "exec" or "files" or "pty" or "process" or "git" or "browser" | no |  |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/secrets/{secret_id}`

Delete a secret (tombstone; erases every stored value; resolution stops immediately).

Any session start after this returns it as denied. The name becomes reusable. The name is removed from every workspace binding that referred to this secret (same transaction, one audit event per workspace). Values already injected into running processes cannot be recalled. Repeating returns 204.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `secret_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `204` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/secrets/{secret_id}/versions`

List a secret’s versions, newest first (metadata only).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `secret_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `POST /v1/secrets/{secret_id}/versions`

Rotate: store a new value as the next version.

The new version is used from the next session start; every older version’s value is erased in the same transaction (metadata kept). Values already injected into running processes are not recalled. Supports Idempotency-Key.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `secret_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `value` | string | yes | UTF-8 text, at most 65536 bytes, no NUL characters. Write-only: never returned by any management API. |

**Responses:** `201` · `4XX` ErrorBody · `5XX` ErrorBody

## Egress policy

Outbound network policy of a project or a workspace.

### `GET /v1/projects/{project_id}/egress-policy`

Get a project’s egress policy (default for its workspaces).

`version` (also the ETag) is the value to send as If-Match. Without a project policy the effective policy is the platform default (allow_all).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `PUT /v1/projects/{project_id}/egress-policy`

Replace a project’s egress policy (creates a new immutable version).

mode allow_all | allowlist | deny_all; rules/cidrs only with allowlist. Hosts: exact FQDN or `*.` single-label wildcard (IDNA-normalized, lower-case); IP literals and localhost-like names are rejected. Ports 1-65535 (default [443]); protocols tcp only. cidrs: public ranges only. Optional If-Match: <version> (409 conflict with details.reason version_mismatch). Owner/admin or an API key of this project with tool permissions.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `if-match` | header | string | no |  |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | "allow_all" or "allowlist" or "deny_all" | yes |  |
| `rules` | array of object | no | allowlist only; at most 256. |
| `cidrs` | array of string | no | allowlist only; public ranges; at most 64. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/projects/{project_id}/egress-policy/versions`

Version history of a project’s egress policy, newest first.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `project_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/egress-policy`

Effective egress policy of a workspace and its enforcement state.

effective = workspace override > project policy > platform default (allow_all). enforcement.state is not_enforced until the cell/host acknowledges the current effective policy for the workspace’s current ownership epoch, then pending | enforced | failed. `version` (ETag) is the override history version for If-Match. template_egress: the template version’s egress ceiling (, null = none); effective_policy: what the host enforces, effective intersected with that ceiling.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `PUT /v1/workspaces/{workspace_id}/egress-policy`

Set a workspace override of the egress policy (new immutable version).

Same body and validation as the project policy. Optional If-Match. 409 conflict (details.reason workspace_deleted) for deleted workspaces. 422 egress_widening (details {template_egress, outside: [hosts] | ["cidrs"] | ["allow_all"]}) when the workspace’s template egress ceiling would narrow the policy.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `if-match` | header | string | no |  |

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | "allow_all" or "allowlist" or "deny_all" | yes |  |
| `rules` | array of object | no | allowlist only; at most 256. |
| `cidrs` | array of string | no | allowlist only; public ranges; at most 64. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `DELETE /v1/workspaces/{workspace_id}/egress-policy`

Remove the workspace override (fall back to the project policy).

Records a `cleared` version. 204 without a new version when there is no active override. Optional If-Match.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |
| `if-match` | header | string | no |  |

**Responses:** `204` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/egress-policy/versions`

Version history of a workspace’s egress override (including cleared versions), newest first.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Usage and spend

Metered usage, estimates, grants and spend of an organization or a workspace.

### `GET /v1/organizations/{organization_id}/usage/summary`

Current-period usage, included allowances and cap state of an organization.

Totals come from the usage ledger (hourly, finalized after the lateness window); `measurement.measured_through` says how far usage is complete. Billable units are whole units (floor per workspace-hour); allowance usage counts billable units. `allowance_exhausted` true means new opens/resumes answer 402 `allowance_exhausted` with `exhausted_reason` as details.reason. `spend_cap` is opt-in overage this period: while it is on, a CPU-hours or RAM GiB-hours allowance past `included` is in cap state `overage` (starts admitted, usage past it charged) until the spend cap is reached.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/usage`

Usage time series (hour or day buckets) from the ledger.

Defaults to the current period at day granularity; hour granularity covers at most 31 days, day at most 400. `workspace_id` / `volume_id` (mutually exclusive) narrow it to one workspace or one shared volume (meter volume_storage_gib_seconds). API keys see only their own project.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string (date-time) | no | RFC 3339 UTC timestamp with Z. |
| `to` | query | string (date-time) | no | RFC 3339 UTC timestamp with Z. |
| `granularity` | query | "hour" or "day" | no |  |
| `meter` | query | "cpu_seconds" or "memory_gib_seconds" or "storage_gib_seconds" or "egress_bytes" or "ingress_bytes" or "volume_storage_gib_seconds" | no |  |
| `workspace_id` | query | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `volume_id` | query | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `project_id` | query | string (uuid) | no | UUIDv7, lowercase canonical form. |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/workspaces/{workspace_id}/usage`

Usage of one workspace (time series and totals).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `from` | query | string (date-time) | no | RFC 3339 UTC timestamp with Z. |
| `to` | query | string (date-time) | no | RFC 3339 UTC timestamp with Z. |
| `granularity` | query | "hour" or "day" | no |  |
| `meter` | query | "cpu_seconds" or "memory_gib_seconds" or "storage_gib_seconds" or "egress_bytes" or "ingress_bytes" or "volume_storage_gib_seconds" | no |  |
| `workspace_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/usage/estimate`

Period cost estimate: subscription fee, usage charges and projected allowance use.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/grants`

Quotas, per-workspace ceilings/reservations/grants and compute budget leases.

Ceiling = min(template, user cap, plan cap). Reservations are organization-level (pending starts count). Leases are the compute budgets granted to the cell (<= 15 min). API keys see only their own project’s workspaces.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | no |  |
| `cursor` | query | string | no |  |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/spend`

Spend policy, charges this period, cap state and enforcement (leases, overshoot bound).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/spend-policy`

Usage alert thresholds and opt-in overage with its spend cap.

Alert thresholds, and whether overage is available, on, off or paused, with the spend cap, its bounds and the rates. Overage is off by default; owners and billing members change it with the PUT (the console on /api/v1, or a CLI session on /v1; project API keys are refused).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Entitlements

The limits and features the organization’s plan grants.

### `GET /v1/organizations/{organization_id}/entitlements`

Resolved plan limits, allowances and policies of an organization.

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Billing

The plan catalog and the organization’s subscription.

### `GET /v1/billing/catalog`

The active plan catalog (the same versioned catalog admission enforces).

Public, cacheable. Prices are in minor units; `purchasable` plans can be bought with POST .../organizations/{id}/billing/checkout-sessions (a browser or CLI session; `shard billing upgrade <plan>`).

**Authentication:** None (public)

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

### `GET /v1/organizations/{organization_id}/billing/subscription`

Subscription, billing state and effective plan of an organization.

Owner/billing members (browser) or any API key of the organization (read-only). `billing_state`: free (no paid subscription), active, canceling (ends at period end, then free limits), grace (payment overdue, workspaces unaffected), restricted (grace ended: new opens/resumes answer 402 entitlement_required).

**Authentication:** API key

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `organization_id` | path | string (uuid) | yes | UUIDv7, lowercase canonical form. |

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Client versions

The latest and the oldest supported version of every Shardflux client package.

### `GET /v1/client-versions`

Latest and minimum supported version of every Shardflux client.

Unauthenticated, `Cache-Control: public, max-age=3600`. Clients compare their own version: below `minimum_supported` they are unsupported, below `latest` outdated. They check at most once a day (the CLI) or once per process (the SDKs), in the background, and never fail because of it.

**Authentication:** None (public)

**Responses:** `200` · `4XX` ErrorBody · `5XX` ErrorBody

## Feedback

Send product feedback straight to the Shardflux team: what failed, confused you or is missing.

### `POST /v1/feedback`

Send product feedback to the Shardflux team.

Delivered by email to the Shardflux team. Any valid project API key may send (no tool permission needed); the key's organization and project are recorded, so `organization_id` is rejected (422). A CLI session (`sfu_`) may send too, with an optional `organization_id` of the user's. Coding agents: send feedback whenever something fails, is confusing or missing, and include `context` (`agent`, `client`, `workspace`, `request_id` and `error_code` of the error response, `command`). Rate limited per sender (10 per 10 minutes, 50 per day) and per organization (200 per day): 429 `rate_limited` with a `retry-after` header and `details.retry_after_seconds`. The same message from the same sender within 24 hours is a duplicate: 200 with the original `id` and `duplicate: true` (it still counts against the rate limits). The body is limited to 32 KiB (413).

**Authentication:** API key

**Request body** (`application/json`, required)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | string | yes | The feedback, 1-8000 characters after trimming surrounding whitespace (must contain a non-whitespace character). Control characters other than newline and tab are stripped; API keys, bearer tokens, JWTs, AWS access key ids and private key blocks are redacted before it is stored or emailed. |
| `category` | "bug" or "confusing" or "missing" or "idea" or "praise" or "other" | no | Default `other`. `bug`: something failed or behaved wrongly; `confusing`: an error, doc, name or output was unclear or misleading; `missing`: a capability, option or template you needed does not exist; `idea`: a suggestion or improvement; `praise`: something worked well; `other`: anything else. |
| `organization_id` | string (uuid) | no | Sessions (browser, or a CLI session on /v1): the organization the user is looking at, one they belong to (404 otherwise). Refused (422) with a project API key, whose organization is used. |
| `context` | object | no | Optional, every field optional. Control characters are stripped and anything shaped like a secret is redacted before storing. |

**Responses:** `200` · `201` · `4XX` ErrorBody · `5XX` ErrorBody
