Cubicle

Requests and waiting

How asynchronous Cubicle HTTP API requests work, with 202 Accepted and desired state, following a request by polling a Box's state, generation and ssh.ready, failure and superseded signals, deletion, exec, snapshot and restore operations, Idempotency-Key retries and polling budgets.

Requests that change a Box return 202 Accepted as soon as Cubicle has recorded them. The Box's Node carries them out afterwards, and you poll until the change is done.

RequestResponsePollDone when
Create a Box202 with the BoxGet a Boxssh.ready is true. With start: false: state is stopped and observed.generation equals desired.generation.
Start a Box202 with the BoxGet a Boxssh.ready is true.
Stop a Box202 with the BoxGet a Boxstate is stopped and observed.generation equals desired.generation.
Hibernate a Box202 with the BoxGet a Boxstate is hibernated and observed.generation equals desired.generation.
Delete a Box202 with the BoxList an organization's BoxesThe Box's ID is no longer listed.
Run a command, Take a snapshot, Restore a Box202 with an operationGet an operationstate is done, failed or unknown.

Every other route finishes its work before it responds with 200, 201 or 204. Access changes, such as a new grant or SSH key, are recorded at once and applied inside running Boxes shortly after: a grant's appliedRevision equals its desiredRevision once the Box has applied it.

The 202 response shows the new desired state

A lifecycle response is the Box right after the request. desired holds the new state and generation, but state and observed are still the Node's last report. A start of a stopped Box returns, in part:

{
  "state": "stopped",
  "desired": { "state": "running", "generation": 3 },
  "observed": {
    "state": "stopped",
    "generation": 2,
    "observedAt": "2026-11-05T10:15:04.000Z",
    "ageSeconds": 2,
    "fresh": true,
    "error": null
  },
  "ssh": { "ready": false, "reason": "not_running" }
}

Do not read state in a 202 response as the result. Box lifecycle explains desired and observed state, generations and every state.

Following a request

Poll Get a Box about once a second, and check each response in this order:

  1. Is the desired state still yours? desired.state must still be the state you asked for: running after a create or a start. If it has changed, another request replaced yours, such as someone else's stop or automatic hibernation. Stop waiting. A 404 means the Box has been deleted.
    • After a hibernate, desired.state back at running with hibernationRetryAt set means saving the memory failed. The Box keeps running, observed.error says why, and Cubicle tries again at hibernationRetryAt.
  2. Is it done? Compare with the table above. ssh.ready is true only at the current generation, so it needs no generation check. A stopped or hibernated report counts only when observed.generation equals desired.generation: a report at a lower generation predates your request.
  3. Did it fail? state: "failed" with observed.error means the start failed or the guest stopped on its own. Cubicle does not retry it; start the Box again, stop it or delete it. A failed report keeps the generation the Node last applied, so it can be left over from an earlier start. It belongs to your request when either holds:
    • observed.ageSeconds is less than the seconds since you sent the request. Allow 1 s for rounding. The Node repeats a current failure about every 10 s, so a leftover report grows older than your request.
    • Since your request, you have seen a state other than failed, unknown or pending.
  4. Does it need a new start? While you wait for ssh.ready, restartRequired: true means the Node's host restarted. The Box does not start until you send another start.
  5. Otherwise, keep polling. pending, provisioning, starting, stopping and hibernating are progress. unknown means the Node has not reported recently: treat the Box as neither done nor failed.

The SDK's lifecycle methods follow these rules for you, up to 5 minutes by default:

// Waits until ssh.ready is true.
await cubicle.boxes.start("acme/job-1")

// Or accept the request now and wait separately, with your own deadline.
await cubicle.boxes.stop("acme/job-1", { wait: false })
const info = await cubicle.boxes.waitUntil("acme/job-1", "stopped", { timeoutMs: 600_000 })
console.log(info.state) // => "stopped"

A wait that fails rejects with a CubicleBoxError or a CubicleTimeoutError; see Waiting.

Set a deadline of your own. Preparing the Box's disk comes first and can take several minutes. After that, a guest that does not answer within 180 s of its VM starting fails with guest did not become reachable.

Deletion

Delete a Box returns 202 with deletion: "pending", and Get a Box returns 404 from then on. List an organization's Boxes and List the caller's Boxes keep showing the Box, with deletion: "pending", until its Node has removed it and released its resources. Deleting a Box explains what deletion does.

To wait for the removal, poll the listing for the Box's ID, not its name: a new Box may take the name.

api=https://api.cubicle.sh/v1
auth="Authorization: Bearer $CB_SDK_API_KEY"
id=0f6c2a5e-3b1d-4c8e-9a7f-2d4e6b8c1a3f

curl -sS -o /dev/null -w '%{http_code}\n' -X DELETE "$api/boxes/$id" -H "$auth"

while :; do
  listed=$(curl -sS "$api/orgs/acme/boxes" -H "$auth" |
    jq --arg id "$id" 'if type == "array" then any(.[]; .id == $id) else true end')
  [ "$listed" = true ] || break
  sleep 2
done

The SDK's boxes.delete() waits like this for you. cubicle boxes delete --wait returns as soon as the Box stops resolving; see cubicle boxes delete.

Operations

Run a command, Take a snapshot and Restore a Box return 202 with an operation in state pending. Poll Get an operation with its id until state is done, failed or unknown. Operations record every request explains each state, and each endpoint page lists its preconditions and errors.

  • Wait until ssh.ready is true before you send an exec. An exec accepted while the Box is still starting fails at the Node.
  • After a done snapshot, List a Box's snapshots shows the snapshot with this operationId and status: "complete".
  • After a done restore, the Box is still stopped. Start it.
  • A snapshot or restore interrupted by a restart of the Node's service is carried out again rather than ending unknown.

exec, snapshot and restore wait for their operation themselves: an exec for its timeoutMs plus 2 minutes, a snapshot or restore for 15 minutes.

const result = await box.exec(["make", "test"])
console.log(result.exitCode) // => 0

// Follow an operation by its ID, for example after a CubicleTimeoutError.
const operation = await cubicle.operations.wait("9d2e4f6a-1b3c-4d5e-8f7a-6b8c0d2e4f1a")
console.log(operation.state) // => "done"

Idempotency keys

A request can reach Cubicle even when its response is lost. An Idempotency-Key header lets you send it again without doing the work twice.

RouteFirst requestSame key, same body
Create a Box202 with the new Box200 with that Box as it is now, even if it has been deleted since. Nothing is created.
Run a command202 with an operation202 with the original operation, in its current state. Nothing runs.
Take a snapshot202 with an operation202 with the original operation.
Restore a Box202 with an operation202 with the original operation.
  • Only these four routes read the header. Every other route ignores it.
  • A key is 1 to 200 characters; anything else fails with 400 invalid_request. A UUID per logical request works well.
  • A key is scoped to the organization, the caller and the route. The same key used by another caller, or on another of these routes, is unrelated.
  • The body is compared after validation, ignoring key order and the fields the API ignores. For an exec, only argv, timeoutMs, maxOutputBytes and cwd count, with defaults and caps applied, so leaving out timeoutMs matches sending 60000. For an exec, a snapshot and a restore, the comparison includes the Box: the same key sent to another Box in the organization fails with 409 idempotency_conflict.
  • The same key with a different body fails with 409 idempotency_conflict. Use a new key.
  • A request that fails, such as 409 conflict or 503 capacity, records nothing. Retry it with the same key.
  • Two requests with the same key at the same time create one resource: the second waits for the first and returns its result.
  • An exec, snapshot or restore replay still checks your access and that the Box exists. It returns the original operation even if the Box has stopped or started since, but 404 once the Box is deleted.
  • A create repeated after its Box was deleted still returns that Box: with deletion: "pending" while its Node removes it, then with state: "deleted" and deletion: "complete".
  • Keys are kept for 7 days. Do not rely on a key after that.
  • An exec that ended unknown comes back unknown for the same key. To run the command again, use a new key.

Start, stop, hibernate and delete take no key. Repeating a start or stop leaves the same desired state, but advances the generation again, so a repeated start briefly keeps SSH and private-network traffic from reaching the Box. A repeated delete returns 404. Send deletes by ID, so that a retry cannot delete a new Box that took the name.

  • The SDK sends a new key with every create, exec, snapshot and restore, and reuses it when it retries that request. Pass idempotencyKey to keep the same key across processes. See Idempotency keys in the SDK.
  • The CLI sends a new key with each run of cubicle boxes create, and none with cubicle exec, so running cubicle exec again runs the command again. The CLI does not retry requests.

Polling budget

Every poll spends from the request budget that all API keys and sessions of your user or service account share; Stay within budget shows how to follow many Boxes with one request and what to do on a 429.

On this page