Sandbox API reference
Every option and method on Sandbox — create, run commands, the fs helper, snapshots, and direct kernel/shell access.
Sandbox is the high-level entry point. Sandbox.create() builds a fully wired
box; the returned object exposes commands, fs, env, cwd, and direct
kernel / shell handles.
Sandbox.create(options?)
const sandbox = await Sandbox.create(options);SandboxOptions:
| Option | Type | Default | Notes |
|---|---|---|---|
persist | boolean | false | Persist the disk to IndexedDB (browser). |
env | Record<string, string> | — | Extra env, merged with defaults. |
cwd | string | /home/user | Initial working directory. |
files | Record<string, string | Uint8Array> | — | Pre-populate the disk. |
terminal | ITerminal | HTMLElement | string | — | Attach a terminal (element/selector lazily creates xterm.js). |
mounts | Array<{ virtualPath; hostPath; readOnly?; fsModule? }> | — | Mount host directories (Node). |
sandbox.commands
sandbox.commands.run(cmd: string, options?: RunOptions): Promise<CommandResult>;
sandbox.commands.register(name: string, handler: Command): void;RunOptions: cwd, env, signal (AbortSignal), timeout (ms),
onStdout(chunk), onStderr(chunk), stdin (string).
CommandResult: { stdout: string; stderr: string; exitCode: number }.
const { stdout, exitCode } = await sandbox.commands.run("ls -la", { cwd: "/tmp" });
sandbox.commands.register("greet", async (ctx) => {
ctx.stdout.write("hi\n");
return 0;
});See Commands for the Command / CommandContext contract.
sandbox.fs
A promise-based, node:fs-shaped helper over the VFS:
sandbox.fs.readFile(path): Promise<string>;
sandbox.fs.readFile(path, null): Promise<Uint8Array>; // binary
sandbox.fs.writeFile(path, content): Promise<void>;
sandbox.fs.readdir(path): Promise<Array<{ name; type }>>;
sandbox.fs.stat(path): Promise<{ type; size; mtime }>;
sandbox.fs.mkdir(path, { recursive? }): Promise<void>;
sandbox.fs.rm(path, { recursive? }): Promise<void>;
sandbox.fs.exists(path): Promise<boolean>;
sandbox.fs.rename(oldPath, newPath): Promise<void>;
sandbox.fs.cp(src, dest): Promise<void>;
sandbox.fs.writeFiles(files): Promise<void>; // batchSnapshots
sandbox.exportSnapshot(options?: SnapshotOptions): Promise<Uint8Array>; // .tar.gz
sandbox.importSnapshot(data: Uint8Array): Promise<SnapshotMetadata | null>;SnapshotOptions: exclude (path segments to skip, e.g. ['node_modules']) and
metadata (session state to embed; defaults to the box's cwd + env, or false
for a files-only archive).
The archive carries cwd and env in a lifo-snapshot.json manifest, so the
same file restores in the browser, in Node and through the CLI. importSnapshot
applies them and returns the manifest, or null for a files-only archive. See
Snapshots.
sandbox.fetch(input, init?)
Make an HTTP request to a server running inside the box. No service worker, no port forwarding, no host networking.
sandbox.fetch(input: string | URL, init?: SandboxFetchInit): Promise<Response>;await sandbox.waitForPort(54321);
const res = await sandbox.fetch("http://localhost:54321/rest/v1/todos", {
headers: { apikey: ANON_KEY },
});
const todos = await res.json();The return value is a real Response,
so .json(), .text(), .arrayBuffer() and .headers all work as usual.
SandboxFetchInit:
| Option | Type | Default | Notes |
|---|---|---|---|
method | string | GET | |
headers | HeadersInit | Record<string, string> | — | content-length is added for you (in-VM body parsers need it). |
body | string | Uint8Array | ArrayBuffer | — | |
port | number | — | Required when input is a bare path. |
timeout | number | 120000 | ms to wait for the server. |
The port picks the server. The URL's port selects which in-VM server answers,
so the host must be loopback (localhost, 127.0.0.1). A bare path needs an
explicit port — there is no ambient "current port", and guessing one would send
requests to the wrong server:
await sandbox.fetch("/rest/v1/todos", { port: 54321 }); // ok
await sandbox.fetch("/rest/v1/todos"); // throws
await sandbox.fetch("https://example.com/"); // throws — use global fetchTransport problems are responses, not exceptions. This matches the service worker, so the host and the browser see the same thing for the same box:
| Situation | Result |
|---|---|
| Nothing listening on the port | 404 with x-lifo: no-server |
| Server didn't answer in time | 504 with x-lifo: timeout |
| Handler threw | 500 with x-lifo: handler-error |
The x-lifo header is what distinguishes these from a status the app itself
produced — an app's own 404 carries no such header.
Gotcha
sandbox.fetch is a host → VM call. It is unrelated to the fetch that code
inside the box sees, which is provided by the Node
runtime.
sandbox.connect(port, url?, options?)
Open a WebSocket to a server running inside the box — the other half of
fetch, for HMR and realtime.
sandbox.connect(port: number, url?: string, options?: SandboxConnectOptions): Promise<VmWebSocket>;await sandbox.waitForPort(5173);
const ws = await sandbox.connect(5173, "/hot"); // Vite HMR
ws.onmessage = (e) => console.log(e.data);
ws.send("ping");
const first = await ws.nextMessage(); // convenient in testsThe promise resolves after the server's handshake, so a send() immediately
afterwards is safe. options takes protocol (a sub-protocol to offer) and
timeout (handshake wait, default 30s).
VmWebSocket is WebSocket-shaped — send, close, readyState,
onopen/onmessage/onclose/onerror, addEventListener — plus
nextMessage(timeoutMs?), which resolves with the next message. It is not a real
WebSocket: there is no socket and no URL a browser could open.
- Text frames arrive as
string, binary asUint8Array. There is nobinaryTypeto switch, since nothing here is aBlob. - A handler attached after connecting still sees earlier messages. Servers often send a greeting in the same write as the handshake, which lands before your code has the socket back — so message events are held until a handler exists. Unusual for an EventTarget, and deliberate: the alternative is silently losing it.
- Rejects if nothing on the port handles upgrades. Use
waitForPortfirst if the server is still starting.
Tip
Building your own transport rather than calling this? openWsPipe from
@lifo-sh/core is the primitive underneath — it forges the upgrade, splits the
handshake from frames that share a write, reassembles fragments and auto-pongs.
sandbox.waitForPort(port, options?)
sandbox.waitForPort(port: number, options?: { timeout?: number }): Promise<void>;Resolves once something is listening on port inside the box; rejects on timeout
(default 30s). "Listening" is all the port registry knows — a server that binds
before it can serve will still need a retried request, which is why the preview
transports poll a real request rather than just waiting for the bind.
sandbox.env, sandbox.cwd
env is the box's environment object; cwd is the current working directory
(reflecting cd within the shell session).
Escape hatches: kernel and shell
For anything the high-level API doesn't cover, sandbox.kernel and
sandbox.shell are exposed directly:
sandbox.kernel.vfs; // synchronous VFS
sandbox.kernel.processRegistry; // the process table
sandbox.kernel.networkStack; // interfaces, ports, DNS
sandbox.kernel.serviceManager; // systemctl-backed servicesSee The kernel, Processes, and Networking for what those expose.
Tip
Prefer commands.run() and fs for most work — they're stable and
environment-agnostic. Reach into kernel / shell only for lower-level control.