> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boat.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Use in Code

> Call Boat from scripts and applications by wrapping the CLI with --json.

Use `--json` and parse stdout when automating the Boat CLI. For typed HTTP clients, use the [Python SDK](/box/sdks/python) or [TypeScript/JavaScript SDK](/box/sdks/typescript).

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 Boat's JSON error handler runs.

For non-interactive environments, create a Boat API key with `box api-key create` and pass it as `BOX_API_KEY`; see [API Keys](/box/api-keys) for setup.

For runtime secrets inside Boxes, use [Dashboard > Environment](https://box.ascii.dev/box/dashboard?tab=environment). Dashboard secrets are injected as environment variables and secret files; configure them before running setup scripts or prompts that need credentials.

See [Environments](/box/environments) for the dashboard workflow and setup-script guidance.

<Warning>
  Treat `desktopUrl` and `viewerUrl` as secrets. They can include short-lived desktop access tokens.
</Warning>

## Authenticate

Authenticate once before the first command in a process or container:

```bash theme={null}
box login --key-stdin --json <<< "$BOX_API_KEY"
```

`--key-stdin` reads the token from standard input, so it never reaches the process arguments, where any other user on the host can read it with `ps` or `/proc/<pid>/cmdline` while the command runs. Passing the token positionally (`box login "$BOX_API_KEY"`) still works and is still supported, but it discloses the key on a shared or multi-tenant host. `--key-stdin` needs a box CLI newer than 0.1.211; check yours with `box --version`. Older binaries only accept the positional form.

Output:

```json theme={null}
{
  "event": "login_complete",
  "data": {
    "user": {
      "login": "octocat",
      "email": "octocat@example.com"
    }
  }
}
```

To provision the key itself per project, use a browser-authenticated session. An admin-scoped API key can also create a child key when every child action, resource, and expiry stays within the parent grant:

```bash theme={null}
BOX_API_KEY="$(box api-key create my-project --json | jq -r '.secret')"
```

The secret is only ever present in this one response; `box api-key list --json` returns metadata without secrets.

If you run `box login --json` with no key at all, the CLI starts the browser flow instead:

```json theme={null}
{
  "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 usage <id> --json`            | `BoxUsageResult`                                               |
| `box stop <id> --json`             | `BoxActionResult`                                              |
| `box resume <id> --json`           | `BoxActionResult`                                              |
| `box fork <id> --json`             | `BoxActionResult`                                              |
| `box steer <id> <message> --json`  | `SteerResult` (`conversationId`, `promptId`, `native`, `mode`) |
| `box interrupt <id> --json`        | `BoxActionResult`                                              |
| `box host <id> <port> --json`      | `HostResult`                                                   |
| `box desktop <id> --json`          | `DesktopResult`                                                |
| `box desktop <id> --vnc --json`    | `VncDesktopResult`                                             |
| `box api-key create <name> --json` | `ApiKeySecretResult`                                           |
| `box api-key rotate <id> --json`   | `ApiKeySecretResult`                                           |
| `box api-key list --json`          | `ApiKeyListResult`                                             |
| `box api-key usage <id> --json`    | `ApiKeyUsageResult`                                            |
| `box api-key revoke <id> --json`   | `{ ok: true }`                                                 |

Commands that emit JSONL:

| Command                           | Lines                                                                                                                                                                                                                  |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `box new --json`                  | `created`, zero or more `state`, then `ready` or `error`                                                                                                                                                               |
| `box prompt [id] ... --json`      | `queued` (carries `conversationId`), 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                                                                                                                                                                                         |
| `box conversations <id> --json`   | one `conversation` line per conversation, newest first: `id`, `createdAt`, `lastPromptAt`, `prompts`, `running`, `lastHarness`, `lastModel`, `lastPromptPreview`, `current` (the box's), `shellCurrent` (this shell's) |

`box prompt` takes an optional `--provider` (`codex`, `claude`, `pi`, `opencode`, `prime`, `kimi`), `--model`, and `--reasoning-effort`; `--attach <path>` (repeatable) to attach files/images; and the [conversation](/box/integrated-agents) flags `--new`, `--resume <id>`, and (on `events`/`interrupt`) `--convo <id>`. A single box runs many conversations in parallel; every `queued` and `chat` line carries a `conversationId`, and `box conversations --json` lists the ids to resume.

## Setup Scripts and Secrets

The usual setup flow is:

1. Configure secrets in [Dashboard > Environment](https://box.ascii.dev/box/dashboard?tab=environment).
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.

```bash theme={null}
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:

```powershell theme={null}
$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:

```bash theme={null}
box_id="$(box new --no-auto-stop --json | jq -r 'select(.event == "ready") | .id')"
```

See [Long-Running Tasks](/box/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.

## Prompt and events

`box prompt --provider codex <id> "..." --json` first emits a queued line:

```json theme={null}
{
  "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.

```json theme={null}
{
  "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:

```ts theme={null}
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" | 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

```bash theme={null}
set -euo pipefail

box login --key-stdin --json <<< "$BOX_API_KEY" >/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`.

```js theme={null}
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, { stdin } = {}) {
  const result = spawnSync("box", [...args, "--json"], {
    encoding: "utf8",
    input: stdin,
    stdio: ["pipe", "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", "--key-stdin"], { stdin: 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

```python theme={null}
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, stdin=None):
    result = subprocess.run(
        ["box", *args, "--json"],
        input=stdin,
        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", "--key-stdin", stdin=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:

```bash theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
ip="$(box info "$box_id" --json | jq -r '.box.ip // empty')"
ssh -i ~/.ssh/ascii_box_ed25519 "user@$ip"
```

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

### The error line

```json theme={null}
{
  "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.

```json theme={null}
{
  "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 Boat API error response was received:

```json theme={null}
{
  "event": "error",
  "error": "invalid provider \"nope\". Use codex, claude, pi, opencode, prime or kimi."
}
```

Network failures also use the local shape:

```json theme={null}
{
  "event": "error",
  "error": "could not reach the Box API at http://127.0.0.1:9: error sending request for url (...)"
}
```

Type:

```ts theme={null}
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
}
```

### Backend error shapes

The backend error object for Boat CLI API paths is one of these shapes:

```ts theme={null}
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"
    | "box_starting"
    | "invalid_timeout"
    | "invalid_path"
    | "invalid_setup_script";
  message?: string;
}

type BoxLimitFields = {
  activeStates: ["provisioned", "cloning", "ready", "idle", "running"];
  activeBoxes: number;
  maxActiveBoxes: number;
  maxCreationRequestsPerMinute: number;
  maxCreationRequestsPerDay: number | null;
  startLimits: { perMinute: number; perHour: number; perDay: number } | null;
  starts: {
    unlimited: boolean;
    minute: { limit: number; used: number; remaining: number } | null;
    hour: { limit: number; used: number; remaining: number } | null;
    day: { limit: number; used: number; remaining: 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;
  accountPlan: "user" | "service" | string;
  plan: "service" | string | null;
  planName: string | null;
  serviceAccount: boolean;
  unlimited: 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" | "service" | string;
  creditBalanceSeconds: number;
  creditBalanceHours: number | null;
  subscriptionQuotaSeconds: number;
  subscriptionRemainingSeconds: number;
  packBalanceSeconds: number;
  packBalanceHours: number;
  packBalanceDollars: 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" | "limit_reached";
  status: "blocked";
  message: string;
  billingUrl: string;
}

type RateLimitedBoxApiError = {
  error: "rate_limited";
  status: "blocked";
  message: string;
  // Which start window was hit, and its cap.
  window: "minute" | "hour" | "day";
  limit: number;
  // The full ladder for the account: per-minute, per-hour, per-day.
  startLimits: { perMinute: number; perHour: number; perDay: number };
  starts: {
    unlimited: boolean;
    minute: { limit: number; used: number; remaining: number } | null;
    hour: { limit: number; used: number; remaining: number } | null;
    day: { limit: number; used: number; remaining: number } | null;
  };
  maxCreationRequestsPerMinute: number;
}

type ProviderNotConfiguredError = {
  error: "provider_not_configured";
  provider: "codex" | "claude-code" | "pi" | "opencode" | "prime-agent" | "kimi";
  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.

For backend errors that include `billingUrl`, `setupUrl`, or `dashboardUrl`, the CLI appends that URL to the human-readable `error` string. Those URLs carry no credential: opening one lands on a signed-out dashboard.

### Backend error codes

Common backend codes used by the Boat CLI paths:

| Code                              | HTTP status | Where it comes from                                                                                                                                      |
| --------------------------------- | ----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unauthorized`                    |         401 | Missing, invalid, expired, or revoked Boat 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.                                                                                                                |
| `box_starting`                    |         409 | A command was sent while the box was still provisioning. Retryable: wait for the box to reach `ready`, then send it again.                               |
| `resume_failed`                   |         409 | Backend could not resume the box.                                                                                                                        |
| `box_restoring`                   |         409 | SSH/desktop access requested in the first seconds of a resume, before the filesystem is being served. Retry after a moment.                              |
| `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 | Command, 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_timeout`                 |         400 | Command `timeoutSeconds` was not an integer in 1–600.                                                                                                    |
| `invalid_path`                    |         400 | File read/write path did not resolve under `/home/user` or `/tmp`.                                                                                       |
| `invalid_setup_script`            |         400 | Box create received a `setupScript` that was not a string or exceeded 64KB.                                                                              |
| `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.                                                                      |
| `trial_auto_stop_required`        |         400 | Auto-stop was disabled, or a TTL above 2 hours requested, on a trial account.                                                                            |
| `trial_machine_class_not_allowed` |         403 | `large` was requested on a trial account.                                                                                                                |
| `team_member_cap_reached`         |         402 | The organization owner's per-member usage cap for this billing window is spent.                                                                          |
| `member_limit_reached`            |         429 | The organization owner's per-member concurrent-box cap is reached.                                                                                       |
| `org_suspended`                   |         403 | The organization wallet this box would bill is suspended.                                                                                                |
| `rate_limited`                    |         429 | A machine-start limit was hit. `window` (`minute`, `hour` or `day`) and `limit` name which cap; `starts` carries remaining counts for all three windows. |
| `limit_reached`                   |         429 | Concurrent box limit reached.                                                                                                                            |
| `method_not_allowed`              |         405 | API key management route received an unsupported method/action.                                                                                          |

### Local CLI errors

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

```ts theme={null}
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 IPv6 or 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

```ts theme={null}
type BoxListResult = {
  boxes: Box[];
  pageInfo?: {
    hasMore: boolean;
    limit: number;
    nextCursor: string | null;
  };
}
```

`box list --json` defaults to up/running boxes, the same as `box list --filter r --json`. Use `box list --filter s --json` for stopped boxes, combine groups like `--filter sr`, or use `--all --json` to include every state.

Example:

```json theme={null}
{
  "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

```ts theme={null}
type BoxInfoResult = {
  box: Box;
}
```

### BoxActionResult

Used by `stop`, `resume`, `fork`, and `interrupt`.

```ts theme={null}
type BoxActionResult = {
  id: string;
  status: "stopping" | "resuming" | "forking" | string;
  box: Box | null;
}
```

### DesktopResult

```ts theme={null}
type DesktopResult = {
  success: true;
  desktopUrl: string;        // secret-bearing stream URL
  viewerUrl: string;         // secret-bearing dashboard viewer URL
  ip: string | null;
}
```

### HostResult

```ts theme={null}
type HostResult = {
  boxId: string;
  port: number;
  url: string;                // HTTPS URL for the exposed port
  access: "private" | "public";
  isProtected: boolean;       // true when url includes or requires _token
  title: string | null;
}
```

### VncDesktopResult

```ts theme={null}
type VncDesktopResult = {
  desktopUrl: string;        // secret-bearing noVNC URL
  mode: "vnc";
}
```

### ApiKeySecretResult

Returned by `box api-key create --json` and `box api-key rotate --json`. The `secret` exists only in this response and can never be retrieved again.

```ts theme={null}
type ApiKeyMetadata = {
  id: string;
  name: string;
  keyPrefix: string;           // first 12 characters
  keyLastFour: string;
  sandboxId: string | null;    // set on platform-managed per-box keys
  createdAt: string;           // ISO timestamp
  lastUsedAt: string | null;   // ISO timestamp
  usage: { requests: number; windowDays: 30 };
  resources: { total: number; boxes: number; agents: number };
}

type ApiKeySecretResult = {
  apiKey: ApiKeyMetadata;
  secret: string;              // full key, shown once
}
```

### ApiKeyListResult

`box api-key list --json` returns keys you created; add `--all` to include platform-managed per-box machine keys (`sandboxId` set).

```ts theme={null}
type ApiKeyListResult = {
  apiKeys: ApiKeyMetadata[];
}
```

`box api-key usage <id> --json` returns the same totals. Add `--verbose` to keep `createdResources`. The command still works after the key is revoked.

```ts theme={null}
type ApiKeyCreatedResource = {
  kind: "box" | "agent";
  id: string;
  name: string;
  state: string;
  createdAt: string | null;
}

type ApiKeyUsageResult = ApiKeyMetadata & {
  createdResources?: ApiKeyCreatedResource[];
}
```

### BoxUsageResult

`box usage <id> --json` returns the API payload unchanged. `seconds` already includes the box type's multiplier; `dollars` is `seconds / secondsPerDollar`. See [Per-box usage](/box/billing#per-box-usage).

```ts theme={null}
type BoxUsageResult = {
  boxId: string;
  boxType: "small" | "default" | "large" | "xlarge";
  billingMultiplier: number;
  since: string;             // ISO timestamp; box creation unless --since was passed
  until: string;             // ISO timestamp; now unless --until was passed
  seconds: number;           // billable machine-seconds inside the window
  dollars: number;           // seconds at list price
  secondsPerDollar: number;  // 100000
  running: boolean;          // the meter is still moving (false while a refused stop holds it paused)
}
```

### ConfigResult

```ts theme={null}
type ConfigResult = {
  path: string;
  apiUrl: string | null;
  channel: string | null;
  loggedIn: boolean;
}
```

### LimitsResult

```ts theme={null}
type LimitsResult = {
  activeBoxes: number;
  maxActiveBoxes: number;
  maxCreationRequestsPerMinute: number;
  maxCreationRequestsPerDay: number | null;
  startLimits: { perMinute: number; perHour: number; perDay: number } | null;
  starts: {
    unlimited: boolean;
    minute: { limit: number; used: number; remaining: number } | null;
    hour: { limit: number; used: number; remaining: number } | null;
    day: { limit: number; used: number; remaining: number } | null;
  };
  activeStates: string[];
  hasSubscription: boolean;
  hasPaymentHistory: boolean;
  accountPlan: "user" | "service" | string;
  plan: "service" | string | null;
  planName: string | null;
  serviceAccount: boolean;
  unlimited: 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" | "service" | string;
  creditBalanceSeconds: number;
  creditBalanceHours: number | null;
  subscriptionQuotaSeconds: number;
  subscriptionRemainingSeconds: number;
  packBalanceSeconds: number;
  packBalanceHours: number;
  packBalanceDollars: 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.

```jsonl theme={null}
{"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:

```ts theme={null}
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:

```json theme={null}
{
  "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.

```json theme={null}
{
  "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:

```ts theme={null}
type QueuedLine = {
  event: "queued";
  data: {
    id: string;
    promptId: string;
    conversationId: string;  // the conversation this prompt runs in
    status: "queued";
    provider: "codex" | "claude-code" | "pi" | "opencode" | "prime-agent" | "kimi" | 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" | string;
    timestamp?: number;   // Unix epoch milliseconds
    taskId?: string;
    conversationId?: string | null;  // which conversation this event belongs to
    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

```bash theme={null}
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`.

```js theme={null}
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

```python theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
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:

```bash theme={null}
ip="$(box info "$box_id" --json | jq -r '.box.ip // empty')"
ssh -i ~/.ssh/ascii_box_ed25519 "user@$ip"
```

## Related

* [API Keys](/box/api-keys)
* [Environments](/box/environments)
* [Long-Running Tasks](/box/long-running-tasks)
* [SSH Access](/box/ssh-access)
