Cubicle

Run commands

Run commands in a Cubicle Box with exec from the TypeScript SDK, the CLI or the HTTP API, covering argv and sh -c, cwd, environment variables and secrets, timeouts, output limits, exit codes, work longer than 10 minutes, waking hibernated Boxes, idempotency keys and polling operations.

exec runs one command in a running Box and returns its exit code, stdout and stderr when the command ends. Use it for scripted work: builds, tests, setup steps and agent tool calls.

  • Each exec is an operation that the Box's Node carries out, so you can follow it after the request returns.
  • The command gets no shell, no stdin and no terminal.
  • Its output arrives only when it ends.
  • Streaming output, stdin, TTYs and file upload or download APIs are not available yet. Move files into and out of a Box shows what to do instead.
  • For an interactive shell, SSH into the Box.

Before you begin

  • Set up the SDK or the CLI as in the Quickstart. The examples use a running Box acme/job-1 (cubicle boxes create --org acme job-1 --wait), with box = cubicle.boxes.ref("acme/job-1") in TypeScript, and curl snippets read a write key from CB_SDK_API_KEY and use jq.
  • You need an explicit grant on the Box. The Box's creator gets one automatically. The organization's owner or admin role is not enough on its own.
  • An API key needs write scope. A read key cannot run commands.

Run a command

const result = await box.exec(["uname", "-a"])
console.log(result)
// => { exitCode: 0, stdout: "Linux box-job-1 … GNU/Linux\n", stderr: "",
//      timedOut: false, truncated: false, operationId: "…" }

How the command runs:

  • The argument vector runs directly, with no shell and no terminal, and stdin is /dev/null.
  • It runs as your own guest account (a service account has its own), in that account's home directory, with a minimal environment: your local variables and shell profiles are not passed on. sudo works only for a person who has added an SSH key; see Root and sudo.

Check who runs your commands

Run this to confirm the account and the working directory:

const result = await box.exec(["sh", "-c", "id -un; pwd"])
console.log(result.stdout)
// => jv_1a2b3c4d5e6f
//    /home/jv_1a2b3c4d5e6f

Use shell syntax

Pipes, redirection, &&, globs and variables need a shell. Pass the script to sh -c as one argument:

const result = await box.exec(["sh", "-c", 'ls /etc | wc -l && echo "home is $HOME"'])

To load a login environment, such as a PATH set in ~/.profile, run bash -lc instead of sh -c.

Set the working directory

Pass cwd as an absolute path of at most 4096 characters. The directory must exist.

const result = await box.exec(["ls", "-la"], { cwd: "/tmp" })

Pass environment variables and secrets

Exec has no option for environment variables, and the API ignores fields it does not know, such as env. Set variables in argv with the env program instead:

const token = process.env.DEPLOY_TOKEN ?? ""
const result = await box.exec(["env", `DEPLOY_TOKEN=${token}`, "./deploy.sh"])

A value in argv is not private:

  • While the command runs, every account in the Box can see its arguments, for example with ps -ef.
  • Anyone who can use sudo in the Box can also read the command's environment and your files.
  • Cubicle stores each exec's argv with its operation, and keeps it after the Box is deleted. Operations read through the API do not include it.
  • Output is stored with the operation too, and only you can read it. Keep the secret out of it.

When several commands need the same secret, write it once to a file that only your guest account can read, and let later commands read it from there. The secret is then in the argv of the one short exec that writes the file:

const token = process.env.DEPLOY_TOKEN ?? ""

// The token is passed as "$1", outside the script, so it needs no quoting.
await box.exec(["sh", "-c", 'umask 077 && printf %s "$1" > "$HOME/.deploy-token"', "sh", token])
await box.exec(["sh", "-c", 'DEPLOY_TOKEN=$(cat "$HOME/.deploy-token") ./deploy.sh'])
await box.exec(["rm", "-f", ".deploy-token"])

The file is on the Box's disk, so a snapshot taken while it exists, and every Box created from that snapshot, contains it. Deleting the file later does not remove it from the snapshot. If you can SSH into the Box, copy the file in over SSH instead, as in Move files into and out of a Box, so the secret never goes through exec.

Set a timeout and an output limit

FieldDefaultRangeWhen exceeded
timeoutMs60,000 ms (60 s)100 ms to 600,000 ms (10 min). Larger values are lowered to 10 min, not rejected.The command is killed. The result has timedOut: true, exitCode: 124 and the output collected so far.
maxOutputBytes65,536 bytes (64 KiB)0 to 1,048,576 bytes (1 MiB). Larger values are lowered to 1 MiB, not rejected.stdout and stderr share one budget. Output past it is discarded and the result has truncated: true. The command keeps running.
argvRequired1 to 1024 strings. The whole request body is limited to 64 KiB.More than 1024 strings fails with 400 invalid_request. A body over 64 KiB fails with 413 payload_too_large.
const result = await box.exec(["sh", "-c", "./build.sh"], {
  timeoutMs: 300_000, // 5 min
  maxOutputBytes: 1_048_576, // 1 MiB
})

The SDK waits for the command's timeout plus 2 minutes before it gives up with CubicleTimeoutError.

For more output than 1 MiB, write it to a file in the Box and read the file back in parts, for example with tail -n 200 or head -c 1000000.

Read the result

A finished exec has five fields. The SDK's ExecResult adds the operationId.

FieldMeaning
exitCodeThe command's exit status.
stdoutStandard output, decoded as UTF-8. Invalid bytes become the replacement character (U+FFFD), and a NUL byte makes the exec end failed, so encode binary output, for example with base64.
stderrStandard error, decoded the same way.
timedOuttrue when the command ran past timeoutMs and was killed. exitCode is then 124.
truncatedtrue when stdout and stderr together went past maxOutputBytes. The rest was discarded.

A non-zero exit code is a result, not an error. The operation still ends done, the SDK resolves, and the CLI exits with that code. Check the code yourself:

const result = await box.exec(["sh", "-c", "test -d /srv/app"])

if (result.timedOut) throw new Error("the check timed out")
if (result.exitCode !== 0) console.log(`no app directory (exit code ${result.exitCode})`)

Check timedOut, not the exit code, to detect a timeout: a command can exit with 124 itself.

Some exit codes come from Cubicle or the system rather than from your program:

exitCodeMeaning
124, with timedOut: trueThe command ran past timeoutMs and was killed.
127, with stderr command not foundThe cwd you passed does not exist in the Box.
127 from sh -cThe shell did not find the program. stderr says not found.

A program that is not on PATH, run without a shell, fails with a non-zero exit code and an error message in stderr. Use its absolute path.

Run work longer than 10 minutes

One exec runs for at most 10 minutes. Split longer work into steps that each finish within that limit, and run each step as its own exec. Files stay on the Box's disk between execs, so a later step can use what an earlier one wrote:

const steps = ["./fetch-data.sh", "./build.sh", "./test.sh"]

for (const step of steps) {
  // wake: true starts the Box again if it hibernated between steps.
  const result = await box.exec(["sh", "-c", step], { timeoutMs: 600_000, wake: true })

  if (result.timedOut || result.exitCode !== 0) {
    throw new Error(`${step} failed with exit code ${result.exitCode}`)
  }
}

Keep these limits in mind:

  • An exec that is still running when the Box hibernates automatically ends unknown, so make each step safe to repeat.
  • An exec ends when the command exits and its stdout and stderr are closed. A background process that still holds them keeps the exec open until timeoutMs.
  • Cubicle does not guarantee that a process started by an exec keeps running after the exec returns.

Wake a stopped or hibernated Box

exec needs a Box whose desired state is running. Otherwise the request fails with 409 conflict "box is not running", as it does once the Box has hibernated automatically 60 minutes after each start, whatever is running. A command still running at that point ends unknown.

const result = await box.exec(["uname", "-r"], { wake: true })

With wake: true, the SDK starts the Box when it is stopped, hibernated or held after a host reboot, waits until it is ready, then runs the command. It never starts a Box that is already running.

Start a Box only when its desired.state is not running, or when restartRequired is true. Starting a running Box does not reboot it, but it advances its generation, which can drop open connections.

Retry safely with idempotency keys

An exec request can reach Cubicle even when its response is lost. An Idempotency-Key makes the retry return the original operation instead of running the command again.

The SDK sends a key with every exec, generated for each call. It reuses the key when it retries a request that was rate limited, got no response, or got a 502, 503 or 504. To make a retry from a new process safe as well, pass your own key:

const result = await box.exec(["sh", "-c", "./migrate.sh"], {
  idempotencyKey: "migrate-2026-11-05",
})

A command whose operation ended unknown may or may not have run, and Cubicle never replays it. The same key returns that unknown operation again. To run the command again, use a new key, and make the command safe to repeat, for example by having it check for a marker file.

Idempotency keys has the rules for keys: their length and scope, how long they are kept, and when a reused key fails with 409 idempotency_conflict.

Follow an exec with curl

The API accepts an exec at once and runs it in the background. Poll its operation for the result:

  1. Request the exec and keep the operation ID:

    op=$(curl -sS -X POST "https://api.cubicle.sh/v1/boxes/acme%2Fjob-1/exec" \
      -H "Authorization: Bearer $CB_SDK_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{"argv": ["uname", "-a"], "timeoutMs": 30000}' | jq -r .id)

    The 202 response is the new operation:

    {
      "id": "0b6f7a52-9c1e-4d3b-8a2f-6e5d4c3b2a19",
      "boxId": "c41d8e2a-5b7f-4c6e-9d1a-3f2b8e7c6d50",
      "type": "exec",
      "state": "pending",
      "attempt": 0,
      "progress": null,
      "result": null,
      "error": null,
      "createdAt": "2026-11-05T10:00:00.000Z",
      "startedAt": null,
      "completedAt": null
    }
  2. Poll Get an operation until its state is done, failed or unknown:

    while :; do
      body=$(curl -sS "https://api.cubicle.sh/v1/operations/$op" \
        -H "Authorization: Bearer $CB_SDK_API_KEY")
      case $(echo "$body" | jq -r .state) in
        done | failed | unknown) break ;;
      esac
      sleep 1
    done
    echo "$body" | jq '{state, error, result}'
  3. Read the result:

    {
      "state": "done",
      "error": null,
      "result": {
        "exitCode": 0,
        "stdout": "Linux box-job-1 … GNU/Linux\n",
        "stderr": "",
        "timedOut": false,
        "truncated": false
      }
    }

    A failed operation has error set and result: null. An unknown operation was interrupted, and its command may or may not have run.

GET /v1/boxes/acme%2Fjob-1/operations lists the Box's 50 most recent operations, newest first. In the SDK, cubicle.operations.get(id) and cubicle.operations.wait(id) read an operation by ID, and box.listOperations() lists them.

Troubleshooting

  • 409 conflict "box is not running": the Box's desired state is not running, often because it hibernated automatically. Wake it first.
  • Operation failed with "box is not running at the requested generation": the exec reached the Box before it was ready, or the Box was started or stopped in between. Wait until ssh.ready is true, then send a new exec. The SDK's create, start and wake: true wait for this.
  • 403 forbidden "an explicit box grant is required": you have no grant on the Box. An owner or admin role does not replace one. Ask an owner or admin to grant access to you or your service account.
  • 403 insufficient_scope "this API key is read-only": use a key with write scope.
  • Operation failed with an error ending in unknown guest account: the grant is new and the Node has not created your guest account in the Box yet. Retry after a few seconds.
  • Operation failed with "requester is not authorized on this box": your grant or organization membership was revoked after you sent the request.
  • Operation unknown with "exec control interrupted by a Box lifecycle transition; command is not replayed": the Box was stopped, hibernated or deleted while the command ran. The SDK rejects with CubicleOperationError and code operation_unknown, and the CLI prints exec unknown: …. Check the Box's state before you run the command again.
  • Operation unknown with "metal service restarted during exec; outcome unknown and not replayed": the service on the Box's Node restarted during the command. Treat it as above.
  • Operation failed with unsupported Unicode escape sequence: the output contained a NUL byte, for example from cat on a binary file, and Cubicle could not store it. The command did run. Encode binary output, for example with base64 -w 0.
  • 409 idempotency_conflict: you reused an idempotency key with a different request. Use a new key.

On this page