# Script the sbx CLI With JSON Output and jq

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

Source: https://sandboxer.com/guides/script-sbx-with-json-output-and-jq. Updated 2026-10-27.

**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](/guides/sbx-cli-cheatsheet). For what each error `code` means, see [API error codes](/guides/sandboxer-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

1. **JSON turns on when stdout isn't a terminal.** `sbx list | jq` works without `--json`. Add `--json` to force it in a terminal.
2. **Results go to stdout, errors to stderr.** An error is one line, `{"event":"error",…}`, on stderr. Stdout stays empty, so `$(…)` captures nothing.
3. **`sbx exec` is the exception.** Piped, it still prints the command's plain stdout and stderr. Pass `--json` explicitly to get a result object. `--detach` always 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:

```bash
id="$(sbx new --type small --name jq-demo-1 | jq -r 'select(.event == "ready") | .id')"
```

The two lines it printed, trimmed:

```json
{"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](/guides/sbx-cli-cheatsheet) has the table. `sbx exec` passes the remote exit code through, in both modes:

```bash
# 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 $?
3
```

So `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:

```bash
sbx stop $id >/dev/null
until [ "$(sbx info $id | jq -r .sandbox.state)" = archived ]; do sleep 2; done
```

On prod, the state read `archiving` at 0 s and `archived` at 2 s.

Wait for a detached job and read its exit code:

```bash
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}'
```

```json
{"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:

```bash
sbx list | jq -r '.sandboxes[] | select(.name | startswith("jq-demo-")) | [.id, .state, .type, .name] | @tsv'
```

```text
sb_1xfcnnm3qr	ready	small	jq-demo-1
```

Labels are set through the REST API; the CLI doesn't have a labels command yet ([labels docs](/docs/api#labels)). Once set, every `sbx list` entry carries them:

```bash
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_1xfcnnm3qr
```

To act on each match, feed the ids to a `while read` loop:

```bash
sbx list | jq -r '.sandboxes[] | select(.labels.job == "nightly") | .id' |
  while read -r id; do sbx stop "$id" --wait >/dev/null && echo "stopped $id"; done
```

Only 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`.

```bash
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
}
```

```text
already gone (exit 4, req_KyJfdFXkiWbB3iPLleZX)
```

Errors from the API carry `code`, `status` and `requestId`. Errors the CLI raises itself have only `code`:

```json
{"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.

```bash
#!/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)"' >&2
```

The real run, with stderr shown:

```bash
$ 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_xrnam6jvlw
```

The 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](/guides/sandboxer-api-error-codes).

## 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

- [sbx CLI cheatsheet](/guides/sbx-cli-cheatsheet)
- [Sandboxer API error codes](/guides/sandboxer-api-error-codes)
- [Run a long job in a cloud sandbox](/guides/run-a-long-job-in-a-cloud-sandbox)
- [CLI reference](/docs/cli)
