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