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
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.
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: theentrypoint + 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:
Two rules follow:
- Once the main command has exited,
execis refused:RuntimeError: invalid state: Cannot exec box <id>: it is stopped, and its main command has already exited. Bring the box back withstart(). - You cannot stop the main command from inside the box.
kill -TERM 1is 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 callsstop().
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.
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:
attach()exists on the box returned byruntime.create().SimpleBoxhas noattach(), and neither does the sync API, so reading a main command’s exit code means usingBoxliteandBoxOptionsdirectly.- Exit codes from
execare separate and easier:ExecResult.exit_code, no attach needed. See Run any language.
Three ways to start work in a box
Which one you pick decides whether the box outlives the work.
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:
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.
Key point:Verified: real run printsBoxliteis a synchronous context manager (with), whereas aBox’s methods (exec/stop/metrics) are async (requireawait).box.info()is async too —awaitit.
created: <box-id>, 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:
Common combinations:
auto_remove=Truetogether withdetach=Trueis invalid (a detached Box needs manual lifecycle control); setting both fails validation with an error.
Restarting a stopped Box (rootfs reuse)
For anauto_remove=False Box, the disk persists after stop(). Re-acquire a handle and exec again, and the VM restarts reusing the original rootfs:
Note: data written to tmpfs paths such asVerified: real run prints/tmpdoes not persist across restarts; only data written to ordinary paths on the rootfs (such as/root) persists.
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).
via second handle.
Cross-process sharing
One process creates and initializes a Box withdetach=True and exits; the Box keeps running, and another process takes it over with runtime.get(box_id).
Important: do not useThe 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.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. Useboxlite.Boxlite(boxlite.Options())instead to construct a releasable runtime instance.
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: <id>, 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.
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)
Note:* Provide one ofnameis not aBoxOptionsfield; it is a separate parameter ofcreate(options, name=...)/get_or_create(options, name=...). Once named, you can operate by name withget(name)/remove(name).
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)
Box methods (async, require await)
BoxInfo / BoxStateInfo (state access)
box.info() returns a BoxInfo; its state field is a BoxStateInfo, and the state string is at state.status:
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.
Calling remove() on a Box / using list()
Removal and listing both happen on the runtime, with the method names remove/list_info:
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):
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 thedefault()lock exits abnormally (crash, kill, or exiting withoutclose()/with), a lock file may remain under$BOXLITE_HOME/locks/, causing the nextBoxlite.default()to reportAnother 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 withrm -f $BOXLITE_HOME/locks/*($BOXLITE_HOMEdefaults to~/.boxlite). A runtime that exits normally viawith/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'.
exec exits non-zero without raising
exec reports a non-zero exit through exit_code / exitCode, not by raising. See Error Handling.
The box stopped without anyone calling stop()
Its main command exited. exec then fails with invalid state: Cannot exec box <id>: 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.
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.
No virtualization causes startup failure (environment constraint)
BoxLite requires hardware virtualization:- Linux: requires KVM (
/dev/kvmavailable; under WSL2 the user must be in thekvmgroup); - macOS: uses Apple Hypervisor.framework, no
/dev/kvmrequired (macOS arm64 supported); macOS Intel is not supported.
try/except to inform the user).
Advanced: lifecycle when using SimpleBox
When you use the higher-levelSimpleBox (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:
SimpleBox.exec’s timeout parameter istimeout(float) and itsenvis adict; whereas the underlyingBox.exec’s timeout parameter istimeout_secsand itsenvis alist[tuple]. For detach / cross-process / explicit restart, continue to useBoxlite+Boxdirectly, because those operations require access to the Box id and the runtime.

