Script the sbx CLI With JSON Output and jq
Updated October 27, 2026 .md
Short answer: Pipe sbx and it prints JSON, so jq can read every result. Most commands print one object with a type field. sbx new and sbx resume print JSON Lines events ending in ready, so select that event for the id. Errors go to stderr as an error event, and the exit code tells you what failed.
This page is about the shape of that JSON and the shell patterns around it. For the list of commands, see the sbx CLI cheatsheet. For what each error code means, see API error codes. Everything below ran on prod on 2026-10-27 with CLI build 14c740d, small sandboxes and jq 1.8. Output is real, trimmed with ….
The three rules#
- JSON turns on when stdout isn't a terminal.
sbx list | jqworks without--json. Add--jsonto force it in a terminal. - Results go to stdout, errors to stderr. An error is one line,
{"event":"error",…}, on stderr. Stdout stays empty, so$(…)captures nothing. sbx execis the exception. Piped, it still prints the command's plain stdout and stderr. Pass--jsonexplicitly to get a result object.--detachalways prints JSON.
What each command prints#
| Command | Output when piped | Fields you'll use |
|---|---|---|
sbx new |
JSON Lines: created, a state line per change (may be none), ready |
.event, .id, .state, .url, .sandbox |
sbx new --no-wait |
One line: created |
.id, .state (provisioning) |
sbx resume |
JSON Lines: resuming, state, ready |
.sandbox.memoryRestored on ready |
sbx resume --no-wait |
One line: resuming |
.status |
sbx info |
One object, "type":"sandbox.info" |
.sandbox.state, .sandbox.labels |
sbx list |
One object, "type":"sandbox.list" |
.sandboxes[] (same fields as .sandbox above) |
sbx stop |
One object, "type":"sandbox.stopping" |
.status (archiving), .stop |
sbx stop --wait |
One object, "type":"sandbox.info" |
.sandbox.state (archived) |
sbx exec --json |
One object, "type":"command.finished" |
.exitCode, .stdout, .stderr, .timedOut, .oomKilled |
sbx exec --detach |
One object, "type":"command.started" |
.processId, .logPath |
sbx exec --status <pid> |
One object, "type":"command.status" |
.status (running, exited), .exitCode |
sbx delete --yes |
One object, "type":"deletion.operation" |
.operation.status (completed) |
sbx status |
One object, "type":"cli.status" |
.signedIn, .apiReachable |
| Any failure | One line on stderr, "event":"error" |
.code, .status, .error, .requestId |
Single objects carry ok and type. Stream lines carry event instead. sbx fork --wait also streams: a forking line, then the same state and ready lines as resume (from the CLI source; we didn't fork here). sbx prompt, sbx events and sbx conversations print JSON Lines too.
Get the id from sbx new#
sbx new prints more than one line, so don't parse its whole output as one JSON document. Select the event you want:
id="$(sbx new --type small --name jq-demo-1 | jq -r 'select(.event == "ready") | .id')"The two lines it printed, trimmed:
{"event":"created","id":"sb_1xfcnnm3qr","state":"provisioning","ttlSeconds":3600,"sandbox":{"access":"owner",…}}
{"event":"ready","id":"sb_1xfcnnm3qr","state":"ready","ip":"2a01:4f9:3120:1d84::5b00:12","url":"https://fern-free-jackal.box.sandboxer.com","sandbox":{…}}It took 3.2 seconds. This run had no state line in between, so don't count lines. Use select(.event == "created") with --no-wait when you want the id at once and plan to wait yourself.
Exit codes in a script#
sbx exits 0 on success, 2 for a usage error, 3 when not signed in, 4 for not found and 5 for a CLI timeout. The cheatsheet has the table. sbx exec passes the remote exit code through, in both modes:
# run from a script, so stdout isn't a terminal
$ sbx exec $id 'echo hello; exit 3'; echo "exit=$?"
hello
exit=3
$ sbx exec $id --json 'echo hello; echo oops >&2; exit 3'
{"ok":true,"type":"command.finished","commandId":"cmd_4znkb1hu7dph","cwd":"/home/user","durationMs":2,"exitCode":3,…,"stderr":"oops\n",…,"stdout":"hello\n",…,"success":false,"timedOut":false}
$ echo $?
3So set -e stops your script when a remote command fails. Write sbx exec … || true where a failure is expected. The CLI source maps a remote timeout to 124 and a signal to 128 plus the signal number.
Put sbx flags before the command: sbx exec $id --json --timeout 60 'cmd'. The default timeout is 30 seconds and the maximum 600; past that, use --detach.
Wait on states#
Most commands wait for you: sbx new and sbx resume return at ready, and sbx stop --wait returns at archived. When you start something with --no-wait or --detach, poll it yourself.
Wait for a stop to finish:
sbx stop $id >/dev/null
until [ "$(sbx info $id | jq -r .sandbox.state)" = archived ]; do sleep 2; doneOn prod, the state read archiving at 0 s and archived at 2 s.
Wait for a detached job and read its exit code:
pid="$(sbx exec $id --detach 'sleep 20; echo done; exit 7' | jq -r .processId)"
while [ "$(sbx exec $id --status $pid | jq -r .status)" = running ]; do sleep 2; done
sbx exec $id --status $pid | jq -c '{status, exitCode, stdout}'{"status":"exited","exitCode":7,"stdout":"done\n"}The states you'll see in .sandbox.state, and what a wait loop should do with each:
| Group | States | In a wait loop |
|---|---|---|
| Up | ready, idle, running |
Done waiting |
| Pending | init, provisioning, provisioned, cloning |
Keep polling |
| Stopping | archiving, stopping |
Keep polling |
| Stopped | archived, stopped |
Done if you stopped it; resume if you need it |
| Failed | error, cancelled |
Stop and report .sandbox.error |
After sbx resume, check .sandbox.memoryRestored on the ready line. Ours was true. When it's false, the sandbox booted from disk: files are there, processes aren't.
Loop over your sandboxes by name or label#
sbx list returns up sandboxes by default (--all for every state). Filter the array with jq. By name prefix:
sbx list | jq -r '.sandboxes[] | select(.name | startswith("jq-demo-")) | [.id, .state, .type, .name] | @tsv'sb_1xfcnnm3qr ready small jq-demo-1Labels are set through the REST API; the CLI doesn't have a labels command yet (labels docs). Once set, every sbx list entry carries them:
curl -sS -X PATCH https://sandboxer.com/api/v1/sandboxes/$id \
-H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
-d '{"labels":{"job":"nightly","repo":"web"}}' | jq -c .sandbox.labels
# {"job":"nightly","repo":"web"}
sbx list | jq -r '.sandboxes[] | select(.labels.job == "nightly") | .id'
# sb_1xfcnnm3qrTo act on each match, feed the ids to a while read loop:
sbx list | jq -r '.sandboxes[] | select(.labels.job == "nightly") | .id' |
while read -r id; do sbx stop "$id" --wait >/dev/null && echo "stopped $id"; doneOnly touch sandboxes your filter selects. A shared account holds other people's sandboxes too.
Handle error events#
Send stderr to a file, then branch on .code, not on the message. Capture $? in the || branch; inside if ! … it's already been negated to 0.
out="$(sbx info sb_doesnotexist 2>err.json)" || {
rc=$?
case "$(jq -r .code err.json)" in
not_found) echo "already gone (exit $rc, $(jq -r .requestId err.json))" ;;
*) jq -r '"failed: \(.code): \(.error)"' err.json; exit "$rc" ;;
esac
}already gone (exit 4, req_KyJfdFXkiWbB3iPLleZX)Errors from the API carry code, status and requestId. Errors the CLI raises itself have only code:
{"event":"error","error":"Sandbox not found","code":"not_found","status":404,"requestId":"req_RRGfeKI104b7FltiW91F"}
{"event":"error","error":"--type must be small, default, large or xlarge","code":"usage"}
{"event":"error","error":"refusing to delete sb_doesnotexist without --yes in a non-interactive session","code":"usage"}The first exited 4, the other two 2. Piped, sbx delete needs --yes.
A complete script: one command in N fresh sandboxes#
fanout.sh creates N small sandboxes without waiting, polls until each is up, runs a command in each, prints one JSON line per sandbox and deletes them all on exit, including after an error or Ctrl-C.
#!/usr/bin/env bash
# fanout.sh: run one command in N fresh sandboxes, print one JSON result per
# sandbox, and delete every sandbox it made, even on Ctrl-C or an error.
# Usage: N=2 PREFIX=ci-run ./fanout.sh 'python3 -c "print(6*7)"'
set -uo pipefail
N="${N:-2}"
PREFIX="${PREFIX:-fanout}"
CMD="${1:?usage: fanout.sh '<command>'}"
ids=()
cleanup() {
for id in "${ids[@]}"; do
sbx delete "$id" --yes >/dev/null 2>&1 && echo "deleted $id" >&2
done
}
trap cleanup EXIT
# wait_up ID: poll sbx info until the sandbox is up, fail on error states.
wait_up() {
local id="$1" state
for _ in $(seq 1 150); do
state="$(sbx info "$id" | jq -r '.sandbox.state')" || return 1
case "$state" in
ready|idle|running) return 0 ;;
error|cancelled) echo "$id ended in $state" >&2; return 1 ;;
esac
sleep 2
done
echo "$id: timed out in $state" >&2
return 1
}
# 1. Ask for all N at once; --no-wait returns after the "created" line.
for i in $(seq 1 "$N"); do
out="$(sbx new --type small --name "$PREFIX-$i" --no-wait 2>err.json)" || {
jq -r '"create failed: \(.code) \(.error)"' err.json >&2; exit 1; }
ids+=("$(jq -r 'select(.event == "created") | .id' <<<"$out")")
done
# 2. Wait for every one to come up.
for id in "${ids[@]}"; do wait_up "$id" || exit 1; done
# 3. Run the command everywhere; record stdout and the remote exit code.
for id in "${ids[@]}"; do
res="$(sbx exec "$id" --json --timeout 60 "$CMD")"; code=$?
jq -c --arg id "$id" --argjson code "$code" \
'{id: $id, exit: $code, stdout: (.stdout | rtrimstr("\n"))}' <<<"$res"
done
# 4. Show which sandboxes with our prefix are still up before cleanup runs.
sbx list | jq -r --arg p "$PREFIX-" \
'.sandboxes[] | select(.name | startswith($p)) | "\(.id) \(.state) \(.name)"' >&2The real run, with stderr shown:
$ N=2 PREFIX=jq-demo ./fanout.sh 'python3 -c "import platform; print(platform.python_version())"'
{"id":"sb_3tww4sm8fd","exit":0,"stdout":"3.12.3"}
{"id":"sb_xrnam6jvlw","exit":0,"stdout":"3.12.3"}
sb_xrnam6jvlw ready jq-demo-2
sb_3tww4sm8fd ready jq-demo-1
deleted sb_3tww4sm8fd
deleted sb_xrnam6jvlwThe whole run took 7 seconds. Afterward, sbx info on both ids exited 4 with not_found. Stdout holds only the result lines, so ./fanout.sh … > results.jsonl gives you a clean file. Your plan limits how many sandboxes run at once and how many start per window. When step 1 fails with limit_reached or rate_limited, the error codes guide says when to wait and when to stop.
FAQ#
Do I need --json when I pipe sbx?#
No. JSON turns on whenever stdout isn't a terminal. The one exception is sbx exec, which prints the command's plain output unless you pass --json explicitly.
Why does jq fail on the output of sbx new?#
sbx new prints JSON Lines, one event per line, not one document. Filter with jq 'select(.event == "ready")', which runs once per line. Avoid jq -s or a JSON parser that reads the whole stream at once unless you handle the array of events.
How do I tell a missing sandbox from other failures?#
Check the exit code and the error event. A missing sandbox exits 4 and its stderr line has "code":"not_found" and "status":404. Log requestId for anything you report to support.
Can I filter sbx list by label on the server?#
The CLI filters by state only, so filter labels with jq as shown above. The REST API filters on the server with GET /api/v1/sandboxes?labels=job=nightly; on prod it returned just our labeled sandbox.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.