# BoxLite Cloud — agent guide FOR: coding agents writing code against BoxLite Cloud. AS OF: 2026-08-28 · Python SDK 0.9.7 CANONICAL: https://boxlite.ai/agent.md SCOPE: BoxLite Cloud, Python. Boxes run on BoxLite's hosted fleet, reached over REST with an API key. LANGUAGE: Python only. The Node, Rust, Go and C SDKs use different class names and method casing — do not hand-translate these snippets. See §11. How to use this file: - Every code block is complete and runnable on its own. Do not splice two blocks together; copy one whole. - A block tagged **[verified · sdk · date]** was *executed* against Cloud by `scripts/verify-agent-md.py` on that date, and its `Verify` line is the assertion that run made. The badge is generated from the run report — the build fails if a block is stamped without a passing run, so the badge cannot drift from reality. A block with **no** badge was not executed; treat it as illustration. - Several things that exist in the SDK do **not** work on Cloud. §9 lists them with the exact symptom. Read it before reaching for a class or option not shown here — most of them fail *silently*, which is worse than an error. - If a capability is in neither this file nor §9, do not invent a method name — but do not conclude it is impossible either. The SDK lags the platform; §10 shows how to check the REST API, which is the source of truth. --- ## §1 Rules of engagement Each rule is a trigger, an action, a fallback for when you cannot ask, and a hard prohibition. **R1 — No key, no boxes. And check for the key properly.** ``` IF the task needs BoxLite THEN confirm BOTH BOXLITE_REST_URL and BOXLITE_API_KEY are set (§4) DEFAULT do not run anything; report exactly which one is missing NEVER print, log, echo, or commit the value of BOXLITE_API_KEY, and never write it into a source file or a .env you add to git NEVER conclude "the user has no key" because `env` looks empty. Interactive shells source ~/.zshrc and ~/.bashrc; the non-interactive shell an agent runs commands in does not. Source it explicitly and re-check before reporting a missing key (§4). NEVER rely on BoxliteRestOptions.from_env() to catch a missing key. It raises only for a missing URL; with no key it returns credential=None and the failure surfaces later as "internal error: Invalid credentials" (§8). ``` **R2 — A completed `exec()` is not a successful `exec()`.** ``` IF you call exec() THEN check result.exit_code before using result.stdout DEFAULT treat a non-zero exit_code as failure and surface result.stderr NEVER assume exec() raises on failure — it does not. It returns an ExecResult carrying a non-zero exit_code. ``` **R3 — Untrusted or model-written code gets an allowlist, not the internet.** ``` IF the box will run code you did not write THEN pass network=NetworkSpec(mode="enabled", allow_net=[...]) naming only the hosts the task needs; everything else is DNS-sinkholed DEFAULT the narrowest allowlist that lets the task finish NEVER use mode="disabled" on Cloud — the box fails to start with "Box state transition failed" (§8). There is no full-off switch here; an allowlist is the tool. ``` **R4 — Secrets go through `Secret`, never through `env`.** ``` IF the workload must authenticate to an outside service THEN pass secrets=[Secret(name=..., value=..., hosts=["api.example.com"])]; the real value stays on the host side and never enters the guest DEFAULT no secrets in the box NEVER pass a credential via env= or bake one into an image — env values are readable by anything running inside the box ``` **R5 — Boxes cost money by the hour, and they do not clean themselves up.** ``` IF you create a box THEN remove it by name or id when you are done: await rt.remove(name_or_id, force=True) DEFAULT one box, removed at the end of the task NEVER rely on auto_remove=True to delete a Cloud box. Measured: a box created with the default was still returned by rt.list_info() after the `async with` block exited normally. `async with` stops the box; it does not remove it here. ALWAYS finish a session by listing what is left: [i.name for i in await rt.list_info()] ``` ``` IF the plan creates more than ~10 boxes concurrently, or a box meant to outlive the session THEN state the box count and the plan's concurrency cap, and confirm first DEFAULT bounded batches ``` **R6 — Publish with a preview URL. Not `ports=`, not `tunnel()`.** ``` IF a service inside the box must be reachable from outside THEN start it bound to 0.0.0.0, then GET /api/box/{boxIdOrName}/ports/{port}/preview-url and use its `url` DEFAULT one preview URL per service port NEVER call box.tunnel(port). It does not exist in SDK 0.9.7 on Box or SimpleBox, on Cloud or locally (§9). NEVER use ports= against Cloud. It is accepted, the box starts, and nothing is published — a silent no-op, not an error (§9). NEVER report a service as reachable without fetching its URL from outside the box and checking the response body ``` **R7 — A preview URL is public. Say so.** ``` IF you hand a preview URL to a user, a PR, an issue, or a log THEN state plainly that anyone with the link can reach it — it answers unauthenticated requests from anywhere DEFAULT for anything not meant to be world-readable, use /ports/{port}/signed-preview-url instead, and expire it when done NEVER put a preview URL to a box holding customer data, credentials, or an internal tool into a public channel without asking first ``` **R8 — When the SDK lags the platform, REST is the source of truth.** ``` IF a capability this file describes is missing from the installed SDK THEN check the REST API before telling the user it is impossible (§10); the SDK is a client for it and is not always in step DEFAULT call the REST endpoint directly with urllib and the same API key NEVER invent an SDK method name and hope NEVER silently substitute a REST call for a documented SDK call without telling the user which one you used and why ``` --- ## §2 Router Every row below resolves to a section that exists. Topics not yet covered are listed openly in §12 rather than linked from here. | Your task | Go to | | --- | --- | | Run one command on Cloud, first time | §5 J1 | | Execute Python an LLM wrote | §5 J2 | | Get files in and out of a box | §5 J3 | | **Serve an HTTP or agent service on a public URL** | **§5 J4** | | Keep that service alive after the script exits | §5 J5 | | Restrict network, pass a credential | §5 J6 | | Look up an exact signature | §6 | | Defaults, images, limits | §7 | | An error you do not recognise | §8 | | "Why doesn't X work on Cloud?" | §9 | | A capability that is in no section | §10 | | Another language | §11 | | Known gaps in this file | §12 | --- ## §3 Model A **box** is a virtual machine on BoxLite's fleet with its own Linux kernel, isolated in hardware. State — installed packages, written files, environment — survives `stop()` and a later `start()`, but **only when the box was created with `auto_remove=False`**; with the default `True`, a later `stop()` may discard it, so anything you want to keep needs J5's settings. Lifecycle: `create` → `start` → `exec` … → `stop` → (`start` again, or `remove`). Used as an `async with` block, the classes below create and start the box, and on exit **stop** it. They do not remove it: a box created with the default `auto_remove=True` was still listed by `rt.list_info()` after a clean exit. Removal is yours to do (R5). Defaults per box: `cpus=1`, `memory_mib=1024`. Guest is Debian 12 with Python 3.12; `exec` runs as uid 1000 (`boxlite`). See §7. ### Pick the right class Reaching for `SimpleBox` + `exec` for everything produces worse code than using the class built for the job. On Cloud there are three: | Class | Use it when | Beyond `exec` | | --- | --- | --- | | `SimpleBox` | You need to run commands | — | | `CodeBox` | An LLM wrote Python and you want to run it | `run(code)`, `run_script(path)`, `install_package(pkg)`, `install_packages(*pkgs)` | | `InteractiveBox` | You need a persistent PTY shell | `wait()` | All three share `exec`, `copy_in`, `copy_out`, `info`, `start`, `stop`, `shutdown`, and all three take `runtime=` (§4). `BrowserBox`, `ComputerBox` and `SkillBox` also exist in the package. **They do not work on Cloud from Python** — see §9 for the exact reason for each, which is different in each case. Below these sits the low-level pair `Boxlite` (the runtime) and `Box` (a single box). You need them for named lookup, snapshots, cloning and metrics. Prefer the three classes above where you can: the low-level `Box.exec()` returns a streaming `Execution` handle with no `exit_code`, not the collected `ExecResult` you get from `SimpleBox`. --- ## §4 Connect Two environment variables, created in the console at https://app.boxlite.ai. The key is shown in full exactly once. ```bash export BOXLITE_REST_URL="https://app.boxlite.ai/api" export BOXLITE_API_KEY="blk_live_..." ``` Build the runtime once and pass it to every box as `runtime=`. **[verified · 0.9.7 · 2026-08-24]** ```python verify=connect expect="4" timeout=420 import asyncio import os import boxlite from boxlite import Boxlite, BoxliteRestOptions # R1: from_env() raises for a missing URL but NOT for a missing key, so check # the key yourself. Never print its value. for required in ("BOXLITE_REST_URL", "BOXLITE_API_KEY"): assert os.environ.get(required), f"{required} is not set" async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) box = boxlite.SimpleBox(image="python", runtime=rt) await box.start() try: result = await box.exec("python", "-c", "print(2 + 2)") assert result.exit_code == 0, result.stderr print(result.stdout.strip()) finally: await rt.remove(box.id, force=True) # R5 asyncio.run(main()) ``` **Verify** — prints exactly `4`, and leaves no box behind. If it raises before that, go to §8. Four notes that cause real bugs: - **If `BOXLITE_API_KEY` looks unset, check `~/.zshrc` before concluding it is missing.** zsh sources `.zshrc` only for *interactive* shells, and the shell an agent runs commands in is not one. `source ~/.zshrc` first, then re-check. Reporting "you have no credentials" to a user who has had one configured for months is a costly and avoidable mistake. - **`runtime=` is not optional here.** Omit it and the SDK calls `Boxlite.default()`, a local in-process runtime — a different product: the box runs on the machine executing this code, is not billed, and is invisible in the console. Worse, if the BoxLite CLI is also installed and has migrated the local database, `Boxlite.default()` does not fall back gracefully; it raises `pyo3_runtime.PanicException: … Schema version mismatch: database has v9, process expects v8`. A Rust panic out of a missing keyword argument is confusing enough to be worth naming. - `exec` on the three classes in §3 is **variadic** — `exec("python", "-c", "...")` — and returns an `ExecResult` with `exit_code`, `stdout` and `stderr`. The low-level `Box` from `Boxlite.create()` differs on both counts: `exec(command, args=[...])`, and it returns a streaming `Execution` you must `await …wait()` on. Mixing the two is the most common signature error. - **`info()` is synchronous.** `await box.info()` raises `TypeError: object builtins.BoxInfo can't be used in 'await' expression`. Write `box.info().state.status`. See §6. --- ## §5 Journeys Every recipe below assumes `BOXLITE_REST_URL` and `BOXLITE_API_KEY` are exported (§4). Each is executed by the verifier on every build, cleans up the boxes it creates, and carries the badge produced by that run. ### J1 — First box, and proof the credentials work **[verified · 0.9.7 · 2026-08-24]** {#j1} **Use when** you are starting from nothing and want to confirm the account, the key and box creation all work before building on them. ```python verify=j1 expect="status running" expect="4" timeout=420 import asyncio import boxlite from boxlite import Boxlite, BoxliteRestOptions async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) box = boxlite.SimpleBox(image="python", runtime=rt) await box.start() try: info = box.info() # synchronous — do not await print("box", box.id, "status", info.state.status) result = await box.exec("python", "-c", "print(2 + 2)") assert result.exit_code == 0, result.stderr print(result.stdout.strip()) finally: await rt.remove(box.id, force=True) # R5 asyncio.run(main()) ``` **Verify** — prints a box id with `status running`, then exactly `4`. **Pitfalls** - Forgetting `runtime=rt` runs the box locally instead, or panics (§4). - `exec` here runs as uid 1000 (`boxlite`), **not root**. Anything that needs to write outside your own directories fails with a permission error — J3. - `await box.info()` is a `TypeError`. `info()` returns `BoxInfo` directly. ### J2 — Run Python an LLM wrote **[verified · 0.9.7 · 2026-08-24]** {#j2} **Use when** a model produced code and you need to execute it without letting it touch anything of yours. `CodeBox` exists for this. Do not hand-roll `exec("python", "-c", generated_code)` — quoting long generated code through argv is where that breaks. ```python verify=j2 expect="mean 9.8" expect="stdev 6.058" timeout=420 import asyncio import boxlite from boxlite import Boxlite, BoxliteRestOptions, NetworkSpec GENERATED = """ import statistics rows = [12, 7, 3, 19, 8] print("mean", statistics.mean(rows)) print("stdev", round(statistics.stdev(rows), 3)) """ async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) box = boxlite.CodeBox( # Not optional: CodeBox defaults to image="python:slim", which Cloud # rejects outright ("Unsupported image"). Only base/python/node exist. image="python", runtime=rt, # R3: an allowlist, not the open internet. pypi.org is here only so # install_packages() can work; drop it if the code needs nothing. network=NetworkSpec(mode="enabled", allow_net=["pypi.org"]), ) await box.start() try: print(await box.run(GENERATED)) finally: await rt.remove(box.id, force=True) asyncio.run(main()) ``` **Verify** — prints `mean 9.8` then `stdev 6.058`. **Pitfalls** - **`CodeBox`'s default image does not exist on Cloud.** Pass `image="python"`. - `run()` returns **stdout only**. If the code might fail, use `exec("python", "-c", code)` and read `exit_code` and `stderr` (R2) — `run()` gives you no way to tell a crash from empty output. - **`run(timeout=…)` is accepted and silently ignored.** Measured: `run("import time; time.sleep(5); print('slept')", timeout=1)` returns `'slept\n'` after five seconds. For a real limit use `exec(..., timeout=1)`, which kills the process and returns `exit_code=-15`. - Need a package? `await box.install_packages("pandas")` first — that needs `pypi.org` and `files.pythonhosted.org` in `allow_net` (J6). ### J3 — Get files in and out **[verified · 0.9.7 · 2026-08-24]** {#j3} **Use when** the box needs input data, or produced an artifact you want back. ```python verify=j3 expect="['/data/README', '/data/input.csv']" expect="4" timeout=420 import asyncio import pathlib import boxlite from boxlite import Boxlite, BoxliteRestOptions async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) src = pathlib.Path("/tmp/indata") src.mkdir(exist_ok=True) # Two files, deliberately: a directory holding exactly one file collapses # into that file at the destination. See the pitfalls below. (src / "input.csv").write_text("a,b\n1,2\n3,4\n") (src / "README").write_text("sample\n") box = boxlite.SimpleBox(image="python", runtime=rt) await box.start() try: # A directory lands directly under the destination on Cloud. await box.copy_in(str(src), "/data") listing = await box.exec("sh", "-c", "find /data -type f | sort") print(listing.stdout.split()) result = await box.exec( "python", "-c", "rows=open('/data/input.csv').read().strip().splitlines()[1:];" "print(sum(int(r.split(',')[0]) for r in rows))", ) assert result.exit_code == 0, result.stderr print(result.stdout.strip()) finally: await rt.remove(box.id, force=True) asyncio.run(main()) ``` **Verify** — prints `['/data/README', '/data/input.csv']` then `4`. **Pitfalls** - **`copy_in` writes as root; `exec` runs as uid 1000.** So `copy_in` can create `/data` at the filesystem root, but `exec("mkdir", "-p", "/data")` fails with `Permission denied`. Let `copy_in` create the directory; do not pre-`mkdir` it. - Consequently, files delivered by `copy_in` are owned `root:root` and mode `644`. Readable by your process, **not writable**. If the app must write next to its own files, write to `/tmp` instead. - **A directory containing exactly one file collapses into that file.** Measured: `copy_in("/tmp/pone", "/data3")` where `pone/` holds only `only.txt` leaves `/data3` as a *regular file*, not a directory. `cd /data3` then fails and, in J4's shape, the only symptom is a 502. Copy to an explicit file path, ship two files, or check with `ls -la` afterwards. - `overwrite=True` is the default on `copy_in` and `copy_out`. - `volumes=` (mounting a host directory) is silently ignored on Cloud — §9. ### J4 — Serve HTTP on a public URL **[verified · 0.9.7 · 2026-08-24]** {#j4} **Use when** the thing you are building is a service — a web app, an API, an agent endpoint, a webhook receiver — that clients outside the box must reach. Four things make this work, and all four are required: 1. The server binds **`0.0.0.0`**, not `127.0.0.1`. 2. It is started **in the background**, so `exec` returns instead of blocking on a process that never exits. 3. You ask the REST API for the port's preview URL. **There is no `box.tunnel()` in this SDK** (§9); the endpoint below is the supported way. 4. You **fetch that URL from outside** before claiming it works. ```python verify=j4 expect="200 hello from the box" expect="public url https" timeout=600 import asyncio import json import os import pathlib import urllib.request import boxlite from boxlite import Boxlite, BoxliteRestOptions PORT = 3000 APP = pathlib.Path("/tmp/myapp") APP.mkdir(exist_ok=True) (APP / "server.py").write_text(f""" from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"hello from the box") def log_message(self, *a): pass ThreadingHTTPServer(("0.0.0.0", {PORT}), H).serve_forever() """) (APP / "README").write_text("second file: keeps copy_in from collapsing\n") def api(method, path): """Call the BoxLite REST API with the same key the SDK uses (R8).""" base = os.environ["BOXLITE_REST_URL"].rstrip("/").removesuffix("/api") request = urllib.request.Request(base + path, method=method) request.add_header("Authorization", "Bearer " + os.environ["BOXLITE_API_KEY"]) with urllib.request.urlopen(request, timeout=30) as response: return json.loads(response.read().decode() or "{}") async def fetch(url, tries=25): last = None for _ in range(tries): try: with urllib.request.urlopen(url, timeout=8) as response: return response.status, response.read().decode() except Exception as error: # not up yet, or still routing last = error await asyncio.sleep(3) return None, f"{type(last).__name__}: {last}" async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) box = boxlite.SimpleBox(image="python", runtime=rt) await box.start() try: await box.copy_in(str(APP), "/app") listing = await box.exec("sh", "-c", "ls -la /app") assert "server.py" in listing.stdout, listing.stdout started = await box.exec( "sh", "-c", "cd /app && (nohup python3 -u server.py >/tmp/server.log 2>&1 &);" " sleep 2; echo launched", ) assert started.exit_code == 0, started.stderr url = api("GET", f"/api/box/{box.id}/ports/{PORT}/preview-url")["url"] print("public url", url) status, body = await fetch(url) if status != 200: log = await box.exec("cat", "/tmp/server.log") raise AssertionError(f"service did not answer: {body}\n{log.stdout}") print(status, body) # R7: that URL answers unauthenticated requests from anywhere. finally: await rt.remove(box.id, force=True) asyncio.run(main()) ``` **Verify** — prints a `https://-.proxy.…boxlite.ai` URL, then `200 hello from the box`. **Pitfalls** - **Bind `0.0.0.0`.** A server on `127.0.0.1` is invisible to the proxy and the only symptom is `502 Bad Gateway`. - **A 502 means the proxy is routing but nothing is listening.** Read `/tmp/server.log` in the box; do not retry blindly. - **Do not use `entrypoint=` / `cmd=` to launch the service.** Cloud replaces the container init process with `sleep infinity` and ignores both, so the box comes up with nothing running and every request 502s. Launch with `exec` as above. - **Do not guard with `pgrep -f server.py`.** `pgrep -f` matches whole command lines and the launch command contains that string, so the guard matches its own shell, decides the app is already up, and starts nothing. Probe the port instead — J5 does. - **`copy_in` of a directory containing exactly one file makes the destination that file** (J3). That is why `README` is written above. - **Treat the URL as opaque.** Its shape is deployment-defined; never build one from a box id and a port. - The endpoint is safe to call again and returns the current URL. - Poll before asserting. A single immediate request loses the race. - This box is removed in `finally`. For a service that must outlive the process, use J5. ### J5 — Deploy an app so the service stays up **[verified · 0.9.7 · 2026-08-24]** {#j5} **Use when** you built an app locally and want it running in a box as a long-lived service: one you can redeploy, and that survives your script exiting. Three settings carry the whole difference from J4: - **`name=`** so you can find the box again. - **`auto_remove=False`**, because the default means a later `stop()` can discard the box and everything installed in it. - **auto-stop disabled**, or the box stops after 900 idle seconds. This is *not* a `SimpleBox` argument — see the pitfall below. Set it with `POST /api/box/{name}/autostop/0`. ```python verify=j5 expect="200 hello from the box" expect="app started" expect="app already-up" timeout=900 import asyncio import json import os import pathlib import urllib.request import boxlite from boxlite import Boxlite, BoxliteRestOptions, BoxliteError NAME = "agent-md-j5-demo" PORT = 3000 APP = pathlib.Path("/tmp/myapp5") APP.mkdir(exist_ok=True) (APP / "server.py").write_text(f""" from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/plain") self.end_headers() self.wfile.write(b"hello from the box") def log_message(self, *a): pass ThreadingHTTPServer(("0.0.0.0", {PORT}), H).serve_forever() """) # Ship an idempotent start script with the app. Probing the port is the only # reliable "is it up?" check here (J4 pitfalls). (APP / "start.sh").write_text(f"""#!/bin/sh probe() {{ python3 -c "import socket,sys;s=socket.socket();s.settimeout(1);\ sys.exit(0 if s.connect_ex(('127.0.0.1',{PORT}))==0 else 1)" }} if probe; then echo already-up; exit 0; fi cd /app && nohup python3 -u server.py >/tmp/server.log 2>&1 & sleep 2 if probe; then echo started; else echo failed; fi """) def api(method, path): base = os.environ["BOXLITE_REST_URL"].rstrip("/").removesuffix("/api") request = urllib.request.Request(base + path, method=method) request.add_header("Authorization", "Bearer " + os.environ["BOXLITE_API_KEY"]) with urllib.request.urlopen(request, timeout=30) as response: return json.loads(response.read().decode() or "{}") async def fetch(url, tries=25): last = None for _ in range(tries): try: with urllib.request.urlopen(url, timeout=8) as response: return response.status, response.read().decode() except Exception as error: last = error await asyncio.sleep(3) return None, f"{type(last).__name__}: {last}" async def find_id(rt): """Resolve NAME -> box id, or None. list_info() is authoritative.""" for info in await rt.list_info(): if info.name == NAME: return info.id return None async def attach(rt, box_id): """Attach by ID, never by name -- see the negative-cache pitfall below.""" box = boxlite.SimpleBox(image="python", runtime=rt, name=box_id, auto_remove=False, reuse_existing=True) await box.start() return box async def open_box(rt): """Attach to the named box, creating it only if it is not there yet. Three things this has to get right, all learned the hard way: 1. Look the name up first. Do NOT attach speculatively and treat the failure as "absent": one failed name lookup is cached for the life of the process, so the name never resolves again -- not even after you create it. 2. Discriminate on the message. Catching bare RuntimeError is a bug -- "No available runners" is also a RuntimeError, and swallowing it turns a retryable capacity blip into a create that fails with the misleading "Box with name ... already exists". 3. Handle the race. Between the lookup and the create, another deploy can win, so a colliding create falls back to attaching. """ box_id = await find_id(rt) if box_id: return await attach(rt, box_id), False # same id, state kept try: box = boxlite.SimpleBox(image="python", runtime=rt, name=NAME, auto_remove=False) await box.start() return box, True except (BoxliteError, RuntimeError) as error: if "already exists" not in str(error): raise # capacity, auth, anything else return await attach(rt, await find_id(rt)), False async def deploy(rt): box, created = await open_box(rt) print("box", box.id, "created" if created else "reused") # Disable the idle timer. There is no auto_stop= constructor argument. api("POST", f"/api/box/{NAME}/autostop/0") await box.copy_in(str(APP), "/app") listing = await box.exec("sh", "-c", "ls -la /app") assert "server.py" in listing.stdout, listing.stdout # Nothing restarts your process for you, so run the guard every time. ensure = await box.exec("sh", "/app/start.sh") state = ensure.stdout.strip() assert state in ("already-up", "started"), (state, ensure.stderr) print("app", state) return box async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) try: box = await deploy(rt) # first run: created / started url = api("GET", f"/api/box/{NAME}/ports/{PORT}/preview-url")["url"] status, body = await fetch(url) print(status, body) await deploy(rt) # redeploy: reused / already-up finally: # A real deployment keeps the box. This demo is also a test, so it # cleans up after itself (R5). await rt.remove(NAME, force=True) asyncio.run(main()) ``` **Verify** — prints `created` / `app started`, then `200 hello from the box`, then the same box id with `reused` / `app already-up`. **Pitfalls** - **There is no `auto_stop=` argument.** `SimpleBox.__init__` ends in `**kwargs`, so `SimpleBox(..., auto_stop=0)` is accepted at the call site and dies inside as `TypeError: BoxOptions.__new__() got an unexpected keyword argument 'auto_stop'`. The same swallow-then-explode applies to any misspelled option. Use the REST endpoint above. - **Nothing restarts your process.** Because Cloud ignores `entrypoint`/`cmd` (J4), a box that stops and resumes comes back with an empty process table. The probe-then-launch guard is not optional — it is the only thing making the service self-healing. - **`copy_in` onto a path that already exists with the wrong type fails silently.** If an earlier run left `/app` as a file, copying a directory there leaves it empty and raises nothing. `ls -la /app` after copying, as above, or remove the box and start clean. - **Do not catch bare `RuntimeError` around the attach.** Almost every Cloud failure arrives as `RuntimeError: internal error: …` — box-not-found, invalid credentials and `No available runners` are indistinguishable by type. An `except RuntimeError` that falls through to "create it then" turns a transient capacity error into `Box with name … already exists`, which reads like a logic bug and is not one. Match on the message, as above. - **A failed name lookup is cached for the life of the process.** This is the sharpest edge in the SDK. Ask for a name that does not exist, create it, then ask again: you still get `Box with ID or name … not found`, forever, in that process — a fresh `Boxlite.rest(...)` does not clear it, because the cache lives in the process-global Rust core. Meanwhile `SimpleBox(name=NAME)` refuses to create with `Box with name … already exists`, so the two calls contradict each other and neither is usable. The server is fine: at that moment `GET /api/box/{NAME}` returns `200` and `rt.list_info()` lists the box under that name. Only the SDK's name→id resolution is stuck. So resolve the name through `list_info()` and attach by **id**, which is never cached — that is what `open_box` above does, and why it never attaches speculatively. - **`reuse_existing=True` does attach**, by id or by an uncached name: same box id, `created == False`, files written by the previous run still present, and it restarts the box if it was stopped. - **`rt.get_or_create()` takes `BoxOptions`, not a name** — `rt.get_or_create("my-box")` raises `TypeError: argument 'options': 'str' object cannot be cast as 'BoxOptions'`, and it returns a tuple. For name-based attach, use the try/except in `open_box` above. - **`start()` after `stop()` on the same object re-creates the box**, which fails with `Box with name … already exists`. To come back to a stopped box, build a fresh `SimpleBox(..., reuse_existing=True)`. - **Do not `stop()` then immediately `start()`.** That raises `State change in progress`; poll `box.info().state.status` until it reads `stopped` first. - **You own the lifetime and the bill.** Removal leaves a `DESTROYED__` entry in `rt.list_info()`; that is a tombstone, not a running box. ### J6 — Restrict the network and pass a credential **[verified · 0.9.7 · 2026-08-24]** {#j6} **Use when** the box must reach exactly one or two outside hosts, or must authenticate to one, and you do not want the credential inside the guest. ```python verify=j6 expect="allowed host ok" expect="blocked host refused" timeout=420 import asyncio import boxlite from boxlite import Boxlite, BoxliteRestOptions, NetworkSpec PROBE = """ import socket, sys host = sys.argv[1] try: socket.create_connection((host, 443), timeout=6).close() print("reached", host) except Exception as error: print("blocked", host, type(error).__name__) """ async def main(): rt = Boxlite.rest(BoxliteRestOptions.from_env()) box = boxlite.SimpleBox( image="python", runtime=rt, # R3: name only what the task needs. Everything else is DNS-sinkholed. # mode="disabled" is not an option on Cloud — the box fails to start. network=NetworkSpec(mode="enabled", allow_net=["pypi.org"]), ) await box.start() try: allowed = await box.exec("python3", "-c", PROBE, "pypi.org") print("allowed host ok" if "reached" in allowed.stdout else f"UNEXPECTED: {allowed.stdout.strip()}") blocked = await box.exec("python3", "-c", PROBE, "example.com") print("blocked host refused" if "blocked" in blocked.stdout else f"UNEXPECTED: {blocked.stdout.strip()}") finally: await rt.remove(box.id, force=True) asyncio.run(main()) ``` **Verify** — prints `allowed host ok` then `blocked host refused`. **Pitfalls** - **`mode="disabled"` breaks the box.** It does not start; you get `internal error: Box state transition failed: failed to start box` with a shim traceback (§8). An allowlist is the only lever. - **`allow_net` is host-based**, and package installs need two hosts: `pypi.org` *and* `files.pythonhosted.org`. An allowlist with only the first fails partway through a download. - **For credentials use `secrets=[Secret(...)]`, not `env=`** (R4). The `Secret` carries `hosts=`, and the value is substituted host-side so it never lands in the guest filesystem or process table. - **`env=` is a sequence of pairs, not a dict.** `env={"A": "B"}` raises `TypeError: argument 'env': 'dict' object cannot be cast as 'Sequence'`. Write `env=[("A", "B")]`. Same for `ports=` and `volumes=` (§6) — though neither of those does anything on Cloud (§9). --- ## §6 Signature reference Read from the installed package. Anything not listed here, check with `inspect.signature`, then §10. ### Constructors ```python SimpleBox(image=None, rootfs_path=None, memory_mib=None, cpus=None, runtime=None, name=None, auto_remove=True, reuse_existing=False, **kwargs) CodeBox(image="python:slim", memory_mib=None, cpus=None, runtime=None, **kwargs) # default image is invalid on Cloud InteractiveBox(image, shell="/bin/sh", tty=None, memory_mib=None, cpus=None, runtime=None, name=None, auto_remove=True, **kwargs) ``` **`**kwargs` is a trap.** It forwards to `BoxOptions`, so an option that does not exist — `auto_stop=0`, a typo — is accepted at the call site and raises `TypeError: BoxOptions.__new__() got an unexpected keyword argument …` from inside. There is no "unknown argument" error at the line you wrote. `BoxOptions` accepts: `image`, `rootfs_path`, `cpus`, `memory_mib`, `disk_size_gb`, `env`, `secrets`, `network`, `entrypoint`, `cmd`, `user`, `working_dir`, `detach`, `auto_remove`, `advanced`, plus `ports` and `volumes`. It does **not** accept `auto_stop`. Sequence-typed options take pairs, not dicts: ```python env=[("KEY", "value")] # env={"KEY": "value"} -> TypeError ports=[(18080, 8080)] # ports=[8080] or ["18080:8080"] -> RuntimeError volumes=[("/host/path", "/guest/path")] ``` ### Sync vs async This is the most common source of `TypeError` in this SDK. | Call | Awaitable? | | --- | --- | | `box.start()`, `box.stop()`, `box.shutdown()` | yes | | `box.exec(...)`, `box.copy_in(...)`, `box.copy_out(...)` | yes | | `CodeBox.run()`, `run_script()`, `install_package(s)()` | yes | | `InteractiveBox.wait()` | yes | | **`box.info()`** | **no — returns `BoxInfo`** | | `box.id`, `box.name`, `box.created` | no — properties | | `rt.list_info()`, `rt.get()`, `rt.remove()`, `rt.metrics()` | yes | `await box.info()` raises `TypeError: object builtins.BoxInfo can't be used in 'await' expression`. ### Methods ```python SimpleBox : copy_in copy_out created exec id info shutdown start stop CodeBox : … + install_package install_packages run run_script InteractiveBox : … + wait Box (low-level) : attach clone_box copy_in copy_out exec export id info metrics name snapshot start stop Boxlite : close create default get get_info get_or_create images import_box init_default list_info metrics remove rest shutdown ``` Neither `Box` nor `SimpleBox` has `tunnel` — see §9 and R6. `rt.get_or_create(BoxOptions(...))` takes options and returns a tuple; it does not take a name. `ExecResult` carries `exit_code`, `stdout`, `stderr`. `exec(..., timeout=N)` enforces a real limit and returns `exit_code=-15` when it fires. ### REST endpoints this file uses Base is `BOXLITE_REST_URL` minus its trailing `/api`; auth is `Authorization: Bearer $BOXLITE_API_KEY`. | Endpoint | Purpose | | --- | --- | | `GET /api/box` | list boxes | | `GET /api/box/{idOrName}` | box detail | | `GET /api/box/{idOrName}/ports/{port}/preview-url` | public URL for a port (R6) | | `GET /api/box/{idOrName}/ports/{port}/signed-preview-url` | token-scoped URL (R7) | | `POST /api/box/{idOrName}/ports/{port}/signed-preview-url/{token}/expire` | revoke one | | `POST /api/box/{idOrName}/autostop/{seconds}` | `0` disables the idle timer | | `POST /api/box/{idOrName}/autodelete/{seconds}` | idle deletion | | `POST /api/box/{idOrName}/public/{isPublic}` | box public flag | | `GET /api/box/{id}/telemetry/logs` | logs | `preview-url` returns `{"boxId": ..., "url": ..., "token": ...}`. --- ## §7 Defaults, images and limits Measured on Cloud, not quoted from a spec sheet. | | | | --- | --- | | `cpus` default | `1` | | `memory_mib` default | `1024` | | Guest OS | Debian 12 (bookworm) | | Guest Python | 3.12.13 | | `exec` user | uid 1000 (`boxlite`) | | `copy_in` writes as | `root:root`, mode `644` | | Idle auto-stop | 900 s, unless disabled (J5) | **Images.** `image=` is an alias, not a Docker reference. Exactly three exist: | Alias | Resolves to | | --- | --- | | `base` | `ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0` | | `python` | `ghcr.io/boxlite-ai/boxlite-agent-python:v0.1.0` | | `node` | `ghcr.io/boxlite-ai/boxlite-agent-node:v0.1.0` | Anything else — `ubuntu`, `python:slim`, a registry path — fails with `internal error: Unsupported image '…'. Supported images: …`. This is why `CodeBox` needs an explicit `image="python"` (J2). --- ## §8 Errors | Message | Cause | Fix | | --- | --- | --- | | `configuration error: BOXLITE_REST_URL not set` | `from_env()` with no URL | export it (§4) | | `internal error: Invalid credentials` | key missing or wrong. `from_env()` does not check it, so this appears on the first real call, not at startup | check `BOXLITE_API_KEY`; if it looks unset, `source ~/.zshrc` first (R1) | | `TypeError: object builtins.BoxInfo can't be used in 'await' expression` | `await box.info()` | `info()` is synchronous (§6) | | `TypeError: BoxOptions.__new__() got an unexpected keyword argument 'x'` | option does not exist; `**kwargs` forwarded it | check §6; for `auto_stop` use the REST endpoint (J5) | | `TypeError: argument 'env': 'dict' object cannot be cast as 'Sequence'` | `env={...}` | `env=[("K", "V")]` (§6) | | `TypeError: argument 'options': 'str' object cannot be cast as 'BoxOptions'` | `rt.get_or_create("name")` | it takes `BoxOptions` (§6); for name attach see J5 | | `RuntimeError: ports entries must be tuple or dict` | `ports=[8080]` | `ports=[(host, guest)]` — but it is a no-op on Cloud (§9) | | `internal error: Unsupported image 'x'. Supported images: …` | not one of the three aliases | §7 | | `internal error: Box state transition failed: failed to start box` + shim log | usually `NetworkSpec(mode="disabled")` | use an allowlist (R3, J6) | | `internal error: Box with ID or name … not found` | `rt.get()` or attach-by-name on a missing box | create it, or use J5's `open_box` | | the same `not found`, but the box **does** exist (`list_info()` lists it, `GET /api/box/{name}` returns 200) | the SDK cached an earlier failed lookup of that name; the cache is process-global and never expires | resolve the name via `rt.list_info()` and attach by **id** (J5). Never probe a name speculatively | | `internal error: No available runners` | fleet capacity, hit by creating several boxes at once | **transient — retry with backoff.** Do not treat it as "box missing"; see J5's `open_box` pitfall. Creating 4 boxes concurrently reproduced it | | `Box with name … already exists` | `start()` on a stopped box's original object | build a fresh `SimpleBox(reuse_existing=True)` (J5) | | `State change in progress` | `stop()` then immediate `start()` | poll `box.info().state.status` until `stopped` | | `pyo3_runtime.PanicException: … Schema version mismatch: database has v9, process expects v8` | `runtime=` omitted, so the SDK tried the *local* runtime and its DB was migrated by a newer CLI | pass `runtime=rt` (§4) | | `502 Bad Gateway` at a preview URL | proxy routes, nothing listens | bind `0.0.0.0`; read `/tmp/server.log`; check `/app` is a directory (J4) | | `ValueError: OAuth token required` | `SkillBox` | §9 | --- ## §9 Does not work on Cloud The dangerous ones are silent. They are marked. | Thing | What actually happens | | --- | --- | | `box.tunnel(port)` | **Does not exist.** Not on `Box`, not on `SimpleBox`, in SDK 0.9.7 — the latest on PyPI. Use the `preview-url` endpoint (R6, J4). | | `ports=[(h, g)]` | **Silent no-op.** The box starts normally and nothing is published. Use J4. | | `volumes=[(h, g)]` | **Silent no-op.** The box starts and the mount point simply does not exist in the guest. Use `copy_in` (J3). | | `CodeBox.run(timeout=N)` | **Silently ignored.** A 5-second sleep under `timeout=1` returns normally after 5 s. Use `exec(..., timeout=N)`. | | `entrypoint=` / `cmd=` | **Silently ignored.** Cloud replaces init with `sleep infinity`. Launch with `exec` (J4). | | `NetworkSpec(mode="disabled")` | Box fails to start (§8). Use an allowlist. | | `auto_remove=True` as cleanup | Box is stopped, not removed; still in `rt.list_info()`. Remove explicitly (R5). | | `image="python:slim"`, `"ubuntu"`, registry paths | `Unsupported image`. Three aliases only (§7). | | `BrowserBox` | Requests `mcr.microsoft.com/playwright:…` → `Unsupported image`. | | `ComputerBox` | Requests `lscr.io/linuxserver/webtop:…` → `Unsupported image`. | | `SkillBox` | Different cause: `ValueError: OAuth token required. Set CLAUDE_CODE_OAUTH_TOKEN`. | | `rt.get_or_create(name)` | Wrong type; it takes `BoxOptions` (§6). | | attach-by-name after a failed lookup | **Poisoned for the process.** The SDK caches the negative result; the box becomes unreachable by name even though REST and `list_info()` resolve it. Attach by id (J5). | --- ## §10 When this file is not enough The SDK is a client for a REST API and does not always keep pace with it — `tunnel()` is the standing example: documented behaviour, absent method, working endpoint. Before reporting a capability as impossible: 1. Check the installed surface: `python3 -c "import boxlite, inspect; print([m for m in dir(boxlite.SimpleBox) if not m.startswith('_')])"` 2. Check the API. It publishes Swagger UI at `BOXLITE_REST_URL`'s root, and the machine-readable spec is embedded in `/api/swagger-ui-init.js`: ```python import json, os, re, urllib.request base = os.environ["BOXLITE_REST_URL"].rstrip("/").removesuffix("/api") request = urllib.request.Request(base + "/api/swagger-ui-init.js") request.add_header("Authorization", "Bearer " + os.environ["BOXLITE_API_KEY"]) text = urllib.request.urlopen(request, timeout=30).read().decode() start = text.index("{", text.index('"swaggerDoc"')) depth = 0 for i in range(start, len(text)): depth += (text[i] == "{") - (text[i] == "}") if depth == 0: break spec = json.loads(text[start:i + 1]) for path in sorted(spec["paths"]): if "port" in path or "preview" in path: print(path, list(spec["paths"][path])) ``` 3. If an endpoint does what you need, call it with `urllib` and the same key (J4's `api()` helper). **Tell the user you went around the SDK and why** (R8) — that is a fact they need when the SDK catches up. --- ## §11 Other languages The Node, Rust, Go and C SDKs expose the same platform with different names and casing. Do not hand-translate the Python here. Nothing in this file has been verified against them. --- ## §12 Known gaps in this file Stated openly rather than left as dangling cross-references. None of the following is covered, and none is linked from §2: - **Snapshot, clone, rollback.** `Box.snapshot()`, `Box.clone_box()` and `Box.export()` exist in the SDK; their Cloud behaviour is unverified here. - **`InteractiveBox` / PTY sessions.** The class works on Cloud, but no verified journey exists for driving a shell. - **Concurrency ceilings and quotas.** §7 covers per-box defaults. The only measured data point: creating **4** boxes concurrently on dev returned `No available runners` for two of them (§8). That is a fleet-capacity signal, not a documented per-account quota, and it will differ on production. Retry with backoff; do not size a plan against that number. R5's "~10 concurrent, then ask" remains a caution, not a measured limit. - **Metrics.** `rt.metrics()` and `Box.metrics()` are unverified. - **Billing.** Rates and how stopped-versus-running boxes are charged. If you need one of these, §10 is the procedure.