Bake disposable VM snapshots. Boot them in under 100 ms.
Declarative microsandbox snapshots for Node and Bun.
┌────────────────────┐ buildSnapshot ┌──────────────────┐
│ SnapshotConfig │ ────────────────▶ │ snapshot on │
│ (image, steps) │ │ ~/.microsandbox │
└────────────────────┘ └──────────────────┘
│
launchSandbox│ (≈100ms)
▼
┌──────────────┐
│ microVM │
│ hardware │
│ isolated │
└──────────────┘
A small TypeScript wrapper around the official
microsandbox Node SDK. You
describe a snapshot — base image, resources, setup steps — and lightbox
drives the SDK for you: create a builder VM, apply your steps, snapshot the
disk, clean up. Later, you launch named sandboxes from that snapshot in under
100 ms with no per-boot install cost.
The headline use case: bake a snapshot with copilot, gemini, codex, and
pi pre-installed, then spin up isolated coding-agent VMs on demand without
re-installing anything.
| Containers | lightbox snapshots |
|
|---|---|---|
| Isolation | shared kernel | hardware-isolated microVM |
| Boot time | ~1 s | < 100 ms |
| Image format | OCI | OCI + per-snapshot diff |
| Run untrusted code | risky | designed for it |
| Pre-bake heavy installs | rebuild image | snapshot once, boot fresh forever |
Install directly from this git repository:
npm install github:beamhop/lightbox
# or pin a tag / branch / sha
npm install github:beamhop/lightbox#v0.2.0npm install runs the package's prepare script, which compiles dist/ on
the consumer's machine — no global tools required beyond Node ≥ 18 and npm.
microsandbox is the only runtime dep; it pulls the platform-appropriate
msb + libkrunfw binaries on first use (cached under ~/.microsandbox/).
{
"dependencies": {
"@beamhop/lightbox": "github:beamhop/lightbox"
}
}import { buildSnapshot, runInSandbox } from "@beamhop/lightbox";
// Setup steps accept strings as a shell-shorthand — no { kind: "shell", ... } needed.
await buildSnapshot({
name: "py-data",
image: "python:3.12",
resources: { cpus: 2, memory: "1G" },
setup: ["pip install --no-cache-dir numpy pandas"],
});
// runInSandbox handles launch + teardown for you, even if the callback throws.
const out = await runInSandbox(
{ snapshot: "py-data", name: "analysis-1" },
(sb) => sb.exec("python", ["-c", "import pandas; print(pandas.__version__)"]),
);
console.log(out.stdout());buildSnapshot and launchSandbox are idempotent by default — re-running
a script overwrites any prior snapshot or sandbox of the same name. Opt out
with { overwrite: false } if you want collisions to error.
- Snapshot — a frozen disk image built from a base OCI image plus your
setup steps. Stored under
~/.microsandbox/snapshots/<name>. Booting from a snapshot is fast because the upper layer is pre-populated. - Builder sandbox — the throwaway VM
lightboxspins up to apply your setup steps. Named<snapshot>-builder; removed after snapshotting unless you pass{ debugBuilder: true }. - Launched sandbox — a persistent VM booted from a snapshot. Survives until you remove it (if detached), or until your process exits (if not).
- Lifecycle ownership — when you call
launchSandbox()you get back a liveSandboxhandle whose lifecycle is tied to your process. Callsb.detach()to let it survive process exit, or userunInSandboxto scope the sandbox to a callback.
Identity helper for type inference and IDE autocomplete. It returns its argument unchanged but narrows the inferred type so discriminated unions stay narrow and string literals don't widen.
import { defineSnapshot } from "@beamhop/lightbox";
const cfg = defineSnapshot({
name: "node-ci",
image: "node:22",
resources: { cpus: 4, memory: "4G" },
workdir: "/work",
env: { CI: "true" },
setup: [
"mkdir -p /work", // string shorthand → shell step
"npm i -g pnpm@9",
],
labels: { team: "platform", purpose: "ci" },
});| Field | Type | Notes |
|---|---|---|
name |
string |
Snapshot artifact name. |
image |
string |
Base OCI image (e.g. "oven/bun", "python:3.12"). |
resources.cpus |
number? |
vCPUs for the builder VM. |
resources.memory |
`${number}M` | `${number}G` |
Memory for the builder VM, e.g. "512M" or "2G". |
workdir |
string? |
Working dir for setup steps. Must be created by a setup step — msb does not auto-mkdir. |
env |
Record<string, string>? |
Env vars during setup. |
setup |
SetupStep[]? |
Steps run in order in the builder before snapshot. |
labels |
Record<string, string>? |
Labels stored on the snapshot artifact. |
type SetupStep =
| string // shell shorthand
| { kind: "shell"; script: string; description?: string }
| { kind: "exec"; cmd: string; args?: string[]; description?: string };Most steps are shell one-liners — use the string form. Use the object form
when you want a description (printed before the step runs when
verbose: true) or when you need direct argv execution via kind: "exec".
Two helpers are provided for the object forms:
import { shell, exec } from "@beamhop/lightbox";
shell("npm i -g pnpm", "install pnpm");
exec("cargo", ["install", "cargo-nextest", "--locked"], "tooling");Builds the snapshot. Idempotent by default — overwrites any existing snapshot of the same name. Workflow:
- Ensure the microsandbox runtime is installed.
- If a snapshot with the same name exists, remove it (or error if
overwrite: false). - Create a builder sandbox from
config.image. - Run each
setupstep in order. syncto flush the page cache. Required — without this, the last setup step's writes can vanish from the snapshot.- Stop the builder and snapshot it under
config.name. - Remove the builder sandbox (unless
debugBuilder: true).
| Field | Default | Notes |
|---|---|---|
overwrite |
true |
Overwrite an existing snapshot with the same name. Pass false to make collisions throw. |
debugBuilder |
false |
Keep the builder VM after snapshotting so you can inspect it. |
verbose |
false |
Stream setup-step output to host stdout/stderr. |
Boots a sandbox from a snapshot and returns the live Sandbox handle.
Idempotent by default — wipes any existing sandbox of the same name.
⚠ Lifecycle: the returned handle is owned by your process. If you exit
without calling sb.detach() or sb.stop(), the SDK stops the VM. For a
scoped "launch → run → tear down" pattern, prefer
runInSandbox.
import { launchSandbox } from "@beamhop/lightbox";
const sb = await launchSandbox({
snapshot: "lightbox",
name: "agent-1",
resources: { cpus: 2, memory: "2G" },
});
const out = await sb.exec("copilot", ["--version"]);
console.log(out.stdout());
await sb.detach(); // VM keeps running after this script exits.
// or: await sb.stop(); to shut it down now.| Field | Type | Notes |
|---|---|---|
snapshot |
string |
Snapshot name to boot from. |
name |
string |
Sandbox name. |
resources |
{ cpus?, memory? } |
Per-sandbox overrides. |
workdir |
string? |
Working dir inside the guest. Must exist in the snapshot. |
env |
Record<string, string>? |
Env vars for the sandbox. |
ports |
Record<number, number>? |
{ host: guest } port mappings. |
mounts |
Record<string, Mount>? |
Mounts keyed by guest path. See Mounts below. |
overwrite |
true |
Replace an existing sandbox of the same name. Pass false to make collisions throw. |
verbose |
false |
Log [lightbox] launching ... to stdout. |
Attach host directories or named persistent volumes to the sandbox at boot:
import { launchSandbox } from "@beamhop/lightbox";
const sb = await launchSandbox({
snapshot: "node-ci",
name: "ci-run-1",
mounts: {
"/work": "./my-project", // bind-mount a host directory
"/cache": { volume: "npm-cache" }, // named volume (auto-created if missing)
},
});type Mount =
| string // bind-mount a host directory at the guest path
| { volume: string }; // named, persistent volume (auto-created on first use)Named volumes persist across sandbox lifecycles — perfect for caches you want to share between launches (npm/pip/cargo caches, model weights, build artifacts). Bind mounts let you ship local code into the sandbox without baking it into the snapshot.
If you need to configure a volume's quota or labels, create it explicitly
with ensureVolume() before launching (see Helpers). Otherwise
just reference the name in mounts and the volume is created on demand.
Launch a sandbox, run a callback against it, then stop the sandbox — even if
the callback throws. Returns whatever the callback returns. Removes the need
to remember await using or to manually call .detach() / .stop().
import { runInSandbox } from "@beamhop/lightbox";
const out = await runInSandbox(
{ snapshot: "lightbox", name: "ephemeral" },
(sb) => sb.shell("pi --version && copilot --version"),
);
console.log(out.stdout());
// Sandbox is stopped by the time we get here, even if `sb.shell` threw.Connect to a sandbox that another process launched (and detached). One call
instead of Sandbox.get(name).connect().
import { connectToSandbox } from "@beamhop/lightbox";
const sb = await connectToSandbox("agent-1");
console.log((await sb.shell("pi --version")).stdout());import {
listSnapshots, // enumerate all snapshots on disk
snapshotExists, // does a snapshot with this name exist?
removeSnapshot, // delete a snapshot artifact + DB row
removeSandbox, // stop (if needed) and delete a sandbox
ensureRuntime, // memoized install() of the host msb + libkrunfw
listVolumes, // enumerate all named volumes
volumeExists, // does a volume with this name exist?
ensureVolume, // create a volume with quota/labels if it doesn't exist
removeVolume, // delete a named volume
} from "@beamhop/lightbox";
await listSnapshots(); // → SnapshotRecord[]
await snapshotExists("lightbox"); // → boolean
await removeSnapshot("lightbox", { force: true }); // force also removes children
await removeSandbox("agent-1", { force: true }); // force kills if running
// Volume lifecycle — only needed if you want quotas/labels. Mounts on
// launchSandbox auto-create volumes on demand otherwise.
await ensureVolume("npm-cache", { quotaMib: 2048, labels: { tier: "ci" } });
await listVolumes(); // → VolumeRecord[]
await volumeExists("npm-cache"); // → boolean
await removeVolume("npm-cache");
// Idempotent; safe to call repeatedly. Usually you don't need to call this
// yourself — buildSnapshot/launchSandbox/runInSandbox call it for you.
await ensureRuntime();import { Sandbox, Snapshot, Volume, VolumeHandle } from "@beamhop/lightbox";
import type { ExecHandle, ExecOutput } from "@beamhop/lightbox";Anything not re-exported can still be imported from microsandbox directly.
Importable from @beamhop/lightbox/presets. Builds a PresetConfig that
installs four coding-agent CLIs on top of oven/bun:
| CLI | npm package | bin |
|---|---|---|
| GitHub Copilot | @github/copilot |
copilot |
| Gemini | @google/gemini-cli |
gemini |
| Codex | @openai/codex |
codex |
| Pi coding agent | @earendil-works/pi-coding-agent |
pi |
import { buildSnapshot } from "@beamhop/lightbox";
import { codingAgentsPreset } from "@beamhop/lightbox/presets";
await buildSnapshot(codingAgentsPreset());
// Snapshot "lightbox" is now ready.Override the defaults:
codingAgentsPreset({
name: "agents-xl",
image: "oven/bun",
resources: { cpus: 4, memory: "4G" },
labels: { tier: "premium" },
});CodingAgentsPresetOptions:
| Field | Default |
|---|---|
name |
"lightbox" |
image |
"oven/bun" |
resources |
{ cpus: 2, memory: "1G" } |
labels |
undefined |
The return type is PresetConfig — a SnapshotConfig with setup
guaranteed defined — so you can append steps without a non-null assertion:
import { buildSnapshot, shell } from "@beamhop/lightbox";
import { codingAgentsPreset } from "@beamhop/lightbox/presets";
const cfg = codingAgentsPreset({ name: "lightbox-plus" });
cfg.setup.push(shell("apt-get update && apt-get install -y ripgrep", "ripgrep"));
await buildSnapshot(cfg);import { buildSnapshot, defineSnapshot } from "@beamhop/lightbox";
await buildSnapshot(defineSnapshot({
name: "rust-ci",
image: "rust:1.82",
resources: { cpus: 4, memory: "4G" },
setup: [
"rustup component add clippy rustfmt",
"cargo install cargo-nextest --locked",
],
}));import { launchSandbox } from "@beamhop/lightbox";
await Promise.all(
["agent-1", "agent-2", "agent-3"].map(async (name) => {
const sb = await launchSandbox({ snapshot: "lightbox", name });
await sb.detach(); // let each VM outlive this script
}),
);import { runInSandbox } from "@beamhop/lightbox";
const versions = await runInSandbox(
{ snapshot: "lightbox", name: "ephemeral" },
(sb) => sb.shell("pi --version && copilot --version"),
);
console.log(versions.stdout());
// Sandbox is already stopped by the time this prints.import { connectToSandbox } from "@beamhop/lightbox";
const sb = await connectToSandbox("agent-1");
console.log((await sb.shell("pi --version")).stdout());Pass debugBuilder: true to inspect the builder VM after a failure:
import { buildSnapshot, connectToSandbox } from "@beamhop/lightbox";
await buildSnapshot(cfg, { debugBuilder: true }).catch(console.error);
// Then attach:
const builder = await connectToSandbox(`${cfg.name}-builder`);
await builder.attachShell(); // drop into an interactive shell- Don't set
workdirto a path that doesn't exist in the base image. The SDK validates the working dir at create time.lightboxdoesn't passworkdirto the builder for this reason — create the dir as the first setup step. - The page cache must be synced before snapshotting.
buildSnapshotrunssyncfor you; without it, the last setup step's writes can vanish. launchSandbox()ties lifecycle to your process. Callsb.detach()to let the sandbox outlive your script, or userunInSandbox()for a scoped sandbox.bun install -gpostinstalls are blocked by default (a Bun default). Most CLIs install their bin viapackage.jsonbinregardless. Runbun pm -g untrustedinside the sandbox to see what was skipped.
0.3.0 adds mount support (named volumes + host bind-mounts) on
launchSandbox/runInSandbox. No existing code breaks — mounts is a new
optional field. New exports: ensureVolume, listVolumes, removeVolume,
volumeExists, plus the Volume / VolumeHandle SDK re-exports and the
Mount, VolumeRecord, EnsureVolumeOptions types.
Version 0.2.0 was a breaking, ergonomics-focused release. Changes (still relevant when migrating from 0.1.x):
| 0.1.x | 0.2.0+ |
|---|---|
launchSandboxDetached(opts) |
const sb = await launchSandbox(opts); await sb.detach(); |
await using sb = await launchSandbox(...) |
await runInSandbox(opts, fn) |
Sandbox.get(name).then(h => h.connect()) |
connectToSandbox(name) |
buildSnapshot(cfg, { force: true }) |
buildSnapshot(cfg) — overwrite: true is the default |
buildSnapshot(cfg, { keepBuilder: true }) |
buildSnapshot(cfg, { debugBuilder: true }) |
launchSandbox({ ..., replace: true }) |
launchSandbox({ ... }) — overwrite: true is the default |
setup: [{ kind: "shell", script: "..." }] |
setup: ["..."] (string shorthand) |
cfg.setup!.push(...) |
cfg.setup.push(...) (presets return PresetConfig) |
memory: 1024 (raw MiB) |
memory: "1G" (string only) |
verbose: true by default |
verbose: false by default |
BuildOptions.builderSuffix |
removed — hard-coded to -builder |
If you need an old-style behavior:
- Re-run-safe builds: just rely on the new default; pass
overwrite: falseif you want the old "throw on collision" behavior. - Streaming output: pass
verbose: trueexplicitly.
All types are re-exported from the package root:
import type {
SnapshotConfig, // input to defineSnapshot / buildSnapshot
PresetConfig, // SnapshotConfig with guaranteed `setup`
SetupStep, // string | shell | exec
SnapshotResources, // { cpus?, memory? }
BuildOptions, // second arg to buildSnapshot
LaunchOptions, // arg to launchSandbox / runInSandbox
Mount, // string (bind) | { volume: string } (named)
SnapshotRecord, // shape returned by listSnapshots()
VolumeRecord, // shape returned by listVolumes()
EnsureVolumeOptions, // second arg to ensureVolume()
Memory, // `${number}M` | `${number}G`
} from "@beamhop/lightbox";lightbox/
├── src/ ← the library source
├── examples/basic/ ← end-to-end demo scripts
└── apps/docs/ ← documentation site (https://beamhop.github.io/lightbox/)
- Node ≥ 18 (or Bun ≥ 1.3)
- The microsandbox SDK auto-installs
msb+libkrunfwunder~/.microsandbox/on first use. No global tooling needed. - Supported hosts: macOS arm64, Linux x86_64 / arm64 (glibc).