# API reference

> Every HTTP route an API key can call: the control plane for boxes and workspace settings, the edge for commands, files, agent prompts, and stopping the desktop.

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

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

| What     | Control plane                                 | Edge                                          |
| -------- | --------------------------------------------- | --------------------------------------------- |
| Base URL | `https://api.prized.dev/api/v1`               | `$EDGE/v1/box/{box}/`                         |
| A box is | its `id` (a uuid)                             | its name or id                                |
| Errors   | `{"error": "code"}`, sometimes with `message` | `{"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_API_KEY=dcp_…
API=https://api.prized.dev/api/v1
AUTH="Authorization: Bearer $PRIZED_API_KEY"
EDGE=$(curl -s "$API/me" -H "$AUTH" | jq -r .edge.url)
```

## Authentication [#authentication]

Send `Authorization: Bearer dcp_…` on every request, with an API key: make one per script or agent with **New key** under **Dashboard → Settings → API keys** (`prized login --token dcp_…` signs a machine in with one), or, for someone new to Prized, let a coding agent make the account and get its own key with an emailed code ([Agent sign-in](https://prized.dev/docs/agent-sign-in)). An agent key whose code has not come back yet gets `403 verification_required` everywhere but `GET /api/v1/me`. A revoked key gets `401` on its next request. A key made with limited access (read only, or per area) gets `403 key_access_denied` on anything outside it, with `area`, `level` and a `message` naming what it lacks ([Workspaces: What a key can do](https://prized.dev/docs/workspaces#what-a-key-can-do)).

* **What a key can do.** It acts as you on every box, snapshot, environment and secret in its workspace; a [contractor](https://prized.dev/docs/teams#roles)'s key reaches only their own boxes, and no secrets. Minting and rotating credentials, membership and roles, policies, the audit export, billing, and closing the workspace are dashboard-only and answer `401 unauthorized` to any bearer, so a leaked key cannot widen itself.
* **Expiry.** A key past its expiry gets `401 token_expired`, with `expiresAt`, on every route and on the edge. `GET /api/v1/me` says when the calling key expires ([When a key expires](https://prized.dev/docs/workspaces#when-a-key-expires)).
* **A key for some boxes.** A key limited to boxes answers `404 not_found` for any other box, as if it did not exist, and leaves other boxes out of every list. Workspace-wide routes (creating boxes, forks and templates, secrets, environments, sandboxes, team activity, the audit log, workspace settings) answer `403 key_access_denied` with `scope: "boxes"` ([Limit a key to some boxes](https://prized.dev/docs/workspaces#limit-a-key-to-some-boxes)).
* **Other credentials.** A dashboard terminal ticket works for its one box only, on the box read, wake and connect routes and the edge's exec, files and prompt routes; monitor tickets, phone passwords and shared-port viewer cookies work nowhere.
* **The version header.** Scripts should not send `X-Prized-CLI-Version`, the CLI's header: below the minimum it answers `426 cli_update_required` with `minVersion`.

## Conventions [#conventions]

| Topic                 | Rule                                                                                                                                                                                                                                                                                                                           |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| JSON in, JSON out     | Send `Content-Type: application/json`. Bodies cap 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; with `Accept-Encoding: gzip`, answers over 1 KiB are gzipped.                                            |
| Ids                   | `GET /api/v1/boxes` maps box 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; `observedState` follows within 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; messages are for people. Refusals carry their numbers: `insufficient_balance` has `balanceMicros`, `requiredMicros`, `burnMicrosPerHour`; `snapshot_limit` has `limit`; `box_not_running` has `state`.                                                                                                       |
| Idempotency           | Creating, forking, templating, resizing and moving a box, and creating or restoring a snapshot, take an `Idempotency-Key` header that makes a retry safe ([Idempotency keys](#idempotency-keys)). Deletes are idempotent; wake or suspend on a box already in that state is a `202` no-op.                                     |
| Limits                | The control plane's are counts: `409 box_limit`, `429 snapshot_limit`, `402 secret_limit`, `429 customer_session_limit` (CLI 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 micro-dollars (`1000000` is $1).                                                                                                                                                                                                                                                     |
| Stability             | The CLI and the dashboard use this API; the box object is what `prized … --json` prints. Fields and routes are added under `/v1` without notice, so ignore unknown fields. CLI-internal routes are not listed.                                                                                                                 |

## Idempotency keys [#idempotency-keys]

A create whose answer is lost (a timeout, a dropped connection, a `5xx`) may or may not have made the box. Send an `Idempotency-Key` header with a value of your own, unique per request (a UUID), and a retry of that request within 24 hours gets the first answer back instead of a second box:

```bash
KEY=$(uuidgen)
curl -s --retry 3 --retry-all-errors -X POST "$API/boxes" -H "$AUTH" -H "Idempotency-Key: $KEY" -H "Content-Type: application/json" -d '{"tier":"flow"}'
```

The header works on `POST /api/v1/boxes`, `POST /api/v1/boxes/{id}/fork`, `POST /api/v1/boxes/{id}/template`, `POST /api/v1/boxes/{id}/resize`, `POST /api/v1/boxes/{id}/region`, `POST /api/v1/snapshots` and `POST /api/v1/snapshots/{id}/restore`.

| When                                                                                                                                                 | Answer                                                                                                                                                                                                                   |
| ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| A retry with the same key and the same request                                                                                                       | The first answer, status and body, with `Idempotent-Replayed: true`. Nothing is created, billed or counted against a limit again; the body is the box as it was then, so read `GET /api/v1/boxes/{id}` for its state now |
| The first request with the key is still running                                                                                                      | `409 idempotency_in_progress` with `Retry-After`; retry with the same key                                                                                                                                                |
| The same key on a different request: another body, another id in the path, another member, or the same member with another role or API key box scope | `422 idempotency_key_reused`                                                                                                                                                                                             |
| A key that is not 1 to 128 printable ASCII characters without spaces                                                                                 | `400 invalid_idempotency_key`                                                                                                                                                                                            |
| The first request was refused (any `4xx`)                                                                                                            | Nothing is kept: fix the cause and retry with the same key                                                                                                                                                               |
| More than 24 hours after the first answer                                                                                                            | A new request                                                                                                                                                                                                            |

A changed box scope refuses the replay rather than returning metadata from the earlier scope. Two credentials for the same member and the same box set can replay each other’s answer; the order of the box ids does not matter.

A key belongs to its workspace and route: the same value on another route, or in another workspace, is another key. Without the header, a retried create makes a second box, unless it names one (then `409 name_taken`).

## Account [#account]

| Route            | Answer                                                                                                                                                         |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/me` | `200 {customer, user, workspace, billing, boxes, edge, key}`: who the key acts as, the workspace, where the edge is, and the calling key. Open to any live key |

```json
{
  "customer": {
    "id": "3f9c…", "email": "you@example.com", "githubLogin": "you",
    "status": "active", "onboardingStep": "done",
    "onboardingCompletedAt": "2026-08-29T12:04:00Z",
    "createdAt": "2026-08-29T11:58:00Z", "name": "Acme"
  },
  "user": {"email": "you@example.com", "name": "Your Name", "role": "owner"},
  "workspace": {
    "id": "3f9c…", "name": "Acme",
    "zeroDataRetention": false, "closingAt": null, "purgeAfter": null
  },
  "billing": {
    "balanceMicros": 41250000, "burnMicrosPerHour": 160000,
    "runwayHours": 257.8125, "warnBelowHours": 48, "planAmountUsd": 50,
    "overage": true, "collecting": false,
    "paymentFailed": false, "paymentGraceUntil": null
  },
  "boxes": [
    {"id": "5d0a…", "hostname": "mango", "observedState": "running"}
  ],
  "edge": {"url": "https://…"}
}
```

* **`customer`** is the workspace: `customer.id` is the workspace id.
* **`user`** is who the key acts as, with their `role`; `null` for a key with no user.
* **`billing`** says why an empty balance may not stop boxes: `overage`, `collecting` (a renewal collecting, or a failed one in its grace), `paymentFailed`, `paymentGraceUntil`. `runwayHours` is `null` while nothing is burning.
* **`boxes`** is every box, terminated included (a contractor's own), each the [box object](#boxes), shortened above. `box` (the oldest live box in `boxes`) is deprecated; read `boxes`.
* **`key`** is the calling API key: `{id, kind, expiresAt, boxIds}`, where `kind` is `machine` (a `prized login`, whose `expiresAt` moves with use) or `api` (**New key**), `expiresAt` is `null` for a key that never expires, and `boxIds` is `null` unless the key is limited to boxes, whose ids it lists (then `boxes` holds only those). Absent for a dashboard session.
* **A limited key** adds `access`, its level per area. Without Boxes: Read it gets `boxes: []`; without Workspace: Read it gets `billing: null`, a `workspace` of only `id` and `name`, and `null` for `customer.email` and `customer.githubLogin`.

## Boxes [#boxes]

| Route                              | Body                                                                                                                    | Answer                                                                                                  |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/boxes`                | query `live=1` leaves out boxes whose `observedState` is `terminated`                                                   | `200 {boxes}`, newest first                                                                             |
| `POST /api/v1/boxes`               | `{name?, tier?, region?, 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`, `us-west-1`, `us-east-1`, `us-east-2` or `eu-central-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` |

A box-scoped ticket or phone password lists only its bound box. Once either `desiredState` or `observedState` is `terminated`, that list is empty, with or without `live=1`. Session and CLI-token lists keep their normal history.

A create refused with `insufficient_balance` includes `message`, `balanceMicros`, `requiredMicros`, and `admissionMicrosPerHour`. The allowance includes unbilled failed-launch cleanup reservations; it is not a charge. `burnMicrosPerHour` remains a compatibility alias for that allowance in this error response only. Display `message` when available.

**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:**

| Field                            | Rule                                                                                                                                                                                                                                                                                                                                      |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                           | A hostname: lowercase letters, digits and hyphens, starting and ending with a letter or digit, 1 to 32 characters; a fruit if absent                                                                                                                                                                                                      |
| `tier`                           | A size id: `nano`, `micro`, `lite`, `flow`, `pro`, `max`, `ultra`. If absent, the control plane picks what the balance runs comfortably (a Small on the [free credit](https://prized.dev/docs/billing#free-credit) alone). The free credit starts only `nano` through `flow`; others answer `409 tier_unavailable`, with `allowed` naming the sizes it does |
| `region`                         | Where a fresh box starts: `us-west-2` (the default), `us-west-1`, `us-east-1`, `us-east-2` or `eu-central-1` ([Regions](https://prized.dev/docs/machines#regions)); anything else, or a region beside `fromSnapshotId` or `fromTemplate`, is `400 invalid_region`                                                                                           |
| `ttlMinutes`                     | 5 to 43200: 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). On the free credit a box never auto-pauses: a value answers `409 trial_no_auto_pause` (absent or `null` passes)                                                                                                                                                        |
| `environment`                    | An id or name; `null` for none, absent for your default                                                                                                                                                                                                                                                                                   |
| `env`                            | `{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`, `fromTemplate` | One, not both: restore instead of booting fresh, in the snapshot's region (a `region` beside it is `400 invalid_region`); `tier` may 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`, not both, `null` to clear; `autoSnapshotHours` 1 to 168 or `null`; `autoSnapshotKeep` 1 to 10), or `{env}` alone, replacing 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` follows the create rule on the free credit, and a member's above the workspace's [auto-pause floor](https://prized.dev/docs/teams#policies) is `403 policy_denied`; only the owner may set `quietHoursExempt`, which keeps the box running through [quiet hours](https://prized.dev/docs/teams#quiet-hours) (`403 not_owner`).

| HTTP | `error`                                                                                                                                                                                                       | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `bad_request`, `invalid_name`, `invalid_template`, `invalid_tier`, `invalid_region`, `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 second move of the box or a fourth in the workspace within 24 hours, or 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`, `box_failed`, `snapshot_in_progress`, `box_not_running` | In order: the plan's box count (1 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 the free credit; 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 wake of a box that failed to launch (`provision_failed`; delete it); a template still being taken; a deadline on a paused box |

## Command history [#command-history]

What `prized exec` and the edge's `POST /exec` ran on a box, read from the control plane, so it answers while the box is paused ([Run commands and files: Command history](https://prized.dev/docs/exec#command-history)). A box's records are visible to whoever can see the box.

| Route                                                | Query                                                                                                                                                          | Answer                                                                              |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `GET /api/v1/boxes/{id}/commands`                    | `limit` (1 to 200, default 50), `before` (the previous page's `nextBefore`), `mode` (`sync` or `detached`), `status`                                           | `200 {commands, nextBefore}`, newest first; `nextBefore` is `null` on the last page |
| `GET /api/v1/boxes/{id}/commands/{commandId}`        |                                                                                                                                                                | `200 {command}`                                                                     |
| `GET /api/v1/boxes/{id}/commands/{commandId}/output` | `stream` (`stdout`, the default, or `stderr`), `offset` (bytes, default 0), `limit` (1 to 1048576, the default), `encoding` (`utf8`, the default, or `base64`) | `200 {stream, encoding, offset, nextOffset, size, skipped, data, status, done}`     |
| `DELETE /api/v1/boxes/{id}/commands/{commandId}`     |                                                                                                                                                                | `204`; writes a `command_history.delete` audit row                                  |
| `DELETE /api/v1/boxes/{id}/commands`                 |                                                                                                                                                                | `200 {deleted}`: every record of the box                                            |

```json
{
  "id": "p_3f9c0a1b2d4e", "boxId": "5d0a…", "mode": "detached", "source": "cli",
  "command": "./train.sh", "commandTruncated": false, "cwd": "/home/you/app",
  "status": "exited", "exitCode": 0, "signal": null, "pid": 41213,
  "startedAt": "2026-10-11T14:02:11Z", "finishedAt": "2026-10-11T14:05:40Z",
  "actor": {"kind": "cli", "userId": "…", "tokenId": "…"},
  "stdoutBytes": 1830, "stderrBytes": 0,
  "stdoutTruncated": false, "stderrTruncated": false, "outputKept": true
}
```

* **`status`** is `running`, `exited`, `killed`, `timed_out`, `failed`, or `lost`; `exitCode`, `signal`, `pid`, and `finishedAt` are `null` until known. `source` is `cli` (`prized exec`) or `api` (`POST /exec`).
* **`command`** is the first 8,192 characters (`commandTruncated` says when it was cut). Under [zero data retention](https://prized.dev/docs/data-retention#zero-data-retention) `command` and `cwd` are `null` and `outputKept` is `false`.
* **Output pages** are bytes of one stream from `offset`. Read again from `nextOffset` until `done` (the command ended and every byte has been read). `size` is the stream's length; `skipped` is how many bytes from your `offset` are no longer kept (up to the last 1 MiB of a stream is; a 256-chunk cap may trim heavily fragmented output sooner), and the page then starts later. `utf8` never ends a page inside a character; for exact bytes ask for `base64`.
* **A limited key** needs Boxes: Write for every history route, reads included: command lines and output can carry secrets. A key limited to some boxes reaches only those boxes' history; any other box is `404 not_found`.

## Shared ports [#shared-ports]

A shared port is a link to one port on a box, for one audience: `members` (signed-in workspace members who can see the box, under `p.prized.dev`), `anyone` (whoever holds the link, which carries a secret), or `public` (anyone at all) ([Ports: Share a port](https://prized.dev/docs/ports#share-a-port)).

| Route                                 | Body                          | Answer                                                                                                                                                                                                                                                |
| ------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/v1/boxes/{id}/port-shares` | `{port, access?, expiresIn?}` | `201 {share}` (with `replaced: {id, access}` when it replaced the port's link of another audience); `200 {share}` when that port already has a link of that audience, or has any link and the body names no `access`, unchanged                       |
| `GET /api/v1/boxes/{id}/port-shares`  |                               | `200 {enabled, linksEnabled, modes, shares, listening}`: the box's live links, oldest first, and `listening` `{at, ports: [{port, process, pid}]}`, the ports it last reported listening on (`null` when it has not reported in the last few minutes) |
| `GET /api/v1/port-shares`             |                               | `200 {enabled, linksEnabled, modes, shares}`: every live link on a box you can see                                                                                                                                                                    |
| `DELETE /api/v1/port-shares/{id}`     |                               | `200 {revoked: true}`; the link stops working for everyone on it                                                                                                                                                                                      |

* **The body**: `port` 1 to 65535; `access` `members` (the default), `anyone` or `public`; `expiresIn` in seconds for `anyone` and `public` only, 300 to 2592000, 604800 (7 days) by default. Anything else is `400 bad_request`.
* **The share object**: `id`, `boxId`, `hostname`, `port`, `slug`, `access`, `url`, `origin`, `expiresAt` (`null` for members), `createdBy` (the member who shared it), `createdAt`, `openCount`, `lastOpenedAt`. `url` is the link to hand out; an anyone link's carries its secret in the `201` that made it and is `null` everywhere after.
* **`modes`**: for each audience, `{"allowed": true}` or `{"allowed": false, "error", "message"}` with what a create would answer, so a client can grey out what you cannot pick.
* **Off**: `enabled: false` means members links are off here (a create answers `503 port_share_disabled`); `linksEnabled: false` means anyone and public links are (`503 share_links_disabled`).

| HTTP | `error`                                               | When                                                                                                                                                                                                  |
| ---- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 402  | `share_links_need_payment`                            | An anyone or public link on the [free credit](https://prized.dev/docs/billing#free-credit)                                                                                                                              |
| 403  | `not_allowed`, `policy_denied`, `share_links_blocked` | A contractor's link outside the workspace, a public link from anyone but the owner, or a key with no member behind it; the `anyoneLinksOwnerOnly` policy (`rule`); links turned off for the workspace |
| 404  | `not_found`                                           | No such box, a box a contractor does not own, or a link already stopped                                                                                                                               |
| 409  | `share_link_limit`                                    | The workspace already has 10 live anyone and public links (`limit`)                                                                                                                                   |

## Cloud federation [#cloud-federation]

The IAM roles the workspace's boxes assume ([AWS access](https://prized.dev/docs/aws)). Owners and members read, only owners write (`403 not_owner`), and a contractor gets `403 not_allowed`. If federation is off on this deployment, 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 refresh                                                                                                                                                                                                                                                                         |
| `POST /api/v1/aws-connect`           | `{profile?}`          | `201 {id, url, expiresAt}`: a single-use, one-hour link that opens a pre-filled CloudFormation stack in the AWS console; the stack reports the role back, and it connects only after AWS confirms the workspace can assume it. 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` |

Deletes answer with one. `status` runs `pending`, `processing`, then `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 has 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, swapped for the value only in HTTPS requests to the secret's `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`, a list of `{boxId}`, `{userId}` or `{email}`, is 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) or `granted` ([Teams: Scoped secrets](https://prized.dev/docs/teams#scoped-secrets)) 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.

| 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}`. `PATCH /api/v1/workspace/policies` (dashboard session only) takes the keys to change, `null` clearing one: `400 invalid_policy` with `field` and `message`, `403 not_owner`, `409 workspace_closing`. A create, wake, resize or fork that a policy refuses answers `403 policy_denied` with `rule` and `message`; bounds and rules 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 an API key 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`. Clients may report only `exec`, `file.upload`, `file.download`, `prompt` and `desktop.start` (`detail`: 16 keys of up to 40 characters, strings of up to 512; body: 64 KiB), and a ticket only on its own box ([Teams: Audit log](https://prized.dev/docs/teams#audit-log)). Reports for a box being deleted or already deleted return `404 not_found`; its existing audit history remains readable.

`box.connect` records SSH certificate issuance, not a successful connection. The dashboard labels it **SSH certificate issued**; the API, CSV and scheduled export keep the `box.connect` kind. A failed connection can still have this row ([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}` |

**API key access:** Workspace: Read (or Write). Boxes-only keys receive `403 key_access_denied` with `area: "workspace"` and `level: "read"`; the response includes workspace members, spend and recent activity, not just boxes. Full-access keys and browser sessions keep their existing role-based view.

```json
{
  "now": "2026-09-10T17:20:00Z",
  "cycle": {"start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z"},
  "scope": "workspace",
  "boxes": [
    {
      "id": "5d0a…", "hostname": "mango", "tier": "flow",
      "desiredState": "running", "observedState": "running",
      "ownerUserId": "7Hq2…", "ownerName": "Kat",
      "ownerEmail": "kat@example.com",
      "idleForSec": 0, "cpuPct": 12, "memPct": 41,
      "lastHeartbeatAt": "2026-09-10T17:19:42Z", "sessions": 1,
      "lastPromptAt": "2026-09-10T16:58:03Z", "lastExecAt": null,
      "spendTodayMicros": 1920000, "spendCycleMicros": 28800000
    }
  ],
  "members": [
    {
      "userId": "7Hq2…", "name": "Kat", "email": "kat@example.com",
      "role": "member", "boxes": 1, "sessions": 1,
      "spendCycleMicros": 28800000,
      "lastActiveAt": "2026-09-10T16:58:03Z"
    }
  ],
  "totals": {
    "spendTodayMicros": 1920000, "spendCycleMicros": 28800000,
    "running": 1, "sessions": 1
  },
  "recent": [
    {
      "id": 48213, "at": "2026-09-10T09:02:11Z", "kind": "box.wake",
      "origin": "server", "actorKind": "cli", "actorUserId": "7Hq2…",
      "actorTokenId": "9b1e…", "boxId": "5d0a…", "detail": {}
    }
  ]
}
```

`scope` is `workspace`, or `own` for a contractor (their boxes only, and `members` empty). A box's `idleForSec`, `cpuPct` and `memPct` are `null` unless it is running; `recent` is the newest 50 [audit events](#audit-log). What a session, today and the cycle mean is on [Teams: Team activity](https://prized.dev/docs/teams#team-activity).

## The edge [#the-edge]

Routes below are relative to `$EDGE/v1/box/{box}/`. `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.

A successful answer carries `ok: true` and a `type`; a failed one carries a code from [Errors](#errors).

### 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`, below                                                                                    |
| `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)             |
| `GET /exec/{processId}/stream`     | query `stdoutOffset`, `stderrOffset` (default 0, or the `Last-Event-ID` header as `<stdout>:<stderr>`), `streams` (`stdout`, `stderr`, or both), `encoding` (`utf8` or `base64`) | `200 text/event-stream`, below                                                                              |

The answer to the `make test` run in [Examples](#examples):

```json
{
  "ok": true, "type": "exec.result",
  "exitCode": 0, "signal": "",
  "stdout": "ok  \tapp/internal/api\t1.42s\n", "stderr": "",
  "stdoutTruncated": false, "stderrTruncated": false, "timedOut": false,
  "startedAt": "2026-09-10T17:20:01Z", "finishedAt": "2026-09-10T17:20:04Z",
  "cwd": "app"
}
```

Commands run under `sh -c` as your box user, in your home or `cwd`; the answer's `cwd` is the one you sent, or `~`. `timedOut` means the time limit ended the command (exiting 124 by itself is `exitCode: 124, timedOut: false`). A synchronous run keeps the last 1 MiB per stream and sets its `Truncated` flag; for all of it, run detached and `/download` the log ([Run commands and files](https://prized.dev/docs/exec#detached-processes)). Both answers carry `commandId` when the run was recorded in the [command history](#command-history); for a detached run it equals `processId`.

**The stream** follows one detached process from the given offsets. Each event's `data:` is one JSON object and its `id:` is `<stdoutOffset>:<stderrOffset>` after it:

| Event              | `data`                                                                                                                                                                                    |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attached`         | `{processId, stdoutOffset, stderrOffset}`, first                                                                                                                                          |
| `stdout`, `stderr` | `{data, offset, nextOffset}`; `data` is base64 under `encoding=base64`. Under `utf8` invalid bytes become U+FFFD and a character is never split across events; offsets always count bytes |
| `heartbeat`        | `{}`, after 15 seconds without an event                                                                                                                                                   |
| `reconnect`        | `{stdoutOffset, stderrOffset}`, after 10 minutes; the stream ends, open it again from there                                                                                               |
| `exit`             | `{status, exitCode, signal, stdoutOffset, stderrOffset}`, last; `status` is `exited`, `killed`, or `lost`                                                                                 |
| `error`            | `{code, message}`; the stream ends                                                                                                                                                        |

A stream holds one of the token's and the box's 8 concurrent requests while open, and a token holds at most 4 streams; a fifth answers `429 rate_limited`.

### 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, codex, opencode or pi), prompt (up to 512 KiB), model?, reasoningEffort? (low, medium, high), cwd?, continue?, queue?, auto?}`; a model of `amazon-bedrock/…`, `openrouter/…` or `deepseek/…` picks a [backend](https://prized.dev/docs/remote-agents#models-and-backends) | `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?conversation=cv_…`       | keeps to one conversation's runs                                                                                                                                                                                                                                             | The same                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `GET /prompts/conversations`           |                                                                                                                                                                                                                                                                              | `200 prompt.conversations`: `conversations[]` most recently active first (at most 50), `running` (runs in progress), `limit` (the box's limit)                                                                                                                                                                                                                                                                                                               |
| `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                                                                                                                                                                                                                                     |
| `GET /prompts/events?follow=1`         | or `Accept: text/event-stream`; query `conversation` to keep to one                                                                                                                                                                                                          | Server-Sent Events of every run in progress, and of each that starts meanwhile, until none is left (with none in progress, the newest run): `event: run` with `{run}` as a run starts, `event: prompt` per event with its `runId` and `conversationId`, `event: run.done` with `{run, next}`, then `event: done`; `event: error` with `{code, message}` when a run disappears from the box (the stream goes on) or the box stops answering (the stream ends) |
| `POST /prompts/{runId}/interrupt`      |                                                                                                                                                                                                                                                                              | `200 prompt.interrupted`: `id, wasRunning, signal`                                                                                                                                                                                                                                                                                                                                                                                                           |
| `POST /prompts/interrupt`              | query `conversation` to stop only that conversation's run                                                                                                                                                                                                                    | `200 prompt.interrupted`: `interrupted[]` (`{id, conversationId, signal}` for every run stopped), and `id, wasRunning, signal` of the first                                                                                                                                                                                                                                                                                                                  |

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}` ([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 are on [Prompt an agent remotely](https://prized.dev/docs/remote-agents).

Conversations: `POST /prompts` also takes `new` (a new conversation) or `conversationId` (continue that one), at most one of them and `continue`, and answers `conversationId` in the `run` and in `prompt.queued`; a run also carries `conversationId`. Without `new`, `conversationId` or `continue` a prompt starts a new conversation, unless `queue` is set and a run of the same provider is in progress, which it then follows. A conversation is `{id, provider, model, prompts, queued, running, lastRunId, lastStatus, sessionId, lastPrompt, createdAt, updatedAt}` ([Conversations](https://prized.dev/docs/remote-agents#conversations)). Every provider, OpenCode and pi included, and every backend model work in conversations alike.

### Desktop [#desktop]

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

Its errors use the bare `{"error": "code"}` form: `unauthorized` (a `dcp_` token only), `token_expired`, `not_found`, `bad_request`, `request_timeout` (`408` if the request body takes too long to arrive), `box_not_running`, `box_unreachable`, `desktop_stop_failed`, `resolve_failed`, `control_plane_unavailable` and `rate_limited` (`429` with `Retry-After`; a stop counts against the edge's per-token and per-box limits). The desktop stream itself is a dashboard WebSocket; from a script, `prized desktop --no-open` prints a `vnc://` address and the password ([Desktop](https://prized.dev/docs/desktop)).

### Errors [#errors]

Every edge route but `POST /desktop/stop` fails 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 another class of credential (a monitor ticket, a phone password)                                           |
| 401  | `token_expired`             | The API key has expired: make a new one ([When a key expires](https://prized.dev/docs/workspaces#when-a-key-expires))                                                          |
| 404  | `not_found`                 | No such box on this account (or outside the key's boxes), 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`           | 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`         | Your box user 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`, `codex`, `opencode` or `pi`                                                       |
| 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, or the model's backend, has no credential on the box; the message names what to set             |
| 409  | `provider_not_installed` | The provider's CLI is not on the box's login PATH, or installing OpenCode or pi failed (the message says why) |
| 409  | `no_session_to_continue` | `continue: true` with no earlier run of the provider that recorded a session                                  |
| 409  | `prompt_limit_reached`   | The box has as many runs in progress as its limit ([Limits](https://prized.dev/docs/limits#prompting-an-agent-remotely))        |
| 409  | `provider_mismatch`      | `conversationId` names a conversation of another provider                                                     |
| 404  | `not_found`              | `conversationId` names no conversation on the box                                                             |

## 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 (each prompt starts a new conversation unless it names one; `run.conversationId` is the id to pass as `conversationId` next time, and a prompt for a conversation with a run in progress answers `prompt_in_progress` unless it passes `queue: true`):

```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]

| Not yet                       | Instead                                                                                                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| An SDK or an OpenAPI document | The routes above with `curl` or any HTTP client, or the CLI's `--json` output ([CLI: For agents and scripts](https://prized.dev/docs/cli#for-agents-and-scripts))                                      |
| 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) (audit rows only) |
