Sign in

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

WhatControl planeEdge
Base URLhttps://api.prized.dev/api/v1$EDGE/v1/box/{box}/
A box isits 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 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).
  • 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).
  • 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

TopicRule
JSON in, JSON outSend 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.
IdsGET /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 progressCreating, 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.
ErrorsCodes 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.
IdempotencyCreating, 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.
LimitsThe 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 moneyTimestamps are RFC 3339 UTC. Balances are micro-dollars (1000000 is $1).
StabilityThe 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 /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.

WhenAnswer
A retry with the same key and the same requestThe 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 running409 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 scope422 idempotency_key_reused
A key that is not 1 to 128 printable ASCII characters without spaces400 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 answerA 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

RouteAnswer
GET /api/v1/me200 {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://…"}
}
  • 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. Also trial (free credit) and billingUrl.
  • boxes is every box, terminated included (a contractor's own), each the box object, 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

RouteBodyAnswer
GET /api/v1/boxesquery live=1 leaves out boxes whose observedState is terminated200 {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}/wake202 {box}, desiredState: "running"
POST /api/v1/boxes/{id}/suspend202 {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-1202 {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}/metricsquery 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-atquery 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:

FieldRule
nameA hostname: lowercase letters, digits and hyphens, starting and ending with a letter or digit, 1 to 32 characters; a fruit if absent
tierA 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
regionWhere 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
ttlMinutes5 to 43200: a pause deadline
autoPauseMin30 to 10080, or null: the idle window. Off when absent; 120 on the free credit
environmentAn id or name; null for none, absent for your default
env{NAME: "value"}
restrictedMakes the box safe for third parties; a contractor's box always is
fromSnapshotId, fromTemplateOne, 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).

HTTPerrorWhen
400bad_request, invalid_name, invalid_template, invalid_tier, invalid_region, invalid_env, invalid_auto_pause, invalid_pause_at, invalid_auto_snapshot, environment_too_largeThe body is off; message names the window or the rule
400resize_rejected, move_rejectedA 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
403restore_tier_not_allowedThe size is off your plan, or its disk cannot hold the snapshot
403policy_denied, not_ownerA workspace policy refused a member (rule is memberSpendCapUsd, memberBoxLimit, memberMaxTier, autoPauseFloorMin or contractor_restricted); a quiet-hours exemption from anyone but the owner
404not_foundNo such box, snapshot, template or environment in this workspace, or a box a contractor does not own
409box_limit, name_taken, insufficient_balance, tier_unavailable, workspace_closing, workspace_held, wake_rejected, box_failed, snapshot_in_progress, box_not_runningIn 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.

RouteQueryAnswer
GET /api/v1/boxes/{id}/commandslimit (1 to 200, default 50), before (the previous page's nextBefore), mode (sync or detached), status200 {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}/outputstream (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}/commands200 {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
}
  • 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 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

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).

RouteBodyAnswer
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-shares200 {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-shares200 {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).
HTTPerrorWhen
402share_links_need_paymentAn anyone or public link on the free credit
403not_allowed, policy_denied, share_links_blockedA 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
404not_foundNo such box, a box a contractor does not own, or a link already stopped
409share_link_limitThe 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.

RouteBodyAnswer
GET /api/v1/aws-roles200 {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}/verify200 {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

RouteAnswer
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

RouteBodyAnswer
GET /api/v1/snapshotsquery transient=1 to include fork copies200 {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}/template200 {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

RouteWhat it does
GET /api/v1/environments200 {environments}
POST /api/v1/environmentsCreate; 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}/defaultMake it the default for new boxes
POST /api/v1/environments/{id}/togglesThe 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}/upgradePin 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).

RouteBodyAnswer
GET /api/v1/secretsquery box={id} for only the ones that box may mount200 {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 it201 {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}/usage200 {uses}: [{boxId, hostname, host, firstAt, lastAt, count}], up to 1,000 rows
GET /api/v1/boxes/{id}/secrets200 {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 step201 {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) 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.

HTTPerrorWhen
400bad_request, invalid_name, invalid_host, invalid_env_nameThe body is off
402secret_limitThe workspace's secret count is full
403secret_not_granted, not_allowedA 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
404not_found, member_not_foundNo such secret, grant or box (a terminated box included); a grant to a user id or email that is not a current member
409name_taken, env_name_taken, box_restrictedA secret with that name, a mount on that variable, a box that is safe for third parties

Policies

RouteAnswer
GET /api/v1/workspace/policies200 {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.

Audit log

RouteBody or queryAnswer
GET /api/v1/auditquery 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=csvthe same filterstext/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).

HTTPerrorWhen
400invalid_time, invalid_kind, invalid_box, invalid_actor, invalid_origin, invalid_cursor, invalid_limit, invalid_formatThe named query parameter is off; message says the shape
400invalid_kind, bad_requestA report of a kind clients may not claim, or a body that is off
403not_allowedA contractor reading, or a monitor ticket reporting
404not_foundA report naming another workspace's box, or a ticket naming a box other than its own

Team

RouteAnswer
GET /api/v1/team/status200 {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

RouteBody or queryAnswer
POST /exec{command, cwd?, timeoutSeconds? (1 to 600, default 30), env? (up to 32), detached?}200 exec.result, below
POST /exec with detached: truethe same200 exec.started: processId, pid, cwd, startedAt, logPath
GET /exec200 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}/streamquery 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:

Eventdata
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

RouteBody or queryAnswer
GET /filesquery path200 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 /downloadquery pathThe 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 /uploadquery path, mode?; the raw file as the body200 file.uploaded: path, size

Size caps are on Limits: Commands and files.

Prompts and events

RouteBody or queryAnswer
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 backend202 prompt.started: run; or 202 prompt.queued: queued, active
GET /prompts200 prompt.list: runs[] newest first (at most 50), active (the run in progress, or null)
GET /prompts?conversation=cv_…keeps to one conversation's runsThe same
GET /prompts/conversations200 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}/eventsquery 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=1or Accept: text/event-streamServer-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=1or Accept: text/event-stream; query conversation to keep to oneServer-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}/interrupt200 prompt.interrupted: id, wasRunning, signal
POST /prompts/interruptquery conversation to stop only that conversation's run200 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

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

HTTPerror.codeMeaning
401unauthorizedNo bearer, a token the control plane rejected, or another class of credential (a monitor ticket, a phone password)
401token_expiredThe API key has expired: make a new one (When a key expires)
404not_foundNo such box on this account (or outside the key's boxes), process, run, path (or parent directory), or route
405method_not_allowedA known route with the wrong method; Allow lists the right ones
409box_not_runningAny route but POST /exec and POST /prompts on a suspended box; the message says how to wake it
400invalid_requestNot 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
400invalid_timeouttimeoutSeconds outside 1 to 600
400invalid_pathA path with a NUL or newline byte, a directory where a file was expected, or no path
403permission_deniedYour box user cannot read or write that path
413payload_too_largeOver the inline (1 MiB) or transfer (1 GiB) cap
429rate_limitedToo many requests for this token or box; Retry-After says when
502box_unreachableThe box could not be reached, or did not wake in time
502exec_failedThe ssh session or the on-box script failed; the message carries the box's own words
5xxcontrol_plane_unavailableThe control plane could not be reached to check the token

The prompt routes add:

HTTPerror.codeMeaning
400invalid_providerprovider is not claude, codex, opencode or pi
400prompt_requiredprompt is missing or blank
409prompt_in_progressA run is in progress; the message names it. Queue behind it, or interrupt it
409provider_not_signed_inThe provider, or the model's backend, has no credential on the box; the message names what to set
409provider_not_installedThe provider's CLI is not on the box's login PATH, or installing OpenCode or pi failed (the message says why)
409no_session_to_continuecontinue: true with no earlier run of the provider that recorded a session
409prompt_limit_reachedThe box has as many runs in progress as its limit (Limits)
409provider_mismatchconversationId names a conversation of another provider
404not_foundconversationId 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; done

Run 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.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):

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; done

Not yet

Not yetInstead
An SDK or an OpenAPI documentThe routes above with curl or any HTTP client, or the CLI's --json output (CLI: For agents and scripts)
WebhooksPoll 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)