> ## 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](/sdks/python) or [TypeScript/JavaScript SDK](/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 `boat api-key create` and pass it as `BOAT_API_KEY`; see [API Keys](/api-keys) for setup.

For runtime secrets inside sandboxes, use [Dashboard > Environment](https://boat.dev/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](/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}
boat login --key-stdin --json <<< "$BOAT_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 (`boat login "$BOAT_API_KEY"`) still works and is still supported, but it discloses the key on a shared or multi-tenant host. `--key-stdin` needs a Boat CLI newer than 0.1.211; check yours with `boat --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}
BOAT_API_KEY="$(boat api-key create my-project --json | jq -r '.secret')"
```

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

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

```json theme={null}
{
  "event": "login_url",
  "url": "https://boat.dev/api/boat/auth/github?state=...",
  "pollToken": "opaque_poll_token",
  "nextCommand": "boat 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                                                          |
| ----------------------------------- | -------------------------------------------------------------- |
| `sandbox config --json`             | `ConfigResult`                                                 |
| `boat list --json`                  | `SandboxListResult`                                            |
| `boat info <id> --json`             | `SandboxInfoResult`                                            |
| `boat limits --json`                | `LimitsResult`                                                 |
| `boat usage <id> --json`            | `SandboxUsageResult`                                           |
| `boat stop <id> --json`             | `SandboxActionResult`                                          |
| `boat resume <id> --json`           | `SandboxActionResult`                                          |
| `boat fork <id> --json`             | `SandboxActionResult`                                          |
| `boat steer <id> <message> --json`  | `SteerResult` (`conversationId`, `promptId`, `native`, `mode`) |
| `boat interrupt <id> --json`        | `SandboxActionResult`                                          |
| `boat host <id> <port> --json`      | `HostResult`                                                   |
| `boat desktop <id> --json`          | `DesktopResult`                                                |
| `boat desktop <id> --vnc --json`    | `VncDesktopResult`                                             |
| `boat api-key create <name> --json` | `ApiKeySecretResult`                                           |
| `boat api-key rotate <id> --json`   | `ApiKeySecretResult`                                           |
| `boat api-key list --json`          | `ApiKeyListResult`                                             |
| `boat api-key usage <id> --json`    | `ApiKeyUsageResult`                                            |
| `boat api-key revoke <id> --json`   | `{ ok: true }`                                                 |

Commands that emit JSONL:

| Command                            | Lines                                                                                                                                                                                                                      |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `boat new --json`                  | `created`, zero or more `state`, then `ready` or `error`                                                                                                                                                                   |
| `boat prompt [id] ... --json`      | `queued` (carries `conversationId`), then `chat` lines until the prompt finishes                                                                                                                                           |
| `boat events <id> --json`          | zero or more `chat` lines and then exits                                                                                                                                                                                   |
| `boat events <id> --follow --json` | `chat` lines until interrupted                                                                                                                                                                                             |
| `boat conversations <id> --json`   | one `conversation` line per conversation, newest first: `id`, `createdAt`, `lastPromptAt`, `prompts`, `running`, `lastHarness`, `lastModel`, `lastPromptPreview`, `current` (the sandbox's), `shellCurrent` (this shell's) |

`boat 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](/integrated-agents) flags `--new`, `--resume <id>`, and (on `events`/`interrupt`) `--convo <id>`. A single sandbox runs many conversations in parallel; every `queued` and `chat` line carries a `conversationId`, and `boat conversations --json` lists the ids to resume.

## Setup Scripts and Secrets

The usual setup flow is:

1. Configure secrets in [Dashboard > Environment](https://boat.dev/dashboard?tab=environment).
2. Create a sandbox with `boat new --json` and wait for the `ready` event.
3. Run a non-interactive command with `boat ssh`.

Secrets configured in the dashboard are available as process environment variables and configured secret files inside the sandbox. Prefer this over passing secret values in prompts, URLs, or command arguments.

`boat 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 sandbox first.

```bash theme={null}
sandbox_id="$(boat new --json | jq -r 'select(.event == "ready") | .id')"
boat ssh "$sandbox_id" -- bash -s < ./setup.sh
boat ssh "$sandbox_id" "cd /home/user/ariana-ide-private && npm install"
boat ssh "$sandbox_id" -- bash -lc "cd /home/user/ariana-ide-private && npm test"
```

On Windows PowerShell:

```powershell theme={null}
$sandboxId = $null
boat new --json | ForEach-Object {
  $event = $_ | ConvertFrom-Json
  if ($event.event -eq "ready") { $sandboxId = $event.id }
}
cmd /c "type setup.sh | boat ssh $sandboxId -- bash -s"
boat ssh $sandboxId "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 sandbox to keep running for a long uninterrupted workflow, disable auto-stop when creating it:

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

See [Long-Running Tasks](/long-running-tasks).

Do not rely on runtime processes surviving resume or fork. After `boat resume` or `boat fork`, check app servers, workers, dev servers, tunnels, and desktop sessions, and run your setup or start command again if needed.

## Prompt and events

`boat 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. `boat events --json` emits the same chat line shape for persisted events. It may return fewer lines than `boat prompt --json`; in a live check, `boat prompt` emitted queued, queued-prompt, running-prompt, finished-prompt, and response lines, while `boat 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": "sandbox-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

boat login --key-stdin --json <<< "$BOAT_API_KEY" >/dev/null

sandbox_id=""
while IFS= read -r line; do
  event="$(printf '%s\n' "$line" | jq -r '.event')"
  case "$event" in
    created)
      sandbox_id="$(printf '%s\n' "$line" | jq -r '.id')"
      ;;
    ready)
      ip="$(printf '%s\n' "$line" | jq -r '.ip // empty')"
      echo "ready: $sandbox_id $ip"
      ;;
    error)
      printf '%s\n' "$line" >&2
      exit 1
      ;;
  esac
done < <(boat new --ttl 3600 --json)

boat info "$sandbox_id" --json
boat stop "$sandbox_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 SandboxCliError extends Error {
  constructor(line, fallback) {
    super(line?.error || fallback);
    this.name = "SandboxCliError";
    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 sandboxJson(args, { stdin } = {}) {
  const result = spawnSync("boat", [...args, "--json"], {
    encoding: "utf8",
    input: stdin,
    stdio: ["pipe", "pipe", "pipe"],
  });

  if (result.status !== 0) {
    throw new SandboxCliError(
      parseErrorLine(result.stdout),
      result.stderr || `sandbox ${args.join(" ")} failed`
    );
  }

  return JSON.parse(result.stdout);
}

async function* sandboxJsonLines(args) {
  const child = spawn("boat", [...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 SandboxCliError(lastError, `sandbox ${args.join(" ")} exited with ${exitCode}`);
  }
}

sandboxJson(["login", "--key-stdin"], { stdin: process.env.BOAT_API_KEY });

let sandboxId;
for await (const line of sandboxJsonLines(["new", "--ttl", "3600"])) {
  if (line.event === "created") sandboxId = line.id;
  if (line.event === "ready") console.log("ready", line);
}

console.log(sandboxJson(["info", sandboxId]));
sandboxJson(["stop", sandboxId]);
```

## Python

```python theme={null}
import json
import os
import subprocess


class SandboxCliError(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 sandbox_json(*args, stdin=None):
    result = subprocess.run(
        ["boat", *args, "--json"],
        input=stdin,
        text=True,
        capture_output=True,
    )
    if result.returncode != 0:
        raise SandboxCliError(
            parse_error_line(result.stdout),
            result.stderr or f"sandbox {' '.join(args)} failed",
        )
    return json.loads(result.stdout)


def sandbox_json_lines(*args):
    process = subprocess.Popen(
        ["boat", *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 SandboxCliError(last_error, stderr or f"sandbox {' '.join(args)} exited with {exit_code}")


sandbox_json("login", "--key-stdin", stdin=os.environ["BOAT_API_KEY"])

sandbox_id = None
for event in sandbox_json_lines("new", "--ttl", "3600"):
    if event["event"] == "created":
        sandbox_id = event["id"]
    if event["event"] == "ready":
        print("ready", event)

print(sandbox_json("info", sandbox_id))
sandbox_json("stop", sandbox_id)
```

## Direct SSH

The CLI manages the SSH key. For an interactive shell, prefer:

```bash theme={null}
boat ssh "$sandbox_id"
boat scp ./local-file.txt "$sandbox_id:/home/user/local-file.txt"
```

For `boat scp`, remote paths are passed through to OpenSSH `scp` after the `<sandbox-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 sandboxes). Avoid relying on `~` in automation because expansion can vary by local shell and `scp` mode.

For non-interactive commands, pass the command after the sandbox ID:

```bash theme={null}
boat ssh "$sandbox_id" "cd /home/user/ariana-ide-private && npm test"
boat ssh "$sandbox_id" -- bash -lc "cd /home/user/ariana-ide-private && npm test"
boat ssh "$sandbox_id" -- bash -s < ./setup.sh
```

For direct SSH, inspect the sandbox for its IP and use the CLI-managed key:

```bash theme={null}
ip="$(boat info "$sandbox_id" --json | jq -r '.sandbox.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 sandbox ID with `boat 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 Boat 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 Boat API failures
  status?: number;  // HTTP status, present only for Boat API failures
}
```

### Backend error shapes

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

```ts theme={null}
type SandboxApiError =
  | BasicSandboxApiError
  | BlockedSandboxApiError
  | RateLimitedSandboxApiError
  | ProviderNotConfiguredError
  | SandboxNotPromptableError
  | StopFailedError
  | SshKeyError;

type BasicSandboxApiError = {
  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"
    | "boat_starting"
    | "invalid_timeout"
    | "invalid_path"
    | "invalid_setup_script";
  message?: string;
}

type SandboxLimitFields = {
  activeStates: ["provisioned", "cloning", "ready", "idle", "running"];
  activeSandboxes: number;
  maxActiveSandboxes: 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: {
    activeSandboxes: number;
    creationRatePerMinute: number;
    creationRequestsPerDay: number | null;
  };
  standardLimits: {
    activeSandboxes: number;
    creationRatePerMinute: number;
    creationRequestsPerDay: number | null;
  };
  trialLimits: {
    activeSandboxes: number;
    creationRatePerMinute: number;
    creationRequestsPerDay: number | null;
  };
}

type SandboxBillingFields = {
  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;
      activeSandboxes: number;
      creationRequestsPerDay: number;
      note: string;
    };
    endTrialOrFirstPayment: {
      activeSandboxes: number;
      creationRequestsPerDay: null;
      note: string;
    };
    pack: {
      seconds: number;
      persistsAcrossMonths: true;
      note: string;
    };
  };
  contactMessage: string;
}

type BlockedSandboxApiError = (SandboxLimitFields & SandboxBillingFields) & {
  error: "billing_required" | "boat_access_inactive" | "limit_reached";
  status: "blocked";
  message: string;
  billingUrl: string;
}

type RateLimitedSandboxApiError = {
  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 SandboxNotPromptableError = {
  error: "sandbox_not_promptable";
  state: string;
  message: string;
}

type StopFailedError = {
  error: "stop_failed";
  message: string;
  sandbox: Sandbox | 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.                                                                 |
| `boat_access_inactive`            |         402 | Reading events or accessing an existing sandbox is blocked by inactive plan or depleted time.                                                            |
| `not_found`                       |         404 | The Sandbox ID or API key was not found for the current user.                                                                                            |
| `account_not_ready`               |         409 | GitHub/Ascii account setup is incomplete.                                                                                                                |
| `boat_starting`                   |         409 | A command was sent while the sandbox was still provisioning. Retryable: wait for the sandbox to reach `ready`, then send it again.                       |
| `resume_failed`                   |         409 | Backend could not resume the sandbox.                                                                                                                    |
| `boat_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 sandbox.                                                                                                                      |
| `provider_not_configured`         |         409 | Prompting was requested before the selected provider was configured.                                                                                     |
| `sandbox_not_promptable`          |         409 | Prompting was requested while the sandbox 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 | Boat create received a `setupScript` that was not a string or exceeded 64KB.                                                                             |
| `invalid_name`                    |         400 | Boat rename received an empty name.                                                                                                                      |
| `no_changes`                      |         400 | Boat update request did not include a supported change.                                                                                                  |
| `stop_failed`                     |         400 | Stop was requested while the sandbox 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-sandbox cap is reached.                                                                                   |
| `org_suspended`                   |         403 | The organization wallet this sandbox 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 sandbox 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

### Boat

```ts theme={null}
type Sandbox = {
  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 `boat list --json`
}
```

The CLI presents backend states for users: `provisioned` becomes `ready`, `archiving` becomes `stopping`, and `archived` becomes `stopped`.

### SandboxListResult

```ts theme={null}
type SandboxListResult = {
  sandboxes: Sandbox[];
  pageInfo?: {
    hasMore: boolean;
    limit: number;
    nextCursor: string | null;
  };
}
```

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

Example:

```json theme={null}
{
  "sandboxes": [
    {
      "id": "bx_8pqt6dup",
      "name": "Boat 2026-05-28 16:00",
      "state": "idle",
      "url": "https://example-sandbox.on.boat.dev",
      "ip": "203.0.113.10",
      "desktopAvailable": true,
      "desktopUrl": "https://example-desktop.on.boat.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
    }
  ]
}
```

### SandboxInfoResult

```ts theme={null}
type SandboxInfoResult = {
  sandbox: Sandbox;
}
```

### SandboxActionResult

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

```ts theme={null}
type SandboxActionResult = {
  id: string;
  status: "stopping" | "resuming" | "forking" | string;
  sandbox: Sandbox | 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 = {
  sandboxId: 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 `boat api-key create --json` and `boat 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-sandbox keys
  createdAt: string;           // ISO timestamp
  lastUsedAt: string | null;   // ISO timestamp
  usage: { requests: number; windowDays: 30 };
  resources: { total: number; sandboxes: number; agents: number };
}

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

### ApiKeyListResult

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

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

`boat 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: "boat" | "agent";
  id: string;
  name: string;
  state: string;
  createdAt: string | null;
}

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

### SandboxUsageResult

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

```ts theme={null}
type SandboxUsageResult = {
  sandboxId: string;
  sandboxType: "small" | "default" | "large" | "xlarge";
  billingMultiplier: number;
  since: string;             // ISO timestamp; sandbox 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 = {
  activeSandboxes: number;
  maxActiveSandboxes: 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: {
    activeSandboxes: number;
    creationRatePerMinute: number;
    creationRequestsPerDay: number | null;
  };
  standardLimits: {
    activeSandboxes: number;
    creationRatePerMinute: number;
    creationRequestsPerDay: number | null;
  };
  trialLimits: {
    activeSandboxes: number;
    creationRatePerMinute: number;
    creationRequestsPerDay: number | null;
  };
  upgradeEffects: Record<string, unknown>;
  contactMessage: string;
}
```

## Create a sandbox

`boat 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-sandbox.on.boat.dev","ip":"203.0.113.10","desktopUrl":"https://example-desktop.on.boat.dev/stream.html?hostId=...&token=...","archiveAfter":"2026-05-28T17:00:33.162Z","commands":{"ssh":"boat ssh bx_8pqt6dup","forward":"boat 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: Sandbox["state"];
}

type NewReadyLine = {
  event: "ready";
  id: string;
  state: Sandbox["state"];
  url: string | null;
  ip: string | null;
  desktopUrl: string | null;
  archiveAfter: string | null;
  commands: {
    ssh: string;
    forward: string;
  };
}
```

## Prompt and events

`boat 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. `boat events --json` emits the same chat line shape for persisted events. It may return fewer lines than `boat prompt --json`; in a live check, `boat prompt` emitted queued, queued-prompt, running-prompt, finished-prompt, and response lines, while `boat 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": "sandbox-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

boat login "$BOAT_API_KEY" --json >/dev/null

sandbox_id=""
while IFS= read -r line; do
  event="$(printf '%s\n' "$line" | jq -r '.event')"
  case "$event" in
    created)
      sandbox_id="$(printf '%s\n' "$line" | jq -r '.id')"
      ;;
    ready)
      ip="$(printf '%s\n' "$line" | jq -r '.ip // empty')"
      echo "ready: $sandbox_id $ip"
      ;;
    error)
      printf '%s\n' "$line" >&2
      exit 1
      ;;
  esac
done < <(boat new --ttl 3600 --json)

boat info "$sandbox_id" --json
boat stop "$sandbox_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 SandboxCliError extends Error {
  constructor(line, fallback) {
    super(line?.error || fallback);
    this.name = "SandboxCliError";
    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 sandboxJson(args) {
  const result = spawnSync("boat", [...args, "--json"], {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
  });

  if (result.status !== 0) {
    throw new SandboxCliError(
      parseErrorLine(result.stdout),
      result.stderr || `sandbox ${args.join(" ")} failed`
    );
  }

  return JSON.parse(result.stdout);
}

async function* sandboxJsonLines(args) {
  const child = spawn("boat", [...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 SandboxCliError(lastError, `sandbox ${args.join(" ")} exited with ${exitCode}`);
  }
}

sandboxJson(["login", process.env.BOAT_API_KEY]);

let sandboxId;
for await (const line of sandboxJsonLines(["new", "--ttl", "3600"])) {
  if (line.event === "created") sandboxId = line.id;
  if (line.event === "ready") console.log("ready", line);
}

console.log(sandboxJson(["info", sandboxId]));
sandboxJson(["stop", sandboxId]);
```

## Python

```python theme={null}
import json
import os
import subprocess


class SandboxCliError(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 sandbox_json(*args):
    result = subprocess.run(
        ["boat", *args, "--json"],
        text=True,
        capture_output=True,
    )
    if result.returncode != 0:
        raise SandboxCliError(
            parse_error_line(result.stdout),
            result.stderr or f"sandbox {' '.join(args)} failed",
        )
    return json.loads(result.stdout)


def sandbox_json_lines(*args):
    process = subprocess.Popen(
        ["boat", *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 SandboxCliError(last_error, stderr or f"sandbox {' '.join(args)} exited with {exit_code}")


sandbox_json("login", os.environ["BOAT_API_KEY"])

sandbox_id = None
for event in sandbox_json_lines("new", "--ttl", "3600"):
    if event["event"] == "created":
        sandbox_id = event["id"]
    if event["event"] == "ready":
        print("ready", event)

print(sandbox_json("info", sandbox_id))
sandbox_json("stop", sandbox_id)
```

## Direct SSH

The CLI manages the SSH key. For an interactive shell, prefer:

```bash theme={null}
boat ssh "$sandbox_id"
boat scp ./local-file.txt "$sandbox_id:/home/user/local-file.txt"
```

For `boat scp`, remote paths are passed through to OpenSSH `scp` after the `<sandbox-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 sandboxes). Avoid relying on `~` in automation because expansion can vary by local shell and `scp` mode.

For non-interactive commands, pass the command after the sandbox ID:

```bash theme={null}
boat ssh "$sandbox_id" "cd /home/user/ariana-ide-private && npm test"
boat ssh "$sandbox_id" -- bash -lc "cd /home/user/ariana-ide-private && npm test"
boat ssh "$sandbox_id" -- bash -s < ./setup.sh
```

For direct SSH, inspect the sandbox for its IP and use the CLI-managed key:

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

## Related

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