# API reference

> Every HTTP route a CLI token can call. The control plane at api.prized.dev/api/v1 manages boxes, snapshots, environments, secrets, policies, the audit log and team status; the edge runs commands, moves files, prompts the box's agent and stops its desktop. No SDK and no OpenAPI document yet; this page is the contract.

URL: https://prized.dev/docs/api

## Two hosts, one token [#two-hosts-one-token]

The control plane, `https://api.prized.dev/api/v1`, manages boxes, snapshots, environments, secrets, operations, the audit log, policies, team status and your account; it takes a box's `id` (a uuid) and answers errors as `{"error": "code"}`, sometimes with `message`. The edge, `$EDGE/v1/box/{box}/`, runs commands, moves files, prompts the agent and stops the desktop; it takes a box's name or id and answers errors as `{"ok": false, "error": {"code", "message"}}`. Both take the same bearer; read the edge's address from `edge.url` in `GET /api/v1/me` rather than assuming one.

```bash
export PRIZED_TOKEN=dcp_…
API=https://api.prized.dev/api/v1
AUTH="Authorization: Bearer $PRIZED_TOKEN"
EDGE=$(curl -s "$API/me" -H "$AUTH" | jq -r .edge.url)
```

## Authentication [#authentication]

Every request carries `Authorization: Bearer dcp_…`, a CLI token from **Dashboard → Workspace → CLI tokens** (`prized login --token dcp_…` signs a machine in with one). Mint one per script or agent and name it, so it can be retired on its own; revoking takes effect on its next request (401) and closes the tunnels that device opened ([Workspaces: CLI tokens](https://prized.dev/docs/workspaces#cli-tokens)).

* **What a token can do.** It acts as you in the workspace it was minted in: every box, snapshot, environment and secret there (a [contractor](https://prized.dev/docs/teams#roles)'s token reaches only the boxes they own and no secrets). Minting credentials, membership and roles, policies, the audit export, billing, and closing the workspace are dashboard-only; those routes answer `401 unauthorized` to any bearer, so a leaked token cannot widen itself.
* **Other credentials.** The dashboard's short-lived terminal tickets are accepted on the box read, wake and connect routes and on the edge's exec, files and prompt routes, for their one box only; monitor tickets, phone passwords and shared-port viewer cookies are refused everywhere (each opens its own surface, none of them a scripting one).
* **The version header.** The CLI sends `X-Prized-CLI-Version`; one below the minimum answers `426 cli_update_required` with `minVersion`. Scripts should not send it.

## Conventions [#conventions]

* **JSON in, JSON out.** Send `Content-Type: application/json`. Bodies are capped at 64 KiB (128 KiB on the secrets routes; 2 MiB on environment create, patch, files and repos); over the cap, or not JSON, is `400 bad_request`. A `204` has no body.
* **Ids.** Control-plane routes take the box's `id`, never its name; `GET /api/v1/boxes` maps names to ids. Snapshots are `snp_…`, environments `env_…`, secrets `sec_…`, grants `sgr_…`, operations `dop_…`. An unknown, malformed, or foreign id is `404 not_found`, with nothing written.
* **202 means in progress.** Creating, waking, pausing, resizing, moving, forking, restoring and deleting a box answer `202` with the row as written: `desiredState` has moved and `observedState` follows over seconds to minutes. Poll `GET /api/v1/boxes/{id}`, or `GET /api/v1/operations/{id}` for a delete. Setting writes answer `200` or `201`.
* **Errors.** Codes are stable words; messages are for people. Refusals that need numbers carry them: `insufficient_balance` has `balanceMicros`, `requiredMicros`, `burnMicrosPerHour`; `snapshot_limit` has `limit`; `box_not_running` has `state`.
* **Idempotency.** No route takes an idempotency key. A `POST /api/v1/boxes` retried after a lost response creates a second box; give it a `name` and the retry answers `409 name_taken` instead. Deletes are idempotent, and wake or suspend on a box already in that state is a `202` no-op.
* **Limits.** The control plane's limits are counts: `409 box_limit`, `429 snapshot_limit`, `402 secret_limit`, `429 customer_session_limit` on the CLI's tunnel sessions. The edge admits 8 concurrent requests and 20 per second per token and per box, then answers `429 rate_limited` with `Retry-After` ([Limits](https://prized.dev/docs/limits#commands-and-files)).
* **Times and money.** Timestamps are RFC 3339 UTC. Balances are in micro-dollars (`1000000` is $1).
* **Stability.** This API is what the CLI and the dashboard use, and the box object is the one `prized … --json` prints. Fields and routes are added under `/v1` without notice; ignore fields you do not know. The CLI's own tunnel and housekeeping routes are not a scripting surface and are not listed.

## Account [#account]

| Route            | Answer                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/me` | `customer` (`id`, `email`, `name`, `githubLogin`, `status`, `onboardingStep`, `onboardingCompletedAt`, `createdAt`), `user` (who the token acts as: `email`, `name`, `role`; `null` for a token with no user), `workspace` (`id`, `name`, `zeroDataRetention`, `closingAt`, `purgeAfter`), `billing` (`balanceMicros`, `burnMicrosPerHour`, `runwayHours`, `warnBelowHours`, `planAmountUsd`), `boxes` (every box, terminated included; a contractor's own), `edge.url` |

`customer.id` is the workspace id. `box` (the oldest live box in `boxes`) is deprecated; read `boxes`.

## Boxes [#boxes]

| Route                              | Body                                                                                                                    | Answer                                                                                                  |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/boxes`                |                                                                                                                         | `200 {boxes}`, newest first, terminated ones included (filter on `observedState`)                       |
| `POST /api/v1/boxes`               | `{name?, tier?, ttlMinutes?, autoPauseMin?, environment?, env?, restricted?, fromSnapshotId?, fromTemplate?}`           | `202 {box}` with `observedState: "requested"`; poll until `running`                                     |
| `GET /api/v1/boxes/{id}`           |                                                                                                                         | `200 {box, state, vitals, events, job, pausesSurvived, environment, envVars, restricted}`               |
| `PATCH /api/v1/boxes/{id}`         | `{autoPauseMin?, ttlMinutes? \| pauseAt?, autoSnapshotHours?, autoSnapshotKeep?, quietHoursExempt?}` or, alone, `{env}` | `200 {box}`                                                                                             |
| `DELETE /api/v1/boxes/{id}`        |                                                                                                                         | `202 {box, operation}`; see [Operations](#operations)                                                   |
| `POST /api/v1/boxes/{id}/wake`     |                                                                                                                         | `202 {box}`, `desiredState: "running"`                                                                  |
| `POST /api/v1/boxes/{id}/suspend`  |                                                                                                                         | `202 {box}`, `desiredState: "suspended"`                                                                |
| `POST /api/v1/boxes/{id}/resize`   | `{target_tier}`                                                                                                         | `202 {box}` with the new `tier`; the box restarts                                                       |
| `POST /api/v1/boxes/{id}/region`   | `{region}` (`us-west-2` or `us-west-1`)                                                                                 | `202 {box}` with `desiredRegion` set; `region` changes when the move lands                              |
| `POST /api/v1/boxes/{id}/fork`     | `{name?, tier?, keepSnapshot?}`                                                                                         | `202 {box, snapshot}`                                                                                   |
| `POST /api/v1/boxes/{id}/template` | `{name, description?}`                                                                                                  | `201 {snapshot}`: a template of this box, taken now                                                     |
| `GET /api/v1/boxes/{id}/metrics`   | query `from`, `to` (epoch seconds or `…Z` ISO 8601; default the last hour, clamped to 30 days)                          | `200 {v, res, from, to, buckets, series, fs, ifaces}`; `res` is 30, 300 or 3600 seconds by span         |
| `GET /api/v1/boxes/{id}/procs-at`  | query `t` (required)                                                                                                    | `200 {v, at, procs, ports}`: the process and port snapshot nearest `t`, within five minutes, else `404` |

**The box object** is the one on [CLI: The box object](https://prized.dev/docs/cli#the-box-object), plus `autoSnapshotHours`, `autoSnapshotKeep`, `environmentId`, `environmentVersionNo`, `envVars` (`[{name, value}]`), `restricted`, `ownerUserId` (the member who created it) and `quietHoursExempt`. `desiredState` is `running`, `suspended` or `terminated`; `observedState` is one of `requested`, `provisioning`, `bootstrapping`, `running`, `suspending`, `suspended`, `waking`, `deep_sleep`, `resizing`, `migrating`, `degraded`, `provision_failed`, `terminating`, `terminated` ([Boxes: States](https://prized.dev/docs/boxes#states)).

**Creating.** `name` is a hostname (lowercase letters, digits and hyphens, starting and ending with a letter or digit, 1 to 32 characters; a fruit without one). `tier` is a size id (`nano`, `micro`, `lite`, `flow`, `pro`, `max`, `ultra`); without one the control plane picks what the balance runs comfortably (a Small while the workspace runs on the [free credit](https://prized.dev/docs/billing#free-credit) alone, which also starts only `nano` through `flow`: the rest answer `409 tier_unavailable` with `allowed` naming the sizes it does). A box on the free credit never auto-pauses: `autoPauseMin` on that create answers `409 trial_no_auto_pause` (absent and `null` are the state it is already in). `ttlMinutes` (5 to 43200) is a [pause deadline](https://prized.dev/docs/boxes#pause-deadline), `autoPauseMin` (30 to 10080, or `null`) the [idle window](https://prized.dev/docs/boxes#auto-pause). `environment` is an id or name, `null` for none, absent for your default; `env` is `{NAME: "value"}`; `restricted` makes the box [safe for third parties](https://prized.dev/docs/environments#safe-for-third-parties) (a contractor's box always is). `fromSnapshotId` or `fromTemplate` (one, not both) restores instead of booting fresh; `tier` may then be any size whose disk holds the snapshot.

**The detail answer.** `events` is the last 50 state changes as `{id, fromState, toState, reason, actor, at}`; `job` is the lifecycle change in flight (`op`, `step`, `attempt`, `lastError`, `nextRetryAt`) or `null`; `vitals` is the last heartbeat (`cpu_pct`, `mem_pct`, `disk_pct`, `idle_for_sec`, `listeners_count`, `agent_version`); `environment` is `{id, name, versionNo, latestVersionNo}` or `null`.

**Patching** takes one family per request: the pause settings and snapshot schedule together (`autoPauseMin`; `ttlMinutes` or `pauseAt`, `null` to clear, only one of the two; `autoSnapshotHours` 1 to 168 or `null`; `autoSnapshotKeep` 1 to 10), or `{env}` alone, which replaces the per-box vars (100 names, 64 KiB in all). A deadline needs a running box (`409 box_not_running`); clearing one always works. `autoPauseMin` on a box whose workspace runs on the free credit is `409 trial_no_auto_pause` (`null` always works). `quietHoursExempt` keeps the box running through [quiet hours](https://prized.dev/docs/teams#quiet-hours) and is the owner's alone (`403 not_owner`); a member's `autoPauseMin` above the workspace's [auto-pause floor](https://prized.dev/docs/teams#policies) is `403 policy_denied`.

| HTTP | `error`                                                                                                                                                                                         | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request`, `invalid_name`, `invalid_template`, `invalid_tier`, `invalid_env`, `invalid_auto_pause`, `invalid_pause_at`, `invalid_auto_snapshot`, `environment_too_large`                    | The body is off; `message` names the window or the rule                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 400  | `resize_rejected`, `move_rejected`                                                                                                                                                              | A box mid-transition, a fifth move in a day, a region the box cannot use                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| 403  | `restore_tier_not_allowed`                                                                                                                                                                      | The size is off your plan or its disk cannot hold the snapshot                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 403  | `policy_denied`, `not_owner`                                                                                                                                                                    | A [workspace policy](https://prized.dev/docs/teams#policies) refused a member (`rule` is `memberSpendCapUsd`, `memberBoxLimit`, `memberMaxTier`, `autoPauseFloorMin` or `contractor_restricted`); a quiet-hours exemption from anyone but the owner                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 404  | `not_found`                                                                                                                                                                                     | No such box, snapshot, template or environment in this workspace, or a box a contractor does not own                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 409  | `box_limit`, `name_taken`, `insufficient_balance`, `tier_unavailable`, `trial_no_auto_pause`, `workspace_closing`, `workspace_held`, `wake_rejected`, `snapshot_in_progress`, `box_not_running` | In order: the plan's box count (1 while the workspace runs on the [free credit](https://prized.dev/docs/billing#free-credit) alone, with `message` and `limit`), a live box with that name, a balance short of four hours, a size the plan or the free credit refuses (`message` says which; `allowed` lists the free credit's sizes), an idle window on a box running on the free credit (which never auto-pauses), a closing workspace, a workspace paused for [mining](https://prized.dev/docs/limits#not-allowed) or for [filling the box's CPU on the free credit](https://prized.dev/docs/billing#free-credit) (`message` says which, and what lifts it; resize and move answer it too), a wake the balance refuses, a template still being taken, a deadline on a paused box |

## Shared ports [#shared-ports]

A port on a box published at `https://<slug>.p.prized.dev/` for the members of the workspace ([Ports: Share a port with your team](https://prized.dev/docs/ports#share-a-port-with-your-team)). Whoever opens the link signs in first; a member who can see the box gets through, nobody else does.

| Route                                 | Body     | Answer                                                                                      |
| ------------------------------------- | -------- | ------------------------------------------------------------------------------------------- |
| `POST /api/v1/boxes/{id}/port-shares` | `{port}` | `201 {share}`; `200 {share}` when that port is already shared, the existing share unchanged |
| `GET /api/v1/boxes/{id}/port-shares`  |          | `200 {enabled, shares}`: the box's live shares, oldest first                                |
| `GET /api/v1/port-shares`             |          | `200 {enabled, shares}`: every live share on a box you can see                              |
| `DELETE /api/v1/port-shares/{id}`     |          | `200 {revoked: true}`; the link stops working for everyone on it                            |

**The share object**: `id`, `boxId`, `hostname`, `port`, `slug`, `url`, `createdBy` (the member who shared it), `createdAt`, `openCount`, `lastOpenedAt`. `enabled: false` (and `url: null`) means this deployment does not serve shared ports; a create then answers `503 port_share_disabled`. `port` is 1 to 65535 (`400 bad_request`); a box a contractor does not own is `404 not_found`, and so is a share that is already stopped.

## Cloud federation [#cloud-federation]

The IAM roles the workspace's boxes assume ([AWS access](https://prized.dev/docs/aws)). Reads are for owners and members; writes are owner-only (`403 not_owner`); a contractor gets `403 not_allowed`. While the deployment has no issuer configured, the list answers `enabled: false` and connect and verify answer `503 aws_federation_disabled`.

| Route                                | Body → answer         |                                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/aws-roles`              |                       | `200 {enabled, issuer, audience, workspaceId, subjectPattern, tokenPath, limit, roles}`                                                                                                                                                                                                                                                     |
| `POST /api/v1/aws-roles`             | `{roleArn, profile?}` | `201 {role}`, verified against AWS at once (`verifiedAt`, `verifiedArn` or `verifyError`); `200 {role}` when that ARN is already connected; `400 invalid_role_arn` / `invalid_profile`; `409 already_connected` (a different profile for a connected ARN: remove, then connect) / `profile_taken` / `aws_roles_limit` / `workspace_closing` |
| `POST /api/v1/aws-roles/{id}/verify` |                       | `200 {role}` with fresh verification fields                                                                                                                                                                                                                                                                                                 |
| `DELETE /api/v1/aws-roles/{id}`      |                       | `200 {revoked: true}`; every box drops the profile on its next poll                                                                                                                                                                                                                                                                         |
| `POST /api/v1/aws-connect`           | `{profile?}`          | `201 {id, url, expiresAt}`: a single-use link (one hour) that opens the AWS console on a pre-filled CloudFormation stack; the stack reports the role back and it connects on its own. Making a new link cancels the last. `503 aws_quick_connect_unavailable` when this deployment has no published template                                |
| `DELETE /api/v1/aws-connect/{id}`    |                       | `200 {cancelled: true}`; `404` once spent, expired or cancelled                                                                                                                                                                                                                                                                             |

## Operations [#operations]

| Route                         | Answer                                                                                                                         |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/v1/operations/{id}` | `200 {operation}`: `id`, `kind` (`box_delete`, `snapshot_delete`), `targetId`, `status`, `requestedAt`, `completedAt`, `error` |

A delete answers with one of these; `status` runs `pending`, `processing`, `completed`, or `failed` with `error`. Finished operations stay readable for 30 days. What a delete keeps and removes is on [Data retention](https://prized.dev/docs/data-retention#deletion-operations).

## Snapshots [#snapshots]

| Route                                    | Body                                       | Answer                                                                               |
| ---------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ |
| `GET /api/v1/snapshots`                  | query `transient=1` to include fork copies | `200 {snapshots}`, newest first                                                      |
| `POST /api/v1/snapshots`                 | `{boxId, name, description?}`              | `201 {snapshot}` with `status: "creating"`; poll the list until `available`          |
| `DELETE /api/v1/snapshots/{id}`          |                                            | `202 {snapshot, operation}`                                                          |
| `POST /api/v1/snapshots/{id}/restore`    | `{name?, tier?}`                           | `202 {box}`, a new box from the snapshot                                             |
| `POST /api/v1/snapshots/{id}/template`   | `{name}`                                   | `200 {snapshot, previous}`; `previous` is the snapshot that held the name, or `null` |
| `DELETE /api/v1/snapshots/{id}/template` |                                            | `200 {snapshot}`, back to a plain snapshot                                           |

A snapshot is `{id, name, description, status, kind, templateName, expiresAt, boxId, sourceBoxHostname, tier, region, sizeGb, error, createdAt}` (`status`: `creating`, `available`, `failed`, `deleting`; `kind`: `manual`, `template`, `fork`, `auto`, `final`); other fields may disappear. Names are 1 to 64 characters of letters, digits, `.`, `_` and `-`, starting and ending with a letter or digit. Forking and templating a box are under [Boxes](#boxes); what each kind means is on [Snapshots](https://prized.dev/docs/snapshots).

Refusals: `409 snapshot_in_progress`, `409 no_volume`, `409 insufficient_balance`, `409 name_taken`, `409 box_limit`, `409 workspace_held`, `409 snapshot_unavailable` (a failed, deleting, or transient fork snapshot as a template), `429 snapshot_limit`, `403 restore_tier_not_allowed`. A `POST /api/v1/boxes` with `fromTemplate` is `404` when no template wears the name.

## Environments [#environments]

| Route                                                          | What it does                                                                     |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `GET /api/v1/environments`                                     | `200 {environments}`                                                             |
| `POST /api/v1/environments`                                    | Create; `201 {environment}`                                                      |
| `PATCH /api/v1/environments/{id}`                              | Change any field; a config change mints a version; `200 {environment}`           |
| `DELETE /api/v1/environments/{id}`                             | `204`; boxes keep their version                                                  |
| `POST /api/v1/environments/{id}/default`                       | Make it the default for new boxes                                                |
| `POST /api/v1/environments/{id}/toggles`                       | The safe-for-third-parties switch and the three credential channels              |
| `POST /api/v1/environments/{id}/vars`, `DELETE …/vars/{name}`  | Set or remove one env var                                                        |
| `PUT /api/v1/environments/{id}/files`, `DELETE …/files?path=`  | Set or remove one secret file                                                    |
| `POST /api/v1/environments/{id}/repos`, `DELETE …/repos?repo=` | Add or remove one repo                                                           |
| `POST /api/v1/environments/{id}/upgrade`                       | Pin live boxes to the latest version; `200 {upgraded, skipped, latestVersionNo}` |

Bodies, the environment object, limits and error codes are on [Environments: API](https://prized.dev/docs/environments#api).

## Secrets [#secrets]

Brokered credentials: the box sees a placeholder, and the broker substitutes the real value only in HTTPS requests to the allowed hosts ([Credentials](https://prized.dev/docs/credentials#secrets-for-agents), [Connectors](https://prized.dev/docs/connectors)).

| Route                                          | Body                                                                                                                                                            | Answer                                                                              |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `GET /api/v1/secrets`                          | query `box={id}` for only the ones that box may mount                                                                                                           | `200 {secrets}`, never a value                                                      |
| `POST /api/v1/secrets`                         | `{name, value, description?, hosts?, grants?}` — `grants` is a list of `{boxId}`, `{userId}` or `{email}`, the scope the secret is born with, committed with it | `201 {secret}`; `404` (a grant's box), `404 member_not_found`                       |
| `PATCH /api/v1/secrets/{id}`                   | `{value?, description?, hosts?}` (at least one)                                                                                                                 | `200 {secret}`; a new value keeps the placeholder and stamps `lastRotatedAt`        |
| `DELETE /api/v1/secrets/{id}`                  |                                                                                                                                                                 | `204`; its mounts, grants and usage go with it                                      |
| `POST /api/v1/secrets/{id}/grants`             | `{boxId}`, `{userId}` or `{email}` (one of the three)                                                                                                           | `201 {grant}`; an existing grant answers `201` again                                |
| `DELETE /api/v1/secrets/{id}/grants/{grantId}` |                                                                                                                                                                 | `204`                                                                               |
| `GET /api/v1/secrets/{id}/usage`               |                                                                                                                                                                 | `200 {uses}`: `[{boxId, hostname, host, firstAt, lastAt, count}]`, up to 1,000 rows |
| `GET /api/v1/boxes/{id}/secrets`               |                                                                                                                                                                 | `200 {mounts}`                                                                      |
| `POST /api/v1/boxes/{id}/secrets`              | `{secretId, envName}` to mount an existing secret, or `{name, value, envName, hosts?, description?}` to create and mount in one step                            | `201 {mount}`                                                                       |
| `DELETE /api/v1/boxes/{id}/secrets/{mountId}`  |                                                                                                                                                                 | `204`                                                                               |

A secret is `{id, name, description, hosts, placeholder, mountCount, scope, grants, createdAt, updatedAt, lastRotatedAt}`, where `scope` is `workspace` (no grants: every box whose owner is not a contractor may mount it) or `granted` (only the boxes named, and the boxes owned by the members named) and `grants` is `[{id, boxId, hostname, userId, email, createdAt}]`; a mount is `{id, envName, secret: {id, name, placeholder, hosts}}`. `name` is `UPPER_SNAKE_CASE` (up to 128 characters), `value` up to 16 KiB, `hosts` up to 32 entries, each a hostname or a `*.` wildcard; `envName` is a shell identifier. The grant rule is on [Teams: Scoped secrets](https://prized.dev/docs/teams#scoped-secrets).

| HTTP | `error`                                                           | When                                                                                                                                           |
| ---- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request`, `invalid_name`, `invalid_host`, `invalid_env_name` | The body is off                                                                                                                                |
| 402  | `secret_limit`                                                    | The workspace's secret count is full                                                                                                           |
| 403  | `secret_not_granted`, `not_allowed`                               | A mount of a secret the box may not have (the create-and-mount form too; nothing is committed); a contractor on any secrets route or box mount |
| 404  | `not_found`, `member_not_found`                                   | No such secret, grant or box (a terminated box included); a grant to a user id or email that is not a current member                           |
| 409  | `name_taken`, `env_name_taken`, `box_restricted`                  | A secret with that name, a mount on that variable, a box that is [safe for third parties](https://prized.dev/docs/environments#safe-for-third-parties)           |

## Policies [#policies]

| Route                            | Answer                                                                                      |
| -------------------------------- | ------------------------------------------------------------------------------------------- |
| `GET /api/v1/workspace/policies` | `200 {policy, updatedBy, updatedAt}`; owners and members; a contractor is `403 not_allowed` |

`policy` is `{memberSpendCapUsd, memberBoxLimit, memberMaxTier, autoPauseFloorMin, quietHours, auditRetentionDays}`, each `null` while off except `auditRetentionDays` (90 by default); `quietHours` is `{start, end, days, allDayDays, timezone}`. Changing them is `PATCH /api/v1/workspace/policies` from a dashboard session only: the body is the keys to change, `null` clears one (`400 invalid_policy` with `field` and `message`, `403 not_owner`, `409 workspace_closing`). A create, wake, resize or fork a policy refuses answers `403 policy_denied` with `rule` and `message`. Bounds and what each rule does are on [Teams: Policies](https://prized.dev/docs/teams#policies).

## Audit log [#audit-log]

| Route                          | Body or query                                                                                                                                                                                             | Answer                                                                                                                                                                          |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/audit`            | query `since`, `until` (RFC 3339 UTC or epoch seconds), `kind` (comma list), `box` (id), `actor` (user id), `origin` (`server` or `client`), `before` (a row id cursor), `limit` (1 to 1000, default 100) | `200 {events, nextBefore}`, newest first; `nextBefore` is `null` on the last page                                                                                               |
| `GET /api/v1/audit?format=csv` | the same filters                                                                                                                                                                                          | `text/csv` as an attachment `audit-YYYYMMDD-HHMMSS.csv`, columns `id,at,kind,origin,actor_kind,actor_user_id,actor_token_id,box_id,detail`, every row of the range up to 50,000 |
| `POST /api/v1/audit`           | `{kind, boxId, detail?}`                                                                                                                                                                                  | `201 {id}`: a client report, from a CLI token or a terminal ticket                                                                                                              |

An event is `{id, at, kind, origin, actorKind, actorUserId, actorTokenId, boxId, detail}`; `actorKind` is `user`, `cli`, `ticket`, `mobile` or `system`. Owners and members read; a contractor is `403 not_allowed`. Only `exec`, `file.upload`, `file.download`, `prompt` and `desktop.start` may be reported; `detail` takes 16 keys of up to 40 characters with strings of up to 512, the body 64 KiB, and a ticket may report only on its own box. Kinds, details and what a reported row means are on [Teams: Audit log](https://prized.dev/docs/teams#audit-log).

| HTTP | `error`                                                                                                                               | When                                                                                 |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| 400  | `invalid_time`, `invalid_kind`, `invalid_box`, `invalid_actor`, `invalid_origin`, `invalid_cursor`, `invalid_limit`, `invalid_format` | The named query parameter is off; `message` says the shape                           |
| 400  | `invalid_kind`, `bad_request`                                                                                                         | A report of a kind clients may not claim, or a body that is off                      |
| 403  | `not_allowed`                                                                                                                         | A contractor reading, or a monitor ticket reporting                                  |
| 404  | `not_found`                                                                                                                           | A report naming another workspace's box, or a ticket naming a box other than its own |

## Team [#team]

| Route                     | Answer                                                                  |
| ------------------------- | ----------------------------------------------------------------------- |
| `GET /api/v1/team/status` | `200 {now, cycle: {start, end}, scope, boxes, members, totals, recent}` |

`scope` is `workspace`, or `own` for a contractor (their boxes only, and `members` empty). A box is `{id, hostname, tier, desiredState, observedState, ownerUserId, ownerName, ownerEmail, idleForSec, cpuPct, memPct, lastHeartbeatAt, sessions, lastPromptAt, lastExecAt, spendTodayMicros, spendCycleMicros}` (`idleForSec`, `cpuPct` and `memPct` are `null` on a paused box); a member is `{userId, name, email, role, boxes, sessions, spendCycleMicros, lastActiveAt}`; `totals` is `{spendTodayMicros, spendCycleMicros, running, sessions}`; `recent` is the newest 50 audit events. What a session, today and the cycle mean is on [Teams: Team activity](https://prized.dev/docs/teams#team-activity).

## The edge [#the-edge]

Every route below is under `$EDGE/v1/box/{box}/`, where `{box}` is the box's name or id, with the same bearer; each request is checked against the control plane, so a revoked token dies on its next request. `POST /exec` and `POST /prompts` wake a suspended box; every other route answers `409 box_not_running` instead. Paths are read the way `prized cp` reads them: relative paths and `~/…` are under your home, absolute paths are taken as is, and every answer echoes the real path it touched.

Every successful answer carries `ok: true` and a `type`. A failed one is `{"ok": false, "error": {"code", "message"}}`, with the codes under [Errors](#errors) below; a known path with the wrong method is `405` with `Allow`, an unknown path `404 not_found`, in the same envelope.

### Commands [#commands]

| Route                              | Body or query                                                                         | Answer                                                                                                                        |
| ---------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `POST /exec`                       | `{command, cwd?, timeoutSeconds? (1 to 600, default 30), env? (up to 32), detached?}` | `200 exec.result`: `exitCode, signal, stdout, stderr, stdoutTruncated, stderrTruncated, timedOut, startedAt, finishedAt, cwd` |
| `POST /exec` with `detached: true` | the same                                                                              | `200 exec.started`: `processId, pid, cwd, startedAt, logPath`                                                                 |
| `GET /exec`                        |                                                                                       | `200 exec.list`: `processes[]` of `processId, pid, running, lost, exitCode, signal, startedAt, command`                       |
| `GET /exec/{processId}`            | query `tail` (bytes; default 16384, max 1048576)                                      | `200 exec.status`: the list fields plus `finishedAt, cwd, stdout, stderr, stdoutTruncated, stderrTruncated`                   |
| `DELETE /exec/{processId}`         |                                                                                       | `200 exec.killed`: `processId, wasRunning, signal` (`TERM`, or `KILL` when TERM was not enough)                               |

The command runs under `sh -c` in your home (or `cwd`) as your box user. `timedOut` is set only when the time limit ended the command, so a command that exits 124 by itself is `exitCode: 124, timedOut: false`. A synchronous run that outgrows 1 MiB per stream keeps the last 1 MiB and sets the `Truncated` flag; use a detached run and `/download` on its log for the whole thing. The on-box process directory is on [Run commands and files](https://prized.dev/docs/exec#detached-processes).

### Files [#files]

| Route           | Body or query                                                 | Answer                                                                                                                                                                        |
| --------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /files`    | query `path`                                                  | `200 file.read`: `path, encoding (utf8 or base64), size, mode, content`; over 1 MiB answers `413` and points at `/download`                                                   |
| `PUT /files`    | `{path, content, encoding? (utf8 or base64), mode? ("0644")}` | `200 file.written`: `path, size`                                                                                                                                              |
| `GET /download` | query `path`                                                  | The file as `application/octet-stream` with `Content-Length`, or a directory as `application/x-tar` with `X-Prized-Content: directory`; `X-Prized-Path` carries the real path |
| `PUT /upload`   | query `path`, `mode?`; the raw file as the body               | `200 file.uploaded`: `path, size`                                                                                                                                             |

Size caps are on [Limits: Commands and files](https://prized.dev/docs/limits#commands-and-files).

### Prompts and events [#prompts-and-events]

| Route                                  | Body or query                                                                                                                        | Answer                                                                                                                                                                                                                   |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /prompts`                        | `{provider (claude or codex), prompt (up to 512 KiB), model?, reasoningEffort? (low, medium, high), cwd?, continue?, queue?, auto?}` | `202 prompt.started`: `run`; or `202 prompt.queued`: `queued, active`                                                                                                                                                    |
| `GET /prompts`                         |                                                                                                                                      | `200 prompt.list`: `runs[]` newest first (at most 50), `active` (the run in progress, or `null`)                                                                                                                         |
| `GET /prompts/{runId}`                 |                                                                                                                                      | `200 prompt.status`: `run` with the full `prompt`, `stderr` (last 4 KiB), `stderrTruncated`                                                                                                                              |
| `GET /prompts/{runId}/events`          | query `after` (a `seq`, default 0), `limit` (1 to 1000, default 200; a page also stops at 1 MiB)                                     | `200 prompt.events`: `run, events[], next, finished`; pass `next` as the following `after`                                                                                                                               |
| `GET /prompts/{runId}/events?follow=1` | or `Accept: text/event-stream`                                                                                                       | Server-Sent Events: `event: prompt` per event, `event: done` with `{run, next}` when the run has ended, `event: error` with `{code, message}` if the box stops answering, a `: keep-alive` comment while nothing happens |
| `POST /prompts/{runId}/interrupt`      |                                                                                                                                      | `200 prompt.interrupted`: `id, wasRunning, signal`                                                                                                                                                                       |
| `POST /prompts/interrupt`              |                                                                                                                                      | The same, for the run in progress                                                                                                                                                                                        |

A run is `{id, provider, model, reasoningEffort, auto, status (running, done, failed, interrupted), exitCode, pid, cwd, startedAt, finishedAt, sessionId, resumedFrom, parent, eventCount, prompt}`; an event is `{seq, at, type, text, tool: {name, input}, raw}` ([Prompt an agent remotely: JSON and JSON Lines](https://prized.dev/docs/remote-agents#json-and-json-lines)). A stream holds one of the token's concurrent request slots until the run ends. Approvals, continuity, the queue and the run directory on the box are on [Prompt an agent remotely](https://prized.dev/docs/remote-agents).

### Desktop [#desktop]

| Route                | Body                                             | Answer                                                |
| -------------------- | ------------------------------------------------ | ----------------------------------------------------- |
| `POST /desktop/stop` | `{mode?}` (`desktop`, the default, or `browser`) | `200 {ok: true, box, mode}`; never wakes a paused box |

This one route predates the edge envelope: its errors are the bare `{"error": "code"}` form, with `unauthorized` (a `dcp_` token only), `not_found`, `bad_request`, `box_not_running`, `box_unreachable`, `desktop_stop_failed`, `resolve_failed` and `control_plane_unavailable`. The desktop stream itself is a dashboard WebSocket; from a script use `prized desktop --no-open`, which prints a `vnc://` address and the password ([Desktop](https://prized.dev/docs/desktop)).

### Errors [#errors]

Every edge route answers a failure as `{"ok": false, "error": {"code", "message"}}` with one of these codes:

| HTTP | `error.code`                | Meaning                                                                                                                                                                  |
| ---- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 401  | `unauthorized`              | No bearer, a token the control plane rejected, or a credential of another class (a monitor ticket, a phone password)                                                     |
| 404  | `not_found`                 | No such box on this account, process, run, path (or parent directory), or route                                                                                          |
| 405  | `method_not_allowed`        | A known route with the wrong method; `Allow` lists the right ones                                                                                                        |
| 409  | `box_not_running`           | Any route but `POST /exec` and `POST /prompts` on a suspended box; the message says how to wake it                                                                       |
| 400  | `invalid_request`           | The body is not the documented shape: bad JSON, an empty command, a bad env name or mode, a bad `model`, a `reasoningEffort` outside low/medium/high, a newline in `cwd` |
| 400  | `invalid_timeout`           | `timeoutSeconds` outside 1 to 600                                                                                                                                        |
| 400  | `invalid_path`              | A path with a NUL or newline byte, a directory where a file was expected, or no path                                                                                     |
| 403  | `permission_denied`         | The box's account cannot read or write that path                                                                                                                         |
| 413  | `payload_too_large`         | Over the inline (1 MiB) or transfer (1 GiB) cap                                                                                                                          |
| 429  | `rate_limited`              | Too many requests for this token or box; `Retry-After` says when                                                                                                         |
| 502  | `box_unreachable`           | The box could not be reached, or did not wake in time                                                                                                                    |
| 502  | `exec_failed`               | The ssh session or the on-box script failed; the message carries the box's own words                                                                                     |
| 5xx  | `control_plane_unavailable` | The control plane could not be reached to check the token                                                                                                                |

The prompt routes add:

| HTTP | `error.code`             | Meaning                                                                        |
| ---- | ------------------------ | ------------------------------------------------------------------------------ |
| 400  | `invalid_provider`       | `provider` is not `claude` or `codex`                                          |
| 400  | `prompt_required`        | `prompt` is missing or blank                                                   |
| 409  | `prompt_in_progress`     | A run is in progress; the message names it. Queue behind it, or interrupt it   |
| 409  | `provider_not_signed_in` | The provider has no sign-in on the box; run `prized agents handoff <provider>` |
| 409  | `provider_not_installed` | The provider's CLI is not on the box's login PATH                              |
| 409  | `no_session_to_continue` | `continue: true` with no earlier run of the provider that recorded a session   |

## Examples [#examples]

Create a box and wait for it to run:

```bash
BOX=$(curl -s -X POST "$API/boxes" -H "$AUTH" -H "Content-Type: application/json" -d '{"name":"ci-42","tier":"flow","ttlMinutes":120}' | jq -r .box.id)
until [ "$(curl -s "$API/boxes/$BOX" -H "$AUTH" | jq -r .box.observedState)" = running ]; do sleep 5; done
```

Run a command on it, upload a file, download a directory:

```bash
curl -s -X POST "$EDGE/v1/box/ci-42/exec" -H "$AUTH" -H "Content-Type: application/json" -d '{"command":"make test","cwd":"app","timeoutSeconds":300}' | jq '{exitCode, timedOut}'
curl -s -X PUT "$EDGE/v1/box/ci-42/upload?path=data/train.bin" -H "$AUTH" --data-binary @train.bin
curl -s "$EDGE/v1/box/ci-42/download?path=app/dist" -H "$AUTH" -o dist.tar
```

Prompt the agent and follow it (a box with a run in progress answers `prompt.queued` with no `run`; pass `queue: true`, or interrupt it first):

```bash
RUN=$(curl -s -X POST "$EDGE/v1/box/ci-42/prompts" -H "$AUTH" -H "Content-Type: application/json" -d '{"provider":"claude","prompt":"fix the failing tests in app/ and run them again","cwd":"app","auto":true}' | jq -r .run.id)
curl -sN "$EDGE/v1/box/ci-42/prompts/$RUN/events" -H "$AUTH" -H "Accept: text/event-stream"
```

Delete the box and wait for the operation:

```bash
OP=$(curl -s -X DELETE "$API/boxes/$BOX" -H "$AUTH" | jq -r .operation.id)
until [ "$(curl -s "$API/operations/$OP" -H "$AUTH" | jq -r .operation.status)" = completed ]; do sleep 5; done
```

## Not yet [#not-yet]

* **No SDK and no OpenAPI document.** The routes above with `curl` or your language's HTTP client are the whole interface; the CLI's `--json` output is the other machine interface ([CLI: For agents and scripts](https://prized.dev/docs/cli#for-agents-and-scripts)).
* **No webhooks.** Poll `GET /api/v1/boxes/{id}` or `GET /api/v1/operations/{id}`; the push channels are the prompt event stream and the [audit export](https://prized.dev/docs/teams#scheduled-export), which carries audit rows only.
* **No API keys apart from CLI tokens.** A token is a signed-in machine with your full standing in the workspace; there is no read-only or per-box token. Mint one per script and revoke it when the script is retired.
* **No idempotency keys**, see [Conventions](#conventions).
