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.
On this page24
Two hosts, one key
| What | Control plane | Edge |
|---|---|---|
| Base URL | https:/ | $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.
Sandboxes have their own routes on both hosts, with the same key: Sandbox API.
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
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). 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).
- What a key can do. It acts as you on every box, snapshot, environment and secret in its workspace; a contractor'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 unauthorizedto any bearer, so a leaked key cannot widen itself. - Expiry. A key past its expiry gets
401 token_expired, withexpiresAt, on every route and on the edge.GET /api/v1/mesays when the calling key expires (When a key expires). - A key for some boxes. A key limited to boxes answers
404 not_foundfor 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) answer403 key_access_deniedwithscope: "boxes"(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 answers426 cli_update_requiredwithminVersion.
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). 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). |
| 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. |
An empty balance's wake_rejected adds reason, cause and billingUrl.
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:
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 /, POST /, POST /, POST /api/v1/snapshots and POST /.
| 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
| 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 |
{
"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://…"}
}customeris the workspace:customer.idis the workspace id.useris who the key acts as, with theirrole;nullfor a key with no user.billingsays why an empty balance may not stop boxes:overage,collecting(a renewal collecting, or a failed one in its grace),paymentFailed,paymentGraceUntil.runwayHoursisnullwhile nothing is burning. Alsotrial(free credit) andbillingUrl.boxesis every box, terminated included (a contractor's own), each the box object, shortened above.box(the oldest live box inboxes) is deprecated; readboxes.keyis the calling API key:{id, kind, expiresAt, boxIds}, wherekindismachine(aprized login, whoseexpiresAtmoves with use) orapi(New key),expiresAtisnullfor a key that never expires, andboxIdsisnullunless the key is limited to boxes, whose ids it lists (thenboxesholds only those). Absent for a dashboard session.- A limited key adds
access, its level per area. Without Boxes: Read it getsboxes: []; without Workspace: Read it getsbilling: null, aworkspaceof onlyidandname, andnullforcustomer.emailandcustomer.githubLogin.
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 | |
POST /api/v1/boxes/{id}/wake | 202 {box}, desiredState: "running" | |
POST / | 202 {box}, desiredState: "suspended" | |
POST / | {target_tier} | 202 {box} with the new tier; the box restarts |
POST / | {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 / | {name, description?} | 201 {snapshot}: a template of this box, taken now |
GET / | 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 / | 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, 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).
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 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); anything else, or a region beside fromSnapshotId or fromTemplate, is 400 invalid_region |
ttlMinutes | 5 to 43200: a pause deadline |
autoPauseMin | 30 to 10080, or null: the idle window. Off when absent; 120 on the free credit |
environment | An id or name; null for none, absent for your default |
env | {NAME: "value"} |
restricted | Makes the box 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 is 403 policy_denied; only the owner may set quietHoursExempt, which keeps the box running through 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 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, 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 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); a closing workspace; a workspace paused for mining or for filling the box's CPU on the 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
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). A box's records are visible to whoever can see the box.
| Route | Query | Answer |
|---|---|---|
GET / | 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 / | 200 {command} | |
GET / | 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 / | 204; writes a command_history.delete audit row | |
DELETE / | 200 {deleted}: every record of the box |
{
"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
}statusisrunning,exited,killed,timed_out,failed, orlost;exitCode,signal,pid, andfinishedAtarenulluntil known.sourceiscli(prized exec) orapi(POST /exec).commandis the first 8,192 characters (commandTruncatedsays when it was cut). Under zero data retentioncommandandcwdarenullandoutputKeptisfalse.- Output pages are bytes of one stream from
offset. Read again fromnextOffsetuntildone(the command ended and every byte has been read).sizeis the stream's length;skippedis how many bytes from youroffsetare 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.utf8never ends a page inside a character; for exact bytes ask forbase64. - 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
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).
| Route | Body | Answer |
|---|---|---|
POST / | {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 / | 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:
port1 to 65535;accessmembers(the default),anyoneorpublic;expiresInin seconds foranyoneandpubliconly, 300 to 2592000, 604800 (7 days) by default. Anything else is400 bad_request. - The share object:
id,boxId,hostname,port,slug,access,url,origin,expiresAt(nullfor members),createdBy(the member who shared it),createdAt,openCount,lastOpenedAt.urlis the link to hand out; an anyone link's carries its secret in the201that made it and isnulleverywhere 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: falsemeans members links are off here (a create answers503 port_share_disabled);linksEnabled: falsemeans 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 |
| 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
The IAM roles the workspace's boxes assume (AWS access). 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 / | 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
| 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.
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 / | {name?, tier?} | 202 {box}, a new box from the snapshot |
POST / | {name} | 200 {snapshot, previous}; previous is the snapshot that held the name, or null |
DELETE / | 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; what each kind means is on 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
| Route | What it does |
|---|---|
GET /api/v1/environments | 200 {environments} |
POST /api/v1/environments | Create; 201 {environment} |
PATCH / | Change any field; a config change mints a version; 200 {environment} |
DELETE / | 204; boxes keep their version |
POST / | Make it the default for new boxes |
POST / | The safe-for-third-parties switch and the three credential channels |
POST /, DELETE …/vars/{name} | Set or remove one env var |
PUT /, DELETE …/files?path= | Set or remove one secret file |
POST /, DELETE …/repos?repo= | Add or remove one repo |
POST / | Pin live boxes to the latest version; 200 {upgraded, skipped, latestVersionNo} |
Bodies, the environment object, limits and error codes are on Environments: API.
Secrets
Brokered credentials: the box sees a placeholder, swapped for the value only in HTTPS requests to the secret's hosts (Credentials, 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 / | {boxId}, {userId} or {email} (one of the three) | 201 {grant}; an existing grant answers 201 again |
DELETE / | 204 | |
GET / | 200 {uses}: [{boxId, hostname, host, firstAt, lastAt, count}], up to 1,000 rows | |
GET / | 200 {mounts} | |
POST / | {secretId, envName} to mount an existing secret, or {name, value, envName, hosts?, description?} to create and mount in one step | 201 {mount} |
DELETE / | 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) 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 |
Policies
| Route | Answer |
|---|---|
GET / | 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 / (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.
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). 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).
| 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
| 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.
{
"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. What a session, today and the cycle mean is on Teams: Team activity.
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.
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:
{
"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). Both answers carry commandId when the run was recorded in the 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
| 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.
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 | 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 / | 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 / | 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 / | 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). 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.
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). Every provider, OpenCode and pi included, and every backend model work in conversations alike.
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).
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) |
| 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) |
| 409 | provider_mismatch | conversationId names a conversation of another provider |
| 404 | not_found | conversationId names no conversation on the box |
Examples
Create a box and wait for it to run:
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; doneRun a command on it, upload a file, download a directory:
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.tarPrompt 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):
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:
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; doneNot 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) |
| 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 (audit rows only) |