Use --json and parse stdout when automating the Box CLI. For typed HTTP clients, use the Python SDK or TypeScript/JavaScript SDK.
Some commands emit one JSON object. Long-running commands emit JSON Lines: one JSON object per line.
Argument parsing errors can still be emitted by the CLI parser on stderr before Box’s JSON error handler runs.
For non-interactive environments, create a Box API key and pass it as BOX_API_KEY; see API Keys for setup and rotation.
For runtime secrets inside Boxes, use Dashboard > Secrets. Dashboard secrets are injected as environment variables and secret files; configure them before running setup scripts or prompts that need credentials.
See Secrets & Setup for the dashboard workflow and setup-script guidance.
Treat desktopUrl and viewerUrl as secrets. They can include short-lived desktop access tokens.
Authenticate
Authenticate once before the first command in a process or container:
box login "$BOX_API_KEY" --json
Output:
{
"event": "login_complete",
"data": {
"user": {
"login": "octocat",
"email": "octocat@example.com"
}
}
}
If you run box login --json without a key, the CLI starts the browser flow instead:
{
"event": "login_url",
"url": "https://ascii.dev/api/box/auth/github?state=...",
"pollToken": "opaque_poll_token",
"nextCommand": "box onboard --json",
"instruction": "Ask the user to open this URL and sign in with GitHub. After the browser says it is complete, run nextCommand."
}
Output contract
Commands that return a single JSON object:
| Command | Shape |
|---|
box config --json | ConfigResult |
box list --json | BoxListResult |
box info <id> --json | BoxInfoResult |
box limits --json | LimitsResult |
box stop <id> --json | BoxActionResult |
box resume <id> --json | BoxActionResult |
box fork <id> --json | BoxActionResult |
box interrupt <id> --json | BoxActionResult |
box delete <id> --json | DeleteResult |
box desktop <id> --json | DesktopResult |
box desktop <id> --vnc --json | VncDesktopResult |
Commands that emit JSONL:
| Command | Lines |
|---|
box new --json | created, zero or more state, then ready or error |
box prompt --provider codex <id> ... --json | queued, then chat lines until the prompt finishes |
box events <id> --json | zero or more chat lines and then exits |
box events <id> --follow --json | chat lines until interrupted |
Setup Scripts and Secrets
The usual setup flow is:
- Configure secrets in Dashboard > Secrets.
- Create a Box with
box new --json and wait for the ready event.
- Run a non-interactive command with
box ssh.
Secrets configured in the dashboard are available as process environment variables and configured secret files inside the Box. Prefer this over passing secret values in prompts, URLs, or command arguments.
box ssh <id> <command> prepares and registers the CLI-managed SSH key, then runs the command without opening an interactive shell. It streams stdin, stdout, and stderr, so a setup script can run without copying it into the Box first.
box_id="$(box new --json | jq -r 'select(.event == "ready") | .id')"
box ssh "$box_id" -- bash -s < ./setup.sh
box ssh "$box_id" "cd /home/user/ariana-ide-private && npm install"
box ssh "$box_id" -- bash -lc "cd /home/user/ariana-ide-private && npm test"
On Windows PowerShell:
$boxId = $null
box new --json | ForEach-Object {
$event = $_ | ConvertFrom-Json
if ($event.event -eq "ready") { $boxId = $event.id }
}
cmd /c "type setup.sh | box ssh $boxId -- bash -s"
box ssh $boxId "cd /home/user/ariana-ide-private && npm install"
PowerShell’s native pipeline can keep stdin open for native executables in some environments. For Windows automation, run setup through Node, Python, WSL, Git Bash, or cmd.exe.
If you need the Box to keep running for a long uninterrupted workflow, disable auto-stop when creating it:
box_id="$(box new --no-auto-stop --json | jq -r 'select(.event == "ready") | .id')"
See Long-Running Tasks.
Do not rely on runtime processes surviving resume or fork. After box resume or box fork, check app servers, workers, dev servers, tunnels, and desktop sessions, and run your setup or start command again if needed.
Errors
Most failed CLI commands in --json mode emit one final JSON line to stdout and exit non-zero. Commands that normally emit one JSON object still use this JSONL error line on runtime failure. Argument parsing failures, such as missing required flags or invalid numeric flag values, are emitted by the CLI parser on stderr and may not be JSON.
{
"event": "error",
"error": "Human-readable error message"
}
Backend API failures include code and status. The backend field is named error; the CLI exposes that backend code as code so error can stay human-readable.
{
"event": "error",
"error": "not found (404). check the Box ID with `box list` (or use `current` if you created one in this shell)",
"code": "not_found",
"status": 404
}
Local CLI failures do not include code or status because no Box API error response was received:
{
"event": "error",
"error": "invalid provider \"nope\". Use codex or claude."
}
Network failures also use the local shape:
{
"event": "error",
"error": "could not reach the Box API at http://127.0.0.1:9: error sending request for url (...)"
}
Type:
type CliErrorLine = {
event: "error";
error: string;
code?: string; // backend `error` code, present only for Box API failures
status?: number; // HTTP status, present only for Box API failures
}
The backend error object for Box CLI API paths is one of these shapes:
type BoxApiError =
| BasicBoxApiError
| BlockedBoxApiError
| RateLimitedBoxApiError
| ProviderNotConfiguredError
| BoxNotPromptableError
| StopFailedError
| SshKeyError;
type BasicBoxApiError = {
error:
| "unauthorized"
| "forbidden"
| "not_found"
| "method_not_allowed"
| "account_not_ready"
| "prompt_required"
| "provider_required"
| "invalid_name"
| "no_changes"
| "resume_failed"
| "fork_failed"
| "machine_not_running"
| "desktop_not_ready";
message?: string;
}
type BoxLimitFields = {
activeStates: ["provisioned", "cloning", "ready", "idle", "running"];
activeBoxes: number;
maxActiveBoxes: number;
maxCreationRequestsPerMinute: number;
maxCreationRequestsPerDay: number | null;
currentLimits: {
activeBoxes: number;
creationRatePerMinute: number;
creationRequestsPerDay: number | null;
};
standardLimits: {
activeBoxes: number;
creationRatePerMinute: number;
creationRequestsPerDay: number | null;
};
trialLimits: {
activeBoxes: number;
creationRatePerMinute: number;
creationRequestsPerDay: number | null;
};
}
type BoxBillingFields = {
hasSubscription: boolean;
hasPaymentHistory: boolean;
billingStatus: string;
subscriptionStatus: string;
subscriptionCancelAtPeriodEnd: boolean;
subscriptionTrialEndsAt: string | null;
subscriptionCurrentPeriodEnd: string | null;
checkoutRequired: boolean;
canStart: boolean;
startBlockedReason: "subscription_required" | "usage_depleted" | string | null;
blockedReason: "subscription_required" | "usage_depleted" | string | null;
accessTier: "trial" | "standard" | string;
creditBalanceSeconds: number;
subscriptionQuotaSeconds: number;
subscriptionRemainingSeconds: number;
packBalanceSeconds: number;
creditPurchasedSeconds: number;
creditUsedSeconds: number;
liveUsageSeconds: number;
creditSecondsPerDollar: number;
package: {
dollars: number;
seconds: number;
secondsPerDollar: number;
};
upgradeEffects: {
startTrial: {
canCreate: true;
activeBoxes: number;
creationRequestsPerDay: number;
note: string;
};
endTrialOrFirstPayment: {
activeBoxes: number;
creationRequestsPerDay: null;
note: string;
};
pack: {
seconds: number;
persistsAcrossMonths: true;
note: string;
};
};
contactMessage: string;
}
type BlockedBoxApiError = (BoxLimitFields & BoxBillingFields) & {
error: "billing_required" | "box_access_inactive" | "daily_limit_reached" | "limit_reached";
status: "blocked";
message: string;
billingUrl: string;
}
type RateLimitedBoxApiError = {
error: "rate_limited";
status: "blocked";
message: string;
maxCreationRequestsPerMinute: number;
}
type ProviderNotConfiguredError = {
error: "provider_not_configured";
provider: "codex" | "claude-code";
setupUrl: string;
message: string;
}
type BoxNotPromptableError = {
error: "box_not_promptable";
state: string;
message: string;
}
type StopFailedError = {
error: "stop_failed";
message: string;
box: Box | null;
}
type SshKeyError = {
success: false;
error: "unauthorized" | "not_found" | "machine_not_running" | "invalid_ssh_key";
message?: string;
}
The CLI uses message when present. If there is no message, it humanizes known backend codes such as unauthorized, not_found, account_not_ready, method_not_allowed, and rate_limited; otherwise it formats the backend code and HTTP status.
Treat CLI error output as sensitive. For backend errors that include billingUrl, setupUrl, or dashboardUrl, the CLI may append a dashboard URL with box_token to the human-readable error string.
Common backend codes used by the Box CLI paths:
| Code | HTTP status | Where it comes from |
|---|
unauthorized | 401 | Missing, invalid, expired, or revoked Box auth. |
billing_required | 402 | Creating, resuming, forking, or prompting is blocked by inactive plan or depleted usage. |
box_access_inactive | 402 | Reading events or accessing an existing box is blocked by inactive plan or depleted time. |
not_found | 404 | The Box ID or API key was not found for the current user. |
account_not_ready | 409 | GitHub/Ascii account setup is incomplete. |
resume_failed | 409 | Backend could not resume the box. |
fork_failed | 409 | Backend could not fork the box. |
provider_not_configured | 409 | Prompting was requested before the selected provider was configured. |
box_not_promptable | 409 | Prompting was requested while the box is in an unsupported state. |
prompt_required | 400 | Prompt request body had no non-empty prompt. |
provider_required | 400 | Prompt request body had no supported provider. |
machine_not_running | 400 | Desktop or SSH key setup was requested before the machine was running. |
desktop_not_ready | 400 | Desktop streaming metadata was not ready. |
invalid_ssh_key | 400 | SSH key registration received a missing or invalid public key. |
invalid_name | 400 | Box rename received an empty name. |
no_changes | 400 | Box update request did not include a supported change. |
stop_failed | 400 | Stop was requested while the box was in a state that cannot transition to stopping. |
rate_limited | 429 | Too many box creation requests in one minute. |
daily_limit_reached | 429 | Trial/pre-payment daily creation limit reached. |
limit_reached | 429 | Active box limit reached. |
method_not_allowed | 405 | API key management route received an unsupported method/action. |
Local CLI validation can fail before the backend is called. These errors have no stable code today. Examples from the current CLI source include invalid provider, invalid model, invalid reasoning effort, empty API key, missing local login, unsupported HTTP method, invalid local JSON from the API, SSH/SCP exit failures, and unreachable API host.
Common types
Box
type Box = {
id: string; // bx_...
name: string;
state: "init" | "provisioning" | "cloning" | "ready" | "idle" | "running" | "stopping" | "stopped" | "error" | string;
url: string | null; // public HTTPS URL for services
ip: string | null; // direct SSH IPv4 when available
desktopAvailable: boolean;
desktopUrl: string | null; // secret-bearing desktop stream URL
snapshotAvailable: boolean;
snapshotCompletedAt: string | null;
createdAt: string | null; // ISO timestamp
updatedAt: string | null; // ISO timestamp
archiveAfter: string | null; // ISO timestamp, null means no automatic stop
self?: boolean; // only added by `box list --json`
}
The CLI presents backend states for users: provisioned becomes ready, archiving becomes stopping, and archived becomes stopped.
BoxListResult
type BoxListResult = {
boxes: Box[];
pageInfo?: {
hasMore: boolean;
limit: number;
nextCursor: string | null;
};
}
Example:
{
"boxes": [
{
"id": "bx_8pqt6dup",
"name": "Box 2026-05-28 16:00",
"state": "idle",
"url": "https://example-box.on.ascii.dev",
"ip": "203.0.113.10",
"desktopAvailable": true,
"desktopUrl": "https://example-desktop.on.ascii.dev/stream.html?hostId=...&token=...",
"snapshotAvailable": true,
"snapshotCompletedAt": "2026-05-28T16:39:33.012Z",
"createdAt": "2026-05-28T16:00:33.143Z",
"updatedAt": "2026-05-28T16:00:39.535Z",
"archiveAfter": "2026-05-28T17:00:33.162Z",
"self": false
}
]
}
BoxInfoResult
type BoxInfoResult = {
box: Box;
}
BoxActionResult
Used by stop, resume, fork, and interrupt.
type BoxActionResult = {
id: string;
status: "stopping" | "resuming" | "forking" | string;
box: Box | null;
}
DeleteResult
type DeleteResult = {
id: string;
status: "deleted";
}
DesktopResult
type DesktopResult = {
success: true;
desktopUrl: string; // secret-bearing stream URL
viewerUrl: string; // secret-bearing dashboard viewer URL
ip: string | null;
}
VncDesktopResult
type VncDesktopResult = {
desktopUrl: string; // secret-bearing noVNC URL
mode: "vnc";
}
ConfigResult
type ConfigResult = {
path: string;
apiUrl: string | null;
channel: string | null;
loggedIn: boolean;
}
LimitsResult
type LimitsResult = {
activeBoxes: number;
maxActiveBoxes: number;
maxCreationRequestsPerMinute: number;
maxCreationRequestsPerDay: number | null;
activeStates: string[];
hasSubscription: boolean;
hasPaymentHistory: boolean;
billingStatus: string;
subscriptionStatus: string;
subscriptionCancelAtPeriodEnd: boolean;
subscriptionTrialEndsAt: string | null;
subscriptionCurrentPeriodEnd: string | null;
checkoutRequired: boolean;
canStart: boolean;
startBlockedReason: "subscription_required" | "usage_depleted" | string | null;
blockedReason: "subscription_required" | "usage_depleted" | string | null;
accessTier: "trial" | "standard" | string;
creditBalanceSeconds: number;
subscriptionQuotaSeconds: number;
subscriptionRemainingSeconds: number;
packBalanceSeconds: number;
creditPurchasedSeconds: number;
creditUsedSeconds: number;
liveUsageSeconds: number;
creditSecondsPerDollar: number;
package: {
dollars: number;
seconds: number;
secondsPerDollar: number;
};
currentLimits: {
activeBoxes: number;
creationRatePerMinute: number;
creationRequestsPerDay: number | null;
};
standardLimits: {
activeBoxes: number;
creationRatePerMinute: number;
creationRequestsPerDay: number | null;
};
trialLimits: {
activeBoxes: number;
creationRatePerMinute: number;
creationRequestsPerDay: number | null;
};
upgradeEffects: Record<string, unknown>;
contactMessage: string;
}
Create a box
box new --json emits JSONL, not a single JSON object.
{"event":"created","id":"bx_8pqt6dup","ttlSeconds":3600}
{"event":"state","id":"bx_8pqt6dup","state":"provisioning"}
{"event":"state","id":"bx_8pqt6dup","state":"ready"}
{"event":"ready","id":"bx_8pqt6dup","state":"ready","url":"https://example-box.on.ascii.dev","ip":"203.0.113.10","desktopUrl":"https://example-desktop.on.ascii.dev/stream.html?hostId=...&token=...","archiveAfter":"2026-05-28T17:00:33.162Z","commands":{"ssh":"box ssh bx_8pqt6dup","forward":"box forward bx_8pqt6dup --remote 3000 --local 3000"}}
Schema:
type NewCreatedLine = {
event: "created";
id: string;
ttlSeconds: number | null;
}
type NewStateLine = {
event: "state";
id: string;
state: Box["state"];
}
type NewReadyLine = {
event: "ready";
id: string;
state: Box["state"];
url: string | null;
ip: string | null;
desktopUrl: string | null;
archiveAfter: string | null;
commands: {
ssh: string;
forward: string;
};
}
Prompt and events
box prompt --provider codex <id> "..." --json first emits a queued line:
{
"event": "queued",
"data": {
"id": "bx_chm52eme",
"promptId": "09690a68-5aee-4b35-a2e7-eb1b64e3e104",
"status": "queued",
"provider": "codex",
"model": null,
"reasoningEffort": null
}
}
Then it emits chat lines until the prompt finishes. box events --json emits the same chat line shape for persisted events. It may return fewer lines than box prompt --json; in a live check, box prompt emitted queued, queued-prompt, running-prompt, finished-prompt, and response lines, while box events later returned the persisted finished-prompt and response lines.
{
"event": "chat",
"final": true,
"data": {
"id": "a75c1426-23bf-4eae-9131-c957046a2af5",
"taskId": "09690a68-5aee-4b35-a2e7-eb1b64e3e104",
"type": "response",
"timestamp": 1779986896114,
"data": {
"content": "box-jsonl-audit-ok",
"is_reverted": false,
"model": "gpt-5.4"
}
}
}
Schema:
type QueuedLine = {
event: "queued";
data: {
id: string;
promptId: string;
status: "queued";
provider: "codex" | "claude-code" | string;
model?: string | null;
reasoningEffort?: string | null;
};
}
type ChatLine = {
event: "chat";
final: boolean; // false for streaming partial response events
data: {
id?: string;
type: "prompt" | "response" | "usage_limit" | "git_checkpoint" | string;
timestamp?: number; // Unix epoch milliseconds
taskId?: string;
data: Record<string, unknown>;
};
}
response chat events can include tool data in data.tools; keep the full object if you need exact agent traces.
Bash JSONL parser
set -euo pipefail
box login "$BOX_API_KEY" --json >/dev/null
box_id=""
while IFS= read -r line; do
event="$(printf '%s\n' "$line" | jq -r '.event')"
case "$event" in
created)
box_id="$(printf '%s\n' "$line" | jq -r '.id')"
;;
ready)
ip="$(printf '%s\n' "$line" | jq -r '.ip // empty')"
echo "ready: $box_id $ip"
;;
error)
printf '%s\n' "$line" >&2
exit 1
;;
esac
done < <(box new --ttl 3600 --json)
box info "$box_id" --json
box stop "$box_id" --json
Node.js
Use a single-object helper for commands like info, and a JSONL helper for commands like new.
import { spawnSync, spawn } from "node:child_process";
import { createInterface } from "node:readline";
class BoxCliError extends Error {
constructor(line, fallback) {
super(line?.error || fallback);
this.name = "BoxCliError";
this.code = line?.code;
this.status = line?.status;
this.line = line;
}
}
function parseErrorLine(output) {
const lastLine = output.trim().split(/\r?\n/).filter(Boolean).at(-1);
if (!lastLine) return null;
try {
const parsed = JSON.parse(lastLine);
return parsed.event === "error" ? parsed : null;
} catch {
return null;
}
}
function boxJson(args) {
const result = spawnSync("box", [...args, "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new BoxCliError(
parseErrorLine(result.stdout),
result.stderr || `box ${args.join(" ")} failed`
);
}
return JSON.parse(result.stdout);
}
async function* boxJsonLines(args) {
const child = spawn("box", [...args, "--json"], {
stdio: ["ignore", "pipe", "pipe"],
});
const rl = createInterface({ input: child.stdout });
let lastError = null;
for await (const line of rl) {
if (!line.trim()) continue;
const parsed = JSON.parse(line);
if (parsed.event === "error") lastError = parsed;
yield parsed;
}
const exitCode = await new Promise((resolve) => child.on("close", resolve));
if (exitCode !== 0) {
throw new BoxCliError(lastError, `box ${args.join(" ")} exited with ${exitCode}`);
}
}
boxJson(["login", process.env.BOX_API_KEY]);
let boxId;
for await (const line of boxJsonLines(["new", "--ttl", "3600"])) {
if (line.event === "created") boxId = line.id;
if (line.event === "ready") console.log("ready", line);
}
console.log(boxJson(["info", boxId]));
boxJson(["stop", boxId]);
Python
import json
import os
import subprocess
class BoxCliError(Exception):
def __init__(self, line, fallback):
super().__init__((line or {}).get("error") or fallback)
self.line = line
self.code = (line or {}).get("code")
self.status = (line or {}).get("status")
def parse_error_line(output):
lines = [line for line in output.splitlines() if line.strip()]
if not lines:
return None
try:
parsed = json.loads(lines[-1])
except json.JSONDecodeError:
return None
return parsed if parsed.get("event") == "error" else None
def box_json(*args):
result = subprocess.run(
["box", *args, "--json"],
text=True,
capture_output=True,
)
if result.returncode != 0:
raise BoxCliError(
parse_error_line(result.stdout),
result.stderr or f"box {' '.join(args)} failed",
)
return json.loads(result.stdout)
def box_json_lines(*args):
process = subprocess.Popen(
["box", *args, "--json"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert process.stdout is not None
last_error = None
for line in process.stdout:
if line.strip():
parsed = json.loads(line)
if parsed.get("event") == "error":
last_error = parsed
yield parsed
exit_code = process.wait()
if exit_code != 0:
stderr = process.stderr.read() if process.stderr else ""
raise BoxCliError(last_error, stderr or f"box {' '.join(args)} exited with {exit_code}")
box_json("login", os.environ["BOX_API_KEY"])
box_id = None
for event in box_json_lines("new", "--ttl", "3600"):
if event["event"] == "created":
box_id = event["id"]
if event["event"] == "ready":
print("ready", event)
print(box_json("info", box_id))
box_json("stop", box_id)
Direct SSH
The CLI manages the SSH key. For an interactive shell, prefer:
box ssh "$box_id"
box scp ./local-file.txt "$box_id:/home/user/local-file.txt"
For box scp, remote paths are passed through to OpenSSH scp after the <box-id>: prefix. Absolute paths such as /home/user/setup.sh are the safest. Relative remote paths are resolved from the SSH user’s home directory (/home/user on hosted Boxes). Avoid relying on ~ in automation because expansion can vary by local shell and scp mode.
For non-interactive commands, pass the command after the box ID:
box ssh "$box_id" "cd /home/user/ariana-ide-private && npm test"
box ssh "$box_id" -- bash -lc "cd /home/user/ariana-ide-private && npm test"
box ssh "$box_id" -- bash -s < ./setup.sh
For direct SSH, inspect the Box for its IP and use the CLI-managed key:
ip="$(box info "$box_id" --json | jq -r '.box.ip // empty')"
ssh -i ~/.ssh/ascii_box_ed25519 "user@$ip"