Skip to main content

What is the sandbox API?

The sandbox API is a runtime interface for spawning short-lived microVMs from your worker code or from the terminal. Each sandbox boots in a few hundred milliseconds, runs commands in isolation from the host, and tears down cleanly. The filesystem is discarded on stop. Use it for:
  • Running untrusted code or AI-agent tool calls.
  • One-shot scripts that should not share state with your workers.
  • Per-request isolation where you want a fresh environment every time.
Don’t use it for:
  • Long-lived services — use a regular worker.
  • Durable stateful tasks — the overlay filesystem is wiped on stop.
This page is about the sandbox::* runtime API (called via iii.trigger()). If you’re looking for how worker processes run inside isolated microVMs, see Developing Sandbox Workers.
Host requirements. Sandboxes run as libkrun microVMs and need hardware virtualization on the host: macOS on Apple Silicon, or Linux with KVM enabled (/dev/kvm readable by the engine process). Intel Macs, Linux without KVM, and Windows hosts cannot boot sandboxes — sandbox::create returns S300 with a stderr tail from the failed VM process. See S300 in the error table for the full diagnostic flow.

API surface at a glance

The daemon registers fourteen triggers — four lifecycle ops plus ten filesystem ops. Every call goes through iii.trigger(); nothing is exposed over plain HTTP. The “Recommended timeoutMs” column is what to pass to iii.trigger()’s envelope timeout from the caller side — the daemon itself enforces a separate per-exec deadline (payload.timeout_ms on sandbox::exec) which defaults to 30s. Pad the envelope by ~5s over the daemon deadline so the daemon’s own timed_out signal lands before the trigger client gives up. The CLI (iii sandbox …) wraps a strict subset: run, create, exec, list, stop, upload, download. Anything not on that list is reachable only through iii.trigger() from your worker code. Also on this page: Engine setup · Allowed images · Custom images · Environment variables · Error handling · S-codes · CLI reference · Testing · Troubleshooting

Quickstart

The Node and Python examples below assume you already have an iii worker handle in scope. If this is your first call from outside an existing worker, here’s the minimal setup:
The default engine WebSocket port is 49134 and the engine binds to 0.0.0.0:49134 by default. Override the port with the --port flag on every CLI command. If your engine runs on a remote host (Docker host, K8s service, remote dev VM), substitute that host’s address in the registerWorker("ws://<host>:<port>") URL. See Engine setup for the full URL convention.

One-shot: boot, run one command, stop

From the terminal:
From your code — create, exec, then stop:

Full lifecycle: create once, exec many times, stop

For agent loops, REPLs, or any multi-step flow where guest state needs to carry across commands, create a sandbox up front and exec into it repeatedly:
On an interactive terminal, create prints ✓ sandbox ready in Xs on stderr before the uuid lands on stdout. In a pipe or command-substitution like $(...) the output is silent automatically, so the capture stays clean. The SDK lifecycle in code mirrors the CLI:

Engine setup

The quickest path is iii worker add iii-sandbox, which appends the builtin default block to your engine config.yaml:
The supported images are python and node — add them to image_allowlist to permit boots. An empty image_allowlist denies every sandbox::create with S100. Bring any additional image via custom_images. The engine auto-starts the sandbox daemon when it sees this entry. The iii-sandbox name resolves to iii-worker sandbox-daemon on your $PATH — shipped in the iii-worker binary, no separate install step.

Configuration reference

Observability. The sandbox daemon registers via the standard SDK worker runtime, which wraps every sandbox::create and sandbox::exec handler invocation in an OpenTelemetry span. Route them through the standard observability worker — see iii-observability.

SDK: creating a sandbox

Call sandbox::create via iii.trigger() to boot a sandbox and get a sandbox_id handle.

sandbox::create payload fields

sandbox::create response fields

SDK: running commands

Use sandbox::exec to run a command inside a running sandbox.

sandbox::exec payload fields

Output shape

SDK: one-shot and listing

One-shot (create → exec → stop)

There is no runOnce wire call — expand it into the three-call form:

sandbox::list

Returns active sandboxes.

sandbox::stop

Tear down a running sandbox. The handler kills the VM process, unmounts and removes the overlay, drops the registry entry, and returns. The trigger is not idempotent across the registry boundary: once a stop succeeds, the registry entry is gone, so a second sandbox::stop against the same UUID returns S002 (not found). It is tolerant of the rare race where the reaper marks the sandbox stopped between your calls — that path returns { stopped: true } without re-killing anything.

sandbox::stop payload fields

sandbox::stop response fields

Errors: S001 on a malformed sandbox_id; S002 on a UUID the daemon doesn’t know about (which is what you’ll get if you call stop twice on the same sandbox — the second call sees an empty registry slot).

SDK: filesystem operations

Ten sandbox::fs::* triggers give first-class file access to a live sandbox without going through sandbox::exec. Binary-safe, atomic where it matters (write, mv, sed all use temp + fsync + rename), and deterministic regex semantics (Rust regex flavor — RE2-ish, no backrefs/lookarounds) independent of the image’s coreutils. All requests carry sandbox_id (the UUID returned by sandbox::create). Paths are absolute inside the VM’s rootfs. Symlinks are operated on as symlinks, never followed. fs::write and fs::read stream bytes through III data channels — they don’t share sandbox::exec’s 4 MiB JSON envelope cap, so multi-megabyte files round-trip cleanly. The other eight ops are one-shot JSON. None of them take the per-sandbox exec mutex, so a long fs::grep won’t block a concurrent sandbox::exec.

sandbox::fs::ls

Non-recursive. Returns S211 if the path is missing, S212 if it’s a regular file.

sandbox::fs::stat

Returns S211 if the path is missing.

sandbox::fs::mkdir

parents: false + missing ancestor → S211. Existing path + parents: falseS213. parents: true on an existing directory is a no-op.

sandbox::fs::write (streaming upload)

Caller opens a data channel, writes bytes into the writer half, and passes the reader half’s ref to the trigger. The worker pumps from the channel into the guest’s atomic temp+rename write.
Atomic on disk: the supervisor writes to <path>.iii-tmp-<uuid>, fsyncs, then renames onto the target. If the channel closes before the writer signals end-of-stream (caller crashed mid-upload), the temp file is unlinked and the trigger returns S218. The original target — if it existed — is untouched.

sandbox::fs::read (streaming download)

Trigger returns metadata synchronously plus a StreamChannelRef the caller pulls bytes from. The worker spawns a background task that pumps file bytes into the channel as the caller reads.
S211 if the path is missing, S212 if it’s a directory. The supervisor holds the file descriptor open for the full stream so mid-read size changes don’t affect the bytes you receive. If the read fails after metadata was emitted (e.g. EIO), the worker side-bands { "error": "S216", "message": "..." } via the channel’s text-message channel and closes the stream early.

sandbox::fs::rm

Non-recursive on a non-empty directory → S214, with the path preserved on disk. Symlinks are unlinked, never their targets.

sandbox::fs::chmod

uid / gid are optional — pass either or both to chown alongside the mode change. recursive: true walks the tree (root entry counted). updated counts entries the call applied to, not entries whose mode actually differed.

sandbox::fs::mv

Same-filesystem moves are atomic via rename(2). Cross-filesystem moves (e.g. across a virtio-fs mountpoint) fall back to copy-to-temp-at-dst, fsync, rename, unlink-src — src survives any partial failure. overwrite: false + existing dstS213.

sandbox::fs::grep

Rust regex syntax. Lines longer than max_line_bytes are truncated with . Binary files (null-byte scan in the first 8 KiB) are skipped silently — same default as ripgrep. truncated: true means max_matches was reached and the walk stopped early. line is 1-based. include_glob / exclude_glob accept * (any chars except /) and ? (any single char except /). For richer globbing, pre-filter via sandbox::exec find ... and pass single files. Bad regex → S217.

sandbox::fs::sed

fs::sed accepts exactly one of two forms — explicit files, or path + walk filters (mirrors fs::grep’s walk semantics so you can copy a grep query into a sed call). Sending both, or neither, returns S210 before any file is touched. Form 1 — explicit list (files):
Form 2 — walk a tree (path):
Line-oriented: regex matches are tested per line, never across newlines. Use $1, $2, etc. in replacement for capture-group references. Each file is rewritten via temp+rename — a per-file error sets success: false on that entry without aborting the rest. The trigger always returns 2xx; check the per-file success flag and the top-level total_replacements counter. Bad regex → S217 (top-level error; nothing rewritten). Mutually exclusive form check (files + path, or neither) → S210. For full POSIX sed (hold space, multi-line patterns, chained commands), use sandbox::exec with the image’s own sed binary.

Concurrency and lifecycle

Each sandbox::fs::* call opens a fresh shell.sock connection; the supervisor serves them on independent threads. There is no per-sandbox FS serialization — parallel fs::write and fs::read on the same file race at the filesystem level (same as two concurrent sandbox::execs would). FS ops do not take the exec_in_progress mutex, so a long fs::grep does not block sandbox::exec. They do bump last_exec_at so the idle reaper leaves the sandbox alone while files are being moved around.

Environment variables

Two layers:
  • Create-time (sandbox::create payload env): passed to the VM at boot and exported into the guest shell’s init environment. Every exec call inherits these. The right place for secrets (keys, tokens), service URLs, locale/PATH overrides.
  • Exec-time (sandbox::exec payload env): sent with that single exec request. The guest shell layers the exec-time list on top of the init environment for the duration of that call. The right place for per-request correlation IDs, debug flags, and one-off overrides.
Both layers take env as a list/array of "KEY=VALUE" strings. If a key appears in both, exec-time wins for that call only. Create-time remains the base for every subsequent exec. There is no “unset” verb. Either don’t set the key, or overwrite it with an empty string.

Allowed images

The daemon ships with two catalog presets: The catalog stores fully-qualified refs (registry / namespace / repo / tag) so they hash to the same rootfs-cache slug as managed-worker boots of the same image. A custom-images entry that uses the shorthand iiidev/python:latest would pull the same bytes into a second cache slug under ~/.iii/cache/ — always pin the registry. Your engine’s image_allowlist in config.yaml controls which images are actually bootable at runtime. The allowlist is fail-closed — an image must appear in image_allowlist for sandbox::create to accept it, whether it’s a preset or a custom image. Anything else a deployment needs ships through custom_images.

Custom images

Deployments can register additional OCI images under custom_images in the iii-sandbox config. Each entry maps a short name (used in image_allowlist and the image field on sandbox::create) to a fully-qualified OCI reference:
Once my-app is in both custom_images and image_allowlist, callers boot it exactly like a preset:
Node
Rules.
  • Presets cannot be shadowed. Declaring a custom_images entry with a reserved preset name (python, node) is rejected at config load — the daemon exits with an explicit error. This stops a mistyped or malicious config from silently redirecting the trusted python image to an attacker-controlled ref.
  • Allowlist is still required. An image in custom_images that is not in image_allowlist returns S100 on sandbox::create. Presence in the catalog is not permission.
  • Auto-install applies. With auto_install: true (default), the first sandbox::create for a custom image pulls it into ~/.iii/cache/<slug>/ and reuses the cached rootfs on subsequent boots. With auto_install: false, pre-pull with iii worker add <oci-ref> or the sandbox returns S101.
  • Image must ship a linux/<host-arch> manifest. The sandbox boots a microVM, not a container — an image missing a matching platform manifest returns S102 with a hint about the host architecture.
  • Rootfs is shared with managed workers. A custom image pulled via the sandbox satisfies a managed worker boot of the same OCI ref, and vice versa. One pull, one cache entry.
See Configure the engine for the full engine-level schema.

Error handling

Every sandbox failure throws an error whose message begins with handler error: followed by a JSON envelope. The envelope is flat and always carries five fields:
Parse the envelope if you need the S-code or the retryable flag for targeted recovery:

S-codes

Both the S-code and the message are canonical: the daemon emits each code from a semantically matching error variant, and the {type, code, message, docs_url, retryable} payload is the stable SDK contract. Parse code from the handler error: {...} envelope for targeted recovery, and follow docs_url for the per-code troubleshooting page.

CLI reference

Five user-facing commands, in two flavors. The daemon itself runs as an internal iii-worker subcommand that the engine spawns automatically — you never invoke it yourself. One-shot: run creates a sandbox, executes a single command, and stops it. Use for batch scripts, CI, and quick evals. Full lifecycle: createexec × N → stop keeps the sandbox alive between calls. Use for agent loops, REPLs, multi-step workflows, anything where you need to carry guest state across commands.

iii sandbox run

Create a sandbox, run one command, stop.
Example:

iii sandbox create

Boot a long-lived sandbox and print its id. The sandbox persists until you call iii sandbox stop <id> or the idle timeout fires.
Pipe-friendly: the sandbox id is the only thing written to stdout, so you can capture it in a shell:
When run interactively, the CLI prints ✓ sandbox ready in Xs on stderr before the uuid hits stdout. Redirecting stderr (2>/dev/null) or piping stdout ($(...)) silences it automatically — the capture stays clean. First-time boots pull and unpack the rootfs (~5-30s depending on image size); subsequent boots with a cached rootfs take well under a second.

iii sandbox exec

Run a command inside an already-running sandbox. Pipe-mode only — for interactive TTY sessions use iii worker exec against a managed worker instead.
Stdout and stderr from the guest command are streamed to the CLI’s stdout and stderr respectively; the CLI exits with the child’s exit code.

iii sandbox list

Prints the active-sandbox table. Always shows every sandbox the daemon knows about — the underlying RPC is owner-scoped for multi-tenant SDK callers, but the CLI has no authenticated identity, so it always requests the unscoped view. (Earlier releases exposed an --all flag; it is now a silent no-op, kept only so existing scripts keep working.)

iii sandbox stop

Graceful stop by UUID. The id comes from iii sandbox create, iii sandbox list, or the sandbox_id field returned by sandbox::create.

iii sandbox upload

Stream a local file into a running sandbox via an iii data channel — no JSON-envelope size cap. Atomic on disk: the supervisor writes to a temp sibling, fsyncs, and renames onto remote-path. The original target (if any) is preserved on caller abort and surfaces as S218 (retryable). Pass - as the local path to read from stdin, which makes the command pipe-friendly:
Two of the eight non-streaming sandbox::fs::* ops have shell equivalents that work fine through sandbox::exec (see SDK: filesystem operations) — upload and download exist as dedicated commands because host↔guest byte movement does not have a clean shell equivalent.

iii sandbox download

Stream a sandbox file out to disk (or stdout). Pass - as the local path to write to stdout for piping:
Errors map to the S21x band: missing path → S211, directory instead of file → S212, permission denied → S215, mid-stream I/O failure → S216. See S-codes.

Smoke testing upload / download

End-to-end verification you can paste into a terminal. Boots a sandbox, exercises both directions, verifies bytes round-trip, and tears down. Upload round-trip — push a known file, then cat it from inside the VM and diff against the original:
Download round-trip — seed a file in the VM, download, byte-compare:
The 16 KiB binary round-trip is the strict check — generate the same buffer host-side and in-VM, then cmp -s:
This catches off-by-one truncation, base64 decode bugs in the channel layer, and partial chunk loss. If you see a sha256 mismatch instead, run with RUST_LOG=trace,iii_worker::cli::shell_relay=trace to inspect the per-chunk relay flow.

Testing

The testing subpaths (iii-sdk/testing, iii.testing, iii_sdk::testing) have been removed along with the SDK sugar. Unit-test sandbox-calling code by intercepting iii.trigger() calls at the mock/stub layer of your test framework. For Node, mock the trigger method directly:

Troubleshooting

S101 on first create. Run iii worker add iiidev/<image> to pre-pull the rootfs, or set auto_install: true in the daemon config so the daemon pulls on demand. S003 repeating after a timeout. The sandbox’s exec-in-progress flag clears when the shell session drops. If you keep getting S003, your client probably has a stuck connection or you’re racing two exec calls on the same handle — serialize them. S300 with a stderr tail. Sandboxes require macOS Apple Silicon or Linux with KVM. On other platforms, and on hosts where libkrun can’t initialize (missing frameworks, dlopen failures), the adapter now appends the last 32 lines (≤ 4 KiB) of the VM process’s stderr to the BootFailed message — read it first; the real reason is almost always in there. dmesg on Linux or the iii-worker logs back-fill anything the tail truncated.