---
name: instabox
description: Use when the user wants a cloud Linux box (instabox sandbox) from the terminal with the `instabox` CLI — create one, SSH in, run a command in it, read its files, set its idle auto-pause, or delete it, to run a repo's tests in CI (GitHub Actions: a box per job, or a self-hosted runner), or to report a problem with instabox itself (`instabox feedback`). For saving or restoring a box's disks, see instabox-snapshots.
metadata:
  installed-by: instabox
---

# instabox CLI

An instabox sandbox is a Linux VM (Debian, one user with passwordless sudo: named after your local username when created with `instabox create`, `user` from the dashboard, `dev` on older boxes; `instabox get <box>` shows it). It has a root disk and a persistent `/data` disk; HOME is `/data/home`. Each box belongs to an organization; access follows its membership and sandbox permissions.

## Sign in

```sh
instabox whoami              # who is signed in (fails if nobody)
instabox login               # device code: open the printed page, enter the code
```

A GitHub sign-in and a Google sign-in with the same verified email share one account; with different emails they are separate accounts with separate boxes. Both `instabox login` and `npx instabox@latest` sign in with a device code: they print a page and a code (and open the page when there is a browser); the user opens it on any device, enters the code and chooses GitHub or Google there. It works the same over SSH or on a machine without a browser. Relay the page and the code to the user and wait; the command finishes on its own. Without a terminal, `npx instabox@latest` does not wait for a code: it uses `INSTABOX_API_KEY` when set (no browser, no code), else exits at once with status 3; add `--device-code` to get the page and code to relay. For Google directly, use `instabox login --provider google`, or `npx instabox@latest --google`. The CLI shares the sign-in `npx` made.

`instabox update` replaces the CLI with the server's current build (it prints "already up to date" when it is); run it when a command the docs describe is missing, or when a command ends with `instabox X is outdated (latest Y): run instabox update` on stderr (a daily check; `instabox version --json` has `outdated`/`latest`; `INSTABOX_NO_UPDATE_CHECK=1` silences it). `instabox completion bash|zsh|fish|powershell` prints a shell completion script for the user's shell startup file.

For CI or an agent without a browser, use an API key instead: `export INSTABOX_API_KEY=ibk_...` (the user creates it in the dashboard: API keys in the sidebar). Never print, commit or log a key. Installing or updating these skills needs no sign-in: `npx instabox@latest skills install -y` (`--claude` for `~/.claude/skills`, `--codex`, `--cursor` or `--opencode` for `~/.agents/skills`, `--antigravity` for `~/.gemini/config/skills`; none named: all).

With no key and nobody at the keyboard, sign up by yourself: `instabox login --agent` (optionally `--name 'what this agent is' --scope machines:read,machines:write --expires-in-days 30`). It prints a claim page (`https://dashboard.instabox.sh/dashboard/claim?code=XXXX-XXXX`) and the code; relay both to the user and wait. The user opens the page on any device, signs in or signs up, checks what you ask for and approves; the command then finishes on its own, stores the API key made for you in the user's account, and every later `instabox` command uses it. The code works for 24 hours. If you must do other work meanwhile, `instabox login --agent --no-wait` prints the page and code and exits, and `instabox login --agent` later resumes the same registration. Before the user approves you have no account and no credential: do not retry other commands, wait. A denial or an expired code is an error; register again only if the user asks. The user can also approve from their own terminal with `instabox claim XXXX-XXXX`.

## Templates

`agent-browser` is the default, so `create` needs no `--template` for ordinary work. It is Debian 13 (trixie, amd64, kernel 6.18.54) with git, gh, Node.js 22 (npm, pnpm, Yarn), Bun, Python 3.13 (`python3 -m venv`, pip, uv), Go, Rust, a C toolchain, Docker, Claude Code, Codex and OpenCode, plus a desktop (VNC, Chromium) ready for browsing or driving a GUI from the start, on a 16 GiB root disk and a 20 GiB persistent disk at `/data`. The dashboard shows it as **standard**. Pick another template only for its one purpose:

- `herdr`: for the herdr client. The dev tools (gh, Node.js 22, Python 3.13 with venv and pip, Claude Code, Codex, OpenCode, the desktop) without Docker, uv, Go, Rust or a C toolchain; an 8 GiB root disk.
- `orca`: for Orca desktop and mobile. The same tools as `herdr` plus the Orca runtime instead of herdr; an 8 GiB root disk.
- `android`: for Android apps (paid plans only). `agent-browser` plus Android 14 as a container on the box's own kernel (no emulator), on a 24 GiB root disk. `adb` is installed in the box and finds the phone once Android has booted (about 20 s; wait with `until [ "$(docker exec instabox-android getprop sys.boot_completed 2>/dev/null)" = 1 ]; do sleep 1; done`), so `instabox push box ./app.apk` then `instabox exec box -- adb install app.apk`; the phone shows on the desktop (Computer tab). Pause, idle pause and fork work. No Google Play or Google services; x86_64 only, so ARM-only apps do not run. Guide: https://instabox.sh/docs/android
- `t3`: for the T3 Code desktop and mobile clients. `agent-browser`'s tools without the preinstalled desktop (it installs itself the first time it is used), plus the T3 Code server (port 3774); a 16 GiB root disk.

To run an older or different image (say `node:16-buster`), use Docker in the box: on `agent-browser`, `universal` and `t3` the box user is in group `docker`, so `docker run --rm node:16-buster node -v` works without sudo. An operator can also turn a public Debian- or Ubuntu-based image into its own template (`images/templates/oci/build.sh`); it then shows up in `instabox templates` like any other.

`instabox templates` lists the ones this server offers, one row per template: the version `create` gets for `--template T` alone, with its VERSION and a description; its TAG column (`--json`: `"latest"`, `"default"`) says latest, or default for the one `create` gets with no `--template`. `--all` lists every version, with a CREATABLE column. Pin another version with `--template T@VERSION` (or `--template-version`). `--json` (also what a piped `instabox templates` prints) lists every version, retired ones with `"creatable": false`, which no new box can start from; `instabox templates --json --latest` prints only the one `create` gets per template. The free plan creates boxes from `agent-browser` only: another template is refused with `403 template_not_allowed` and needs a paid plan (the user upgrades on the dashboard). A box from a snapshot (`--snapshot`) works on every plan, whatever template the snapshot came from. Do not retry another template on the free plan; use `agent-browser`. Over the HTTP API, `POST /v1/machines` with an unknown `templateId` is `400 bad_request` whose message lists the templates your plan can create from, and a version that template does not have (`agent-browser@nope`, or `templateVersion`) is `400 bad_request` listing its versions newest first, the one you get without a version tagged `(latest)` or `(default)`; a body field the API does not have is `400 unknown_field` naming the closest real one (`template`: did you mean `templateId`?); `name` may be left out (`box-` and 8 random hex digits).

## Sizes

A box is 4 vCPU / 8 GiB of memory unless `create` names another size of the account's plan with both `--vcpus` and `--mem-mib`:

| Plan    | Sizes (`--vcpus` / `--mem-mib`)               |
| ------- | --------------------------------------------- |
| Free    | 4 / 8192 (default)                            |
| Pro     | 2 / 4096, 4 / 8192 (default), 8 / 16384       |
| Builder | 2 / 4096, 4 / 8192 (default) up to 16 / 32768 |
| Team    | 2 / 4096, 4 / 8192 (default) up to 32 / 65536 |

`instabox whoami --json` lists the sizes this account may create as `allowedSpecs` (the default first, then the plan's others and any an admin granted); check it rather than assuming the plan. Another size is refused with `400 spec_not_allowed`, whose message names the account's sizes. Pick a size for the work: 2 vCPU (paid plans) for light editing or a single agent, the default for most builds, 8 vCPU (paid plans) for heavy builds or many processes. `--snapshot` takes the same flags. A paid box moves to another of the plan's sizes while stopped (`instabox resize <box> --vcpus N --mem-mib M [--restart]`, applied at its next start; that start fails with `no_capacity` if its host has no room for the new size, or `host_unavailable` if the host's node-agent cannot resize yet, and the box stays stopped at the new size); a free box's size is fixed.

## Everyday commands

```sh
instabox templates                              # base systems to start from
instabox create --name work                     # new box; waits until it is running
instabox create --name work --setup-file ./setup.sh   # then runs the script in it
instabox create --devcontainer                     # from ./.devcontainer/devcontainer.json (no Dev Containers CLI needed)
instabox ls                                     # your boxes and their states
instabox ssh work                               # interactive shell (leave with: exit; Ctrl+V pastes a local clipboard PNG)
instabox exec work -- uname -a                  # one command, no SSH client needed
instabox exec work --cwd /data/home -- ls -la   # exec runs `bash -lc` as the box's user
instabox run -- make -v                         # one-shot: new box, run, delete it, exit with the code
instabox ps work                                # its processes: pid, ppid, user, %cpu, %mem, elapsed, command (a stopped box: the list from just before its stop)
instabox history work                           # the commands run in it through exec, newest first, with exit codes (stopped boxes too)
instabox output work                            # the last command's stored output (or: instabox output work ex_<id>)
instabox files work /data/home                  # list a directory (or print a text file)
instabox push work ./app                        # copy a file or directory in (to /data/home/app)
instabox pull work /data/home/app/out.log       # copy a file or directory out (to ./out.log)
instabox cp ./x.tgz work:/data/home/x.tgz       # scp-style, either way (cp work:/data/home/x.tgz .)
instabox push work ./app/ /data/home/app        # trailing / (or /.): app's files into /data/home/app, no app/app
instabox auto-suspend work 10m                  # idle time before a paid box pauses
instabox max-run work 120                      # pause a paid box after two running hours
instabox max-run work -1                       # remove the runtime cap; idle policy still applies
instabox stop work && instabox start work       # stop ends processes, keeps disks
instabox suspend work && instabox resume work   # pause/resume keeps running processes
instabox restart work                           # power-cycle a hung box (stop, start); disks kept
instabox rename work api                        # rename in place, any state; ID and port URLs stay
instabox rm --yes work                          # delete the box (without --yes it asks y/N)
```

- Names or IDs (`m_...`) both work wherever `<id-or-name>` is accepted.
- Directories in `push`, `pull` and `cp` (both directions) follow `cp -r`/rsync: `SRC` into an existing directory lands as `DST/<name>` (`push work ./app /data/home` gives `/data/home/app`); `SRC` to a missing path becomes it; `SRC/`, `SRC/.` or `.` copies the contents into `DST` itself, creating it if missing (`pull work /data/home/app/ ./app` fills `./app`). With the destination omitted a directory always keeps its name (`push work .` gives `/data/home/<dirname>`), so nothing merges into HOME or the cwd by default.
- For large uploads use `instabox push` or `instabox cp` (the HTTP API, parallel-friendly), not scp or rsync over `ssh.instabox.sh`, which keeps about 2 MiB in flight per connection and so slows with distance. Measured: at a 5 ms round trip both paths do ~104 MB/s; at 305 ms the API does 11-13 MB/s per stream and SSH 3-6 MB/s; 4 parallel API PUTs did 39 MB/s total.
- `create` options: `--template T` or `--snapshot S`, `--vcpus N --mem-mib M` (a size of the plan, see Sizes), `--auto-suspend D|0`, `--max-run-minutes N`, `--delete-after D` (delete it D after creation, 1m-24h, whatever its state), `--meta K=V`, `--org O`, `--secrets all|NAME,NAME|none`, `--no-wait`, `--setup-file PATH|-` (`--wait-setup`), `--fail-fast` (when no host has room, fail at once and create nothing instead of failing the create after; with `--snapshot`, instead of creating it stopped).
- `create --setup-file PATH` (`-`: stdin; at most 64 KB) runs that script in the new box once it is running, in HOME as the box's user (by its `#!` line, else with bash; CRLF line endings become LF), and prints its output; it replaces create + push + exec for provisioning. A failing script makes `create` exit with the script's code and keeps the box: debug it with `instabox exec work -- /tmp/instabox-setup.sh`. With `--json` the sandbox object gets a `setup` object (`exitCode`, `stdout`, `stderr`, `signal`, `timedOut`, `truncated`, `durationMs`). `create` always waits for the script (`--wait-setup` names that; `=false` is refused) and keeps its state in the box's metadata, so `instabox get work --json` shows it later: `metadata.setupStatus` (`pending`, `running`, `succeeded`, `failed`), `setupExitCode` (`124`: timed out) and `setupLog` (the last 512 characters of its stdout then stderr). The script is killed after `--setup-timeout` (default and at most 10m); for longer installs, use `ssh`. A missing, empty or over-64 KB file is refused before any box is created. `run --setup-file` does the same before its command, with its `-e` variables; when the script fails, run's `--json` `exitCode` is the script's. The script stays at `/tmp/instabox-setup.sh` (and in snapshots), so keep credentials out of it.
- `create --devcontainer [PATH|DIR]` (also `run --devcontainer`) builds a box from a repo's devcontainer.json (default `.devcontainer/devcontainer.json` or `.devcontainer.json` here; JSONC): it pushes the repo into `workspaceFolder` (default `/workspaces/<dir>`), writes `containerEnv`/`remoteEnv` to `/etc/profile.d/instabox-devcontainer.sh` (login shells, nested `bash -l` and `instabox ssh BOX -- cmd` all get it; `PATH` entries appear once), installs `features` via their install.sh (ones the template already has, such as node/python/go/docker-in-docker/github-cli at the default version, are skipped), and runs `onCreateCommand`/`updateContentCommand`/`postCreateCommand`/`postStartCommand` in the workspace. Do not install the Dev Containers CLI in a box for this. `image`/`build`/compose are not booted (boxes are VM templates); the summary (stderr, or `--json`'s `devcontainer`: `applied`, `skipped`, `ports`, `steps`, `status`, `failedStep`) says what was skipped, and `forwardPorts` come with `instabox port-forward`/`port publish` commands. A failed step exits with its code and keeps the box (`metadata.setupStep` names it); `--name` defaults to the config's name. `${devcontainerId}` is the box's name; the summary shows commands as written (a `${localEnv:TOKEN}` is not printed), but the profile file keeps env values (readable by root and the box user's group, and in snapshots). In `run`, write `--devcontainer=PATH` when a command follows without `--`.
- Idle auto-pause defaults to 30 minutes. Paid boxes allow any whole minute from 5 to 30 (a part minute, below 5m or above 30m is refused, nothing created), or `0` to turn it off. Free boxes keep 30 minutes unless an admin grants an idle override; asking another value on a free box fails (`permission_denied`, nothing created), so leave `--auto-suspend` out there. Idle means no agent working, no terminal use and little CPU. `create --json` and `get --json` show the effective setting as `autoSuspendAfterSeconds` (`0`: off), and `readyAt` minus `createdAt` is the create time.
- Max run minutes is independent of idle activity: `-1` is unlimited, `0` pauses immediately, and a positive integer forces a pause after that many running minutes. Free boxes have a fixed 480-minute cap that only an admin session can change; paid boxes default to `-1` and can change it. An idle override does not permit changes to a free box's runtime cap. The first limit reached pauses the box; both timers restart after sleep and wake.
- A runtime pause blocks every wake path during a server-configured cooldown, 5 to 20 minutes by default (`409 machine_cooldown` with `Retry-After`). Wait until the `cooldownUntil` deadline shown by `instabox get`; edits cannot clear it. Afterward, an automatically paused box wakes on `exec`, `ssh`, `files`, a public port request or `instabox wake <box>`. A zero runtime cap must be changed before waking. A normal pause preserves memory and processes.
- `sudo reboot` inside a box reboots it: `instabox get` shows `starting`, then `running` (same disks, processes gone); wait for `running` before the next `exec`. `sudo poweroff` inside stops it until `instabox start`. Boxes from older template versions stop on a reboot too: start them.
- If `exec`/`ssh` stop answering (hang or time out on a running box), run `instabox restart <box>` (alias `recover`; `--timeout 20s` shortens the wait, `--json` prints the box). It stops the box, killing the VM if it has not shut down by the timeout (60 seconds by default), then starts it and waits until it runs. Files on the root disk and `/data` stay; processes do not, so start your servers again. A paused box is not hung: resume it. A box stuck in `starting` with no start in progress cannot be restarted; restart says so.
- Disk full (`df -h /data`)? Grow it with `instabox resize <box> --disk N` (`/data`, where HOME is), in GB. A running box grows online, no restart. A shrink, a paused box, or an image that can't grow online needs `--restart` (it stops the box, resizes the disk and starts it again; files stay, processes do not), else resize changes nothing and says so. A shrink must fit your data plus 10% (at least 1 GB), else it's refused (`disk_too_small`) and nothing changes. Free-plan boxes keep their size. The system disk (`/`) is the template's; you can't grow it.
- Need more CPU or memory? A paid box moves to another of its plan's sizes with `instabox resize <box> --vcpus N --mem-mib M` (Pro: 2/4096, 4/8192, 8/16384; Builder: 2/4096 up to 16/32768; Team: 2/4096 up to 32/65536), while it is stopped; add `--restart` on a running box, and run that from outside the box (from inside, the stop would end the command before the start, so resize refuses it; the same goes for a shrink with `--restart`). A size the plan does not have is refused before anything stops. The new size applies at the next start; free-plan boxes keep theirs.
- Use `exec` to run a command and read its output, and `ssh` for an interactive shell.
- Use `cp`, `push` and `pull` to move files, not `echo <base64> | base64 -d` over `exec`. `cp LOCAL work:PATH` and `cp work:PATH LOCAL` copy either way (`-r` for directories). `push work LOCAL [REMOTE]` copies a file or directory to REMOTE (default: HOME); an existing directory or a path ending in `/` receives it under its own name. REMOTE's parent directory must exist (else `missing_parent`); `-p` creates it. A file streams byte-for-byte, up to 5 GiB, and its sha256 is checked; a directory goes as a gzipped tar of at most 1 GiB. From inside a git repo, a directory push sends only tracked files (`git ls-files -c`); `--all` adds untracked files git does not ignore; `node_modules`, build output and `.git` stay behind. `pull work REMOTE [LOCAL]` copies back the same way: a file streams, any size; a directory is read as root, at most 32 MiB compressed. Over HTTP: `PUT /v1/machines/{id}/files?path=P` with the raw body, `GET /v1/machines/{id}/files/content?path=P`, and `POST /v1/machines/{id}/files/upload-url` for a signed URL that takes the PUT without a key.
- `exec` with one argument after `--` runs it as a shell string (`instabox exec work -- 'ls | wc -l'`); several arguments run as that exact argv, each quoted for you (`instabox exec work -- bash -c 'echo a b > /tmp/x'`).
- For a one-off job that needs no box afterwards, use `instabox run [--template T | --from SNAPSHOT] [--name N] [--push DIR] [--cwd D] [-e K=V ...] [--timeout 60s] [--keep] [--setup-file PATH|-] [--json] -- <cmd...>` instead of create + exec + rm: it deletes the box even when the command fails or the run is interrupted (Ctrl-C, SIGTERM), and exits with the command's code (124: timed out). Its own failures have their own codes: 2 usage, 75 `--fail-fast` and no host has room (retry later), 125 `run` itself failed (API, network, create, upload or cleanup), 130 interrupted; with `--json` they print the `{"error": {...}}` object, except a failed cleanup delete: the result then has `deleted: false` and `cleanup: {"error": {...}}` (stdout is always one JSON document). One argument is a shell string, several an exact argv. The same 10-minute limit as `exec` applies. `--push .` first uploads the current directory's files (those git does not ignore) into the working directory (`--cwd`, default HOME), so `instabox run --push . -- 'go test ./...'` tests your checkout. `--name N` is used as is (1-63 lowercase letters, digits or `-`, starting with a letter or digit; default `run-<8 hex digits>`). A box left behind by a killed CLI is deleted by the server at its `deleteAt`, 5 minutes after its timeout (10 with `--push`, plus another timeout with `--setup-file`); a cleanup delete that gets a 5xx, a retryable error or no answer is retried 5 times with backoff. `--setup-file PATH|-` runs a setup script (at most 64 KB) first, in HOME with the same `--timeout` and `-e` variables (after `--push`); if it fails, `run` exits with its code without running the command. `--keep` keeps the box with no deadline and, once it was created, prints `Kept NAME; delete it with: instabox rm --yes NAME` to stderr (stdout stays the command's; `--json` has it as `sandbox`); a failed create operation prints only its error (one whose wait was cut short is kept, with `Kept NAME, whose create had not finished; …`).
- `instabox ps work [--all] [--json]` lists the processes running in the box (the server runs `ps` through exec, so it wakes a box that paused itself). Kernel threads and the listing's own processes (its shell, `ps`, `head` and the sshd sessions exec uses) are left out unless `--all`. `--json` prints an array of `{pid, ppid, user, cpuPercent, memPercent, elapsedSeconds, command}`: `instabox ps work --json | jq '.[] | select(.command | test("node"))'`. A stopped or manually paused box shows the list the server read just before it stopped or paused (a stderr note says when it is from), so you can see what was running when a box went down; a box that was never listed while running says so and asks you to start it.
- `instabox history work [--limit N] [--json]` lists the commands run in the box through `exec` (by anyone: the CLI, the API, other agents; not SSH sessions), newest first, with ID, start time, duration and exit code, also while the box is stopped. The server keeps the newest 200 with the first 64 KiB of each output stream. `instabox output work [ex_<id>]` prints a past command's stored stdout and stderr (the last command's by default) and exits with its exit code, as `exec` did; `--json` gives `{id, command, cwd, startedAt, durationMs, status, exitCode, stdout, stderr, ...}`. Use them to find out what an earlier session or another agent ran in a box and what it printed, instead of rerunning it. The `jobs` polls and `ps` are not recorded; `exec --background` is recorded as `exec --background: <cmd>` (its output is in `jobs logs`).
- To copy a box (try something risky, or run agents side by side from the same state), use `instabox fork work [--name N] [--count N] [--keep-snapshot] [--json]` (`clone` works too): the server copies `work`'s disks (running or stopped) on its host into `--count` boxes (1-5) with the same template and size and boots them, in a few seconds and with no snapshot (memory and running programs are not copied); with an older server that has no native fork, or with `--keep-snapshot`, it saves a snapshot, creates the boxes from it and deletes the snapshot, also on error or Ctrl-C, unless `--keep-snapshot`; a suspended box is copied neither way, so resume or stop it first. `work` is not changed. Several boxes are named `N-1` to `N-N`; each box counts toward the plan's starts a minute, which fork waits out (up to 5 minutes in total). If the source's size is no longer one of the plan's sizes, pass `--vcpus N --mem-mib M`. On a failure part-way it prints which boxes were created; they are kept, so delete those you do not need. `--json` prints `{source, machines, native, snapshotId, snapshotName, kept, snapshotMs, createMs, totalMs}` (`native` true: no snapshot was used).
- `exec` returns the command's exit code and takes `--stdin` to pipe input (any size up to 5 GiB, binary too: `instabox exec work --stdin -- tar -xz -C /data/workspace < src.tgz`; past 1 MiB the CLI uploads it as a file first). A CLI built before 2026-10-26 refuses stdin past 1 MiB (`stdin is larger than 1048576 bytes`): run `instabox update` instead of splitting the input. A big file can also go in with `push`, then exec reads it: `instabox push work bundle.tgz /tmp/` and `instabox exec work -- tar -xzf /tmp/bundle.tgz -C /data/workspace`. push checks the SHA-256 and sends a file of 64 MiB or more as parallel parts by itself; past 5 GiB, `split` the file, push the parts, and `cat` them together in the box. `exec --json` prints one object instead of the raw output (`stdout`, `stderr`, `exitCode`, `signal`, `timedOut`, `truncated`, `invalidUtf8`, `durationMs`) and still exits with the command's code: `instabox exec work --json -- make test | jq .exitCode`. exec output is UTF-8 text: each stream is cut at 1 MiB (`truncated`; `--stream` has no cap) and bytes that are not UTF-8 become U+FFFD (`invalidUtf8`), so never copy a file out with `exec -- cat` (an APK or tarball comes back corrupt); use `pull`/`cp`. A cut is noted on stderr and keeps the exit code; when the command exits 0 but its output was binary, exec says so on stderr and exits 65 (its own code, like 75 and 125, so it never looks like the command's). `exec --stream` prints the output as the command writes it instead of at the end (same timeout and exit code; with `--json`, one `{"type":"stdout"|"stderr","data"}` object per line, then `{"type":"exit","exitCode",...}`), so you can watch a build or test run as it goes. It stops the command after `--timeout` seconds: 60 by default, 600 at most (whole seconds; `run`'s also takes `90s` or `5m`). A larger or unparsable value is a one-line usage error, exit 2. It returns when the command exits, even if it left a process running in the background (`instabox exec work -- 'nohup ./server >server.log 2>&1 &'` returns at once and the server keeps running); background output written later than about a second after the exit is not returned, and a background process that still writes to exec's stdout or stderr after that gets a broken pipe (it may die, or a server may drop requests), so send both to a file (`>log 2>&1`) or use `exec --background`. Run longer commands (installs, builds, test suites) over SSH, which also returns the exit code: `instabox ssh work -- 'cd /data/workspace/app && npm ci'`. The box's runtime cap still applies during SSH or exec.
- For work past 600 s, or to keep going while you do something else, start a background job: `instabox exec --background work -- 'npm run build'` prints a job ID at once (`--json`: `{jobId, pid}`); then `instabox jobs ls work`, `instabox jobs logs work <job> -f` (streams until it ends, exits with its code; Ctrl-C leaves the job running), `instabox jobs wait work <job> [--timeout 30m]` and `instabox jobs kill work <job> [--signal KILL]` (the whole process group). This is the CLI polling over plain exec calls. A job holds no activity lease: only its CPU use, or a running `jobs wait`/`logs -f`, counts as activity, so keep `jobs wait` going for a quiet job you need to finish. A pause freezes the job and it carries on after resume; a stop ends jobs (`exited(143)`, or `lost` after a hard stop).
- To hand a task to a coding agent inside the box, use `instabox agent`: `instabox agent prompt work [--provider claude|codex] [--model M] [--new | --resume ID] [--attach FILE] [--cwd D] [--env K=V] [-f] [--json] "text"` starts a turn as a background job (`claude -p --output-format stream-json` or `codex exec --json`, permission prompts and sandboxing skipped: the box is the boundary) and prints the conversation ID (`--json`: `{conversationId, turn, jobId, pid}`; `-f` follows the turn and exits with its code). `instabox agent events work [-c ID] [-f] [--json]` prints the conversation's events, one JSON object per line: the agent's own, between `instabox.turn` (with the prompt) and `instabox.turn_end` (`exitCode`, `interrupted`). `instabox agent steer work [-c ID] "text"` interrupts the running turn, if any, and resumes the agent's session with the text; `instabox agent interrupt work [-c ID]` stops the turn (INT, then TERM, then KILL); `instabox agent ls work [--json]` lists conversations (status `running`, `idle`, `interrupted`, `failed(N)` or `lost`). Without `-c`, the conversation prompted last. The agent needs its key in the box's environment: `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) for claude, `OPENAI_API_KEY` (or `CODEX_API_KEY`) for codex, from an org Secret the box selects, or `--env`; the CLI warns when none is set. When the template lacks the agent CLI, the first turn installs it with npm into `~/.instabox/agent-cli` (an `instabox.install` event), or fails with an `instabox.error` event when there is no npm. State lives in `~/.instabox/agents/<id>` in the box. A turn waiting on the model API looks idle to auto-suspend (network does not count), so keep `agent events -f` (or `prompt -f`) going for a long turn you need to finish.
- A box that does not boot, is unreachable, or failed to start or resume: `instabox logs work` prints its console log (kernel and systemd boot messages) and its last 20 operations with their errors, in any state, without waking it. Add `-f` to follow it or `--json` for `GET /v1/machines/{id}/logs`.
- Output: `--json` (or `-o json`) works on every command (each command's `-h` lists it); the only exceptions are `ssh`, `mosh` and `port-forward`, which are interactive and refuse it with the JSON error code `json_unsupported` (use `exec --json`). With JSON output a failure prints `{"error":{"code","message",...}}` on stdout instead of text on stderr and still exits non-zero (2 for usage errors, code `usage`); API errors keep the server's `code`, `status`, `requestId` and `retryable` (present when true). Piped, the list and status commands (`ls`, `get`, `templates`, `usage`, `snapshot ls`, `port ls`, `keys ls`, `secrets ls`, `vault ls`, `workspace ls|get|members|invites`) print JSON on their own, so `instabox ls | jq` works; `--output table` or `INSTABOX_NO_AUTO_JSON=1` keeps the table. `exec`, `ssh`, `files` and the other commands print the same output piped or not.
- Exit codes: a failed command exits 1 for any other error, 2 usage, 3 auth (not signed in, a bad or expired key, no permission: 401/403), 4 not found (404), 5 quota or plan limit (`quota_exceeded`, `credit_exhausted`, `spend_limit_reached`, `spec_not_allowed`, other 402/429), 75 retryable (`retryable: true`, such as `no_capacity` and `host_unavailable`, or another 5xx; retry with backoff). `--json` errors exit the same way. Commands that run something (`exec`, `ssh`, `jobs wait`, `output`, `run`, ...) exit with its code instead. Branch on the code (`case $? in 4) ...`), not on message text.
- The box's HOME has `MACHINE.md` (which box it is, its size, what survives a stop, the installed tools) and `AGENTS.md`, a moment after it starts. They are reference for when a task needs a fact about the box, not steps to run first.
- A public URL for a web server in the box: `instabox port publish work 3000` prints it (`instabox port ls`, `instabox port unpublish work 3000`). The URL works while the box runs.
- A raw TCP service (Postgres, Redis) reachable from anywhere, where the server enables it: `instabox port publish work 5432 --tcp` prints `5432-<id>.<tcp domain>:443`; clients connect with TLS and that host as SNI (`psql "host=<host> port=443 sslmode=require user=postgres"`). Otherwise, or for a private tunnel, use `instabox port-forward work 5432`.
- A private tunnel instead, only from your computer: `instabox port-forward work 8080:3000` (alias `forward`; `LOCAL` defaults to `REMOTE`, several ports at once) serves the box's port 3000 at `http://127.0.0.1:8080` over SSH and stays in the foreground until Ctrl-C, so start it in the background (`instabox port-forward work 8080:3000 &`) and kill it when done. It fails at once when the local port is busy.

## Organization Secrets

Secrets are encrypted API credentials owned by an org, each limited to the HTTPS hosts it may be sent to. A box that selects a Secret gets a placeholder in its environment, never the credential itself. When a request from the box goes to one of the Secret's allowed hosts over HTTPS, the outbound proxy swaps the placeholder in the request headers for the credential. Code in the box can call the API but cannot read or copy the credential. No header-name configuration is needed.

Secrets suit HTTP API keys and tokens that a client sends in a request header: model providers, GitHub's API, deploy and SaaS APIs. HTTP Basic auth in the `Authorization` header also works, so `git clone` and `git push` over HTTPS work when the Secret's hosts include `github.com` and git gets the placeholder from a credential helper such as `gh auth setup-git`. They do not cover database URLs or passwords, signing keys, or anything a client hashes, signs, encodes some other way or sends outside an HTTPS header; those need their own setup on the box.

Secrets replace the Vault, which is legacy and will be retired: save every new API credential as a Secret, never in the Vault, and do not suggest `vault set`, `set-file` or `import` to users. A new box without `--secrets` still gets an existing user's Vault items (see the last point below). Opt into Secrets with `create --secrets`, or use `secrets select` later. Metadata labels do not select Secrets, and Secrets do not deliver files.

```sh
instabox secrets ls --org team                 # metadata only
instabox secrets show work                     # selection and proxy status
instabox secrets set SERVICE_API_KEY --org team --host api.example.com
instabox secrets set ANTHROPIC_API_KEY --org team --ai anthropic
instabox secrets import .env --org team --host api.example.com   # every value gets these hosts
instabox create --name app --org team --secrets SERVICE_API_KEY
instabox secrets select work --secrets SERVICE_API_KEY,OTHER_API_KEY
instabox secrets push work                     # refresh the selection on a running box
instabox create --name tenant --no-vault --secrets none
```

- Values for `set` come from stdin or a hidden prompt. Never print a credential or put its value into agent output, chat, command arguments, or logs. Before asking for a credential, check metadata with `secrets ls --org <org>` and `secrets show <box>`. A silent check such as `test "${NAME+x}" = x` tests that the placeholder is present without revealing it; it does not prove the upstream accepts the credential.
- `--host` is optional and repeatable. Give each exact hostname the credential may be sent to; without `--host` (or with `--host '*'`) every public HTTPS destination is allowed. Schemes, ports, paths, IP addresses, and partial wildcards such as `*.example.com` are rejected. Use the service's API host, such as `api.openai.com`, never a guess from the credential's value.
- Org commands default to the signed-in user's personal org. Use `--org` for a team, and select only Secrets from the box's org. Value, host and deletion changes require the org owner/admin: a sign-in, or headless the workspace's own API key with `workspace:admin` in `INSTABOX_API_KEY` (org commands then default to the key's org; `secrets rm` needs `--yes` without a terminal). A personal org's key and keys with only machine scopes can inspect metadata and manage sandbox selections, but cannot change Secret values (`403 missing_scope`). No key or session can retrieve a value.
- For model provider keys use `--ai anthropic|openai|gemini|openrouter` (repeatable, combinable with `--host`): it scopes the key to `api.anthropic.com`, `api.openai.com`, `generativelanguage.googleapis.com` or `openrouter.ai`. The box only sees a placeholder, in its environment and in any file a tool writes, so the real key is never on its disk or in snapshots. Gemini keys must go in the `x-goog-api-key` header, not `?key=`. To block a box from all Secrets, `secrets select BOX --secrets none`; unblock with `--secrets all` or names. When the server records usage, `secrets show BOX` lists each Secret's proxied and denied request counts.
- `secrets select BOX --secrets` replaces the box's selection: `all`, `none`, comma-separated names, or a JSON array (which allows a Secret literally named `all` or `none`). Keep existing names when adding one.
- Placeholders work only in HTTPS request headers on port 443 to the allowed hosts. Apart from Basic auth in `Authorization`, which the proxy decodes and re-encodes, do not hash, encode, or sign with a placeholder; other traffic carries the placeholder unchanged.
- Rotating a value keeps its placeholder, so running clients need no change. Newly selected names appear in new shells that load the managed environment; restart existing applications to pick them up.
- For a box intended for another person or tenant, disable personal Vault injection with `--no-vault` and select only the org Secrets they may use. For a fresh box with neither system's credentials, use `--no-vault --secrets none`. An empty Secret selection alone still permits Vault injection. These flags do not remove credentials already copied into a source snapshot.
- For an existing box, disable Vault with `start BOX --no-vault` or `resume BOX --no-vault` and clear the Secret selection with `secrets select BOX --secrets none`. Refresh a running guest with both `vault push BOX` and `secrets push BOX`. Vault values already copied into processes or files remain there; disabling Vault does not recover them.
- The Vault is legacy and will be retired; every `vault` command prints a warning. Its commands remain only for items a user already saved: `vault ls`, `show`, `push` and `rm`, and `vault export` to recover a retained item. A box may use Vault and Secrets together; Secrets wins same-named environment variables. Neither store copies or deletes the other. To move a Vault env item that is an HTTP API credential, the user enters its value again as a Secret with its hosts (`secrets set NAME --host HOST` reads it from a hidden prompt), you select it on the box, then `vault rm --yes` it with the user's go-ahead. Never read a Vault value to copy it. Database credentials and other non-HTTP secrets are not supported by Secrets and need their own setup on the box; Vault file items likewise.

## Webhooks (lifecycle events instead of polling)

To be told when a box is ready, pauses, stops, fails or is deleted, register an HTTPS endpoint instead of polling `instabox get`. Each event is a signed POST (Standard Webhooks).

```sh
instabox webhooks create https://example.com/hook --event sandbox.started --event sandbox.error   # prints the signing secret once
instabox webhooks ls
instabox webhooks test wh_...                  # send a webhook.test event now
instabox webhooks deliveries wh_...            # each attempt's status and error
instabox webhooks events                       # the event types
```

Every `webhooks` subcommand takes `--json` (or `-o json`).

- Events: `sandbox.created`, `sandbox.started` (running: ready for exec/SSH, after create, start, resume or wake), `sandbox.stopped`, `sandbox.suspended` (paused, `data.reason` says why: `user`, `idle`, ...), `sandbox.error`, `sandbox.deleted`, `snapshot.created`, `snapshot.failed`, `snapshot.deleted`. No `--event` subscribes to all.
- Verify each request: `webhook-signature` is `v1,<base64 HMAC-SHA256>` of `<webhook-id>.<webhook-timestamp>.<raw body>` keyed with the base64 part of the `whsec_...` secret; reject timestamps over 5 minutes old and dedupe on `webhook-id` (deliveries retry for about a day). Store the secret like any credential; `rotate` gives a new one (the old one signs for 24 hours).
- The URL must be public `https`. The org's owner or admin manages them: signed in, or headless with an API key of that org in `INSTABOX_API_KEY` (`machines:read` lists, `machines:write` changes, `machines:delete` removes; `--org` defaults to the key's org).

## CI (GitHub Actions)

When the user asks to run their CI on instabox, set up `runs-on: instabox-medium` in their repo, end to end. Each job then runs on a fresh Ubuntu 24.04 box (like `ubuntu-latest`) that is deleted when the job ends.

1. Get OWNER/REPO: `gh repo view --json owner,name,visibility --jq '.owner.login+"/"+.name+" "+.visibility'`. If it's public, tell the user that pull requests from forks will run on their workspace too, on their plan, and that GitHub's **Require approval for all external contributors** (the repository's **Settings** > **Actions** > **General**) makes each one wait for their approval. Then run `instabox ci` (with `--org ORG` for a workspace): if it says GitHub Actions jobs aren't open on this account yet, use the CLI way below instead.
2. Pick the workspace that runs and pays for the jobs: when `instabox workspace ls` lists more than one, ask the user which. Send them the App link with its ID, https://dashboard.instabox.sh/auth/github-app/install?org=ORG_ID (without `?org=` the page asks which workspace when they are in more than one). Linking takes an owner or admin of that workspace. The link has them confirm their GitHub account, then lists the GitHub accounts with the App: ask them to click **Link** next to OWNER (when OWNER is not listed, **Install on another GitHub account**, install it on OWNER and give it access to REPO), and wait until they say it's done. The page it ends on names the workspace it linked. If it says "your personal workspace" and they meant another, see the end of this section. You can't do this step for them.
3. Change `runs-on:` of the jobs to move to `instabox-medium` (4 vCPU / 8 GiB), or `instabox-small` (2 / 4) or `instabox-large` (8 / 16) where the plan has them (the free plan runs `instabox-medium` only; any other `instabox` label, `instabox` alone included, is refused). Leave the steps alone; the jobs' secrets stay GitHub Actions secrets, as the box gets none of the user's instabox secrets.
4. Show the user the diff and push only with their OK.
5. Check the first run: `gh run watch`, and `instabox ci` (with `--org ORG` for a workspace) shows each job's state and why it waits or was refused. When nothing is linked, `instabox ci` says so and prints the App install link to send. A job that stays queued on GitHub with nothing in `instabox ci` means the App has no access to REPO; go back to step 2.

A GitHub account runs CI for one workspace at a time. To move OWNER to another workspace, an owner or admin unlinks it (**Unlink** in the account menu of the dashboard's CI page), then links it from the App link of the other workspace. One linked to the user's personal workspace links straight to another workspace they manage, after a confirmation. The App stays installed on GitHub through all this.

When linking does not work:
- The user cannot link a workspace, or `instabox ci` says only an owner or admin links the App: they are a member there. Ask an owner or admin (`instabox workspace members WS` lists them) to link it, or to make the user an admin.
- OWNER shows **Already used for CI**: its CI runs in another workspace. With **Open**, the user is in that workspace and unlinks it there first. Without it, they are not: ask whoever set up CI for OWNER to invite them, or to unlink it.
- OWNER is not listed: the App is not installed on it, or their GitHub account cannot see it. **Install on another GitHub account** installs it.

For another CI, or a job that must stay on GitHub's runner, a step runs one command in a box with the CLI, with INSTABOX_API_KEY from a CI secret:

```sh
instabox run --push . --timeout 10m -e CI=1 -- 'npm ci && npm test'
```

Both ways, their limits and the reasons a job is refused: https://instabox.sh/docs/ci.md

## Moving work onto a box

To move the repo the user is working in (with its worktrees, uncommitted work and agent conversations) onto a new box and install its dependencies there, follow the `instabox-move-repo` skill.

## MCP clients

Keep using the CLI and this skill. If the user wants instabox as MCP tools in Claude Code, Cursor, Codex, OpenCode or Antigravity, `instabox mcp init claude|cursor|codex|opencode|antigravity` writes that client's config (the client then runs `instabox mcp start`, which uses this CLI's sign-in or `INSTABOX_API_KEY` and never opens a browser); `--hosted` points it at `https://api.instabox.sh/mcp` with the key read from `INSTABOX_API_KEY` (Antigravity reads no variables, so add `--inline-key` there). The user restarts the client afterwards. Details: https://instabox.sh/docs/mcp.md

## When instabox itself gets in your way (feedback)

If instabox is at fault — a command breaks the contract its help or this skill states, the skill does not match what really happens, a capability is missing, or the CLI is confusing — report it, then carry on with the user's task using a workaround. Never block on the report, and never report problems in the code the user is building.

```sh
instabox feedback --json --type bug --component cli --area exec \
  --title "exec --timeout 600 stops the command at 60s" \
  --detail "instabox exec work --timeout 600 -- make test was killed after 60 seconds" \
  --command "instabox exec work --timeout 600 -- make test" \
  --expected "the skill says exec accepts up to 600 seconds" --doc "skills/instabox/SKILL.md" \
  --workaround "ran it over instabox ssh instead"
```

- `--type`: `bug` when something does not work as its help or the docs say (when instructions did not match reality, also give `--doc`, `--expected` and `--workaround`), `feature-request` when what you need is not supported, `friction` when it works but is confusing or awkward, `other` otherwise.
- `--component`: `cli`, `api`, `sandbox` (the box itself, its templates or tools), `dashboard`, `skills`, `docs` or `other`. `--severity blocker|major|minor` (default `minor`); `--sandbox NAME` names the box involved.
- Emails, keys, tokens, home paths and public IPv4 addresses are redacted before sending, but do not paste secrets into it. Signed in, the report opens a support ticket on the account, and the team's replies show in the dashboard at the link it prints (`ticket` in `--json`); signed out or waitlisted, it reaches the team without one.
- A failed or unconfirmed submission prints a warning and exits 0: do not retry. File one report per problem, not one per attempt.

## Habits

- Forgotten boxes pause themselves after 30 idle minutes. Delete a box with `instabox rm --yes NAME` once its work is done. `rm`, `snapshot rm`, `secrets rm`, `vault rm`, `keys rm` and `webhooks rm` ask y/N first and, off a terminal with no answer, delete nothing and fail, so pass `--yes` (`-y`).
- When the user asks for a new box, create one, with a name `instabox ls` does not already show. Offer an existing box only when they want to continue earlier work in it.
- `instabox usage` shows storage use and limits, this month's cost in USD and the credit balance (`--json`: `.cost.costUSD`, `.cost.creditsUSD`). It and `whoami` describe your personal org; a workspace has its own plan and limits: `instabox usage --org <workspace>` (whoami lists your workspaces and tiers). A quota error means delete or stop something; do not retry the same create in a loop.
- `no_capacity` (`create` exits 75, with or without `--fail-fast`; `run` exits 75 with `--fail-fast`, otherwise 125; a permanent one that is not `retryable` exits 1) is different: no host has room right now and nothing was kept. It is retryable: retry the same command with backoff (about 15 s, 30 s, 60 s, then every few minutes), or try another size (`--vcpus/--mem-mib`) or template.
