Skip to main content
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:
CommandShape
box config --jsonConfigResult
box list --jsonBoxListResult
box info <id> --jsonBoxInfoResult
box limits --jsonLimitsResult
box stop <id> --jsonBoxActionResult
box resume <id> --jsonBoxActionResult
box fork <id> --jsonBoxActionResult
box interrupt <id> --jsonBoxActionResult
box delete <id> --jsonDeleteResult
box desktop <id> --jsonDesktopResult
box desktop <id> --vnc --jsonVncDesktopResult
Commands that emit JSONL:
CommandLines
box new --jsoncreated, zero or more state, then ready or error
box prompt --provider codex <id> ... --jsonqueued, then chat lines until the prompt finishes
box events <id> --jsonzero or more chat lines and then exits
box events <id> --follow --jsonchat lines until interrupted

Setup Scripts and Secrets

The usual setup flow is:
  1. Configure secrets in Dashboard > Secrets.
  2. Create a Box with box new --json and wait for the ready event.
  3. 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:
CodeHTTP statusWhere it comes from
unauthorized401Missing, invalid, expired, or revoked Box auth.
billing_required402Creating, resuming, forking, or prompting is blocked by inactive plan or depleted usage.
box_access_inactive402Reading events or accessing an existing box is blocked by inactive plan or depleted time.
not_found404The Box ID or API key was not found for the current user.
account_not_ready409GitHub/Ascii account setup is incomplete.
resume_failed409Backend could not resume the box.
fork_failed409Backend could not fork the box.
provider_not_configured409Prompting was requested before the selected provider was configured.
box_not_promptable409Prompting was requested while the box is in an unsupported state.
prompt_required400Prompt request body had no non-empty prompt.
provider_required400Prompt request body had no supported provider.
machine_not_running400Desktop or SSH key setup was requested before the machine was running.
desktop_not_ready400Desktop streaming metadata was not ready.
invalid_ssh_key400SSH key registration received a missing or invalid public key.
invalid_name400Box rename received an empty name.
no_changes400Box update request did not include a supported change.
stop_failed400Stop was requested while the box was in a state that cannot transition to stopping.
rate_limited429Too many box creation requests in one minute.
daily_limit_reached429Trial/pre-payment daily creation limit reached.
limit_reached429Active box limit reached.
method_not_allowed405API 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"