@beamhop/lightbox
v0.1.0
Published
Declarative microsandbox snapshot builder and sandbox launcher for Bun & Node.
Downloads
81
Maintainers
Readme
lightbox
Declarative snapshot builder and sandbox launcher built on 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.
// Pull the three pieces we need: a typed-config helper, the snapshot
// builder, and the launcher that detaches the sandbox from this process.
import { buildSnapshot, defineSnapshot, launchSandboxDetached } from "@beamhop/lightbox";
// defineSnapshot is identity at runtime — it only exists so the object
// literal below gets full IntelliSense and type-checking.
const config = defineSnapshot({
name: "py-data", // artifact name under ~/.microsandbox/snapshots/
image: "python:3.12", // base OCI image the builder VM boots from
resources: { cpus: 2, memory: "1G" }, // 2 vCPUs + 1 GiB for the builder VM
setup: [
// Each setup step runs inside the builder before snapshotting.
// kind:"shell" lets us use pipes/&&; cache-disabled keeps the snapshot small.
{ kind: "shell", script: "pip install --no-cache-dir numpy pandas" },
],
});
// Spin up the builder, run setup, sync, stop, snapshot, then clean up.
// force:true overwrites any prior snapshot with the same name.
await buildSnapshot(config, { force: true });
// Boot a fresh VM from the snapshot. Detached = keeps running after exit;
// replace:true wipes any stale sandbox named "analysis-1" first.
await launchSandboxDetached({ snapshot: "py-data", name: "analysis-1", replace: true });Install
{
// Consume the library from another package in this monorepo.
// "workspace:*" tells bun to resolve to the in-repo version, never npm.
"dependencies": { "@beamhop/lightbox": "workspace:*" }
}microsandbox is the only runtime dep. It pulls the platform-appropriate
msb + libkrunfw binaries on first use (cached under ~/.microsandbox/).
No global tools required.
Concepts
- 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{ keepBuilder: true }. - Launched sandbox — a persistent VM booted from a snapshot via
Sandbox.builder(name).fromSnapshot(...).createDetached(). Survives until you remove it. - Lifecycle ownership — when you call
launchSandbox()you get back a liveSandboxhandle whose lifecycle is tied to your process. CalllaunchSandboxDetached()instead (orsandbox.detach()manually) to let the sandbox survive process exit.
API
defineSnapshot(config) → SnapshotConfig
Identity helper for type inference and IDE autocomplete:
import { defineSnapshot } from "@beamhop/lightbox";
const cfg = defineSnapshot({
name: "node-ci", // snapshot name on disk
image: "node:22", // base image — Node 22 LTS
resources: { cpus: 4, memory: "4G" }, // beefier builder for npm installs
workdir: "/work", // default cwd for setup-step commands
env: { CI: "true" }, // env exported into every setup step
setup: [
// First step must create /work — msb won't mkdir workdir for you.
{ kind: "shell", description: "make workspace", script: "mkdir -p /work" },
// Install global tooling that should bake into the snapshot.
{ kind: "shell", description: "tooling", script: "npm i -g pnpm@9" },
],
// Labels are metadata stored in the snapshot manifest — useful for filtering.
labels: { team: "platform", purpose: "ci" },
});SnapshotConfig fields
| Field | Type | Notes |
|-------|------|-------|
| name | string | Snapshot artifact name. Must be unique unless you pass force. |
| 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` | number | Memory for the builder VM. Strings are converted to MiB; raw numbers are MiB. |
| 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. |
SetupStep
A tagged union — shell for one-liners that need pipes/redirects, exec for
direct argv execution.
type SetupStep =
// shell: passes `script` to `sh -lc`; supports pipes, redirects, &&, ||.
| { kind: "shell"; script: string; description?: string }
// exec: direct argv — no shell parsing. Safer for paths with spaces.
| { kind: "exec"; cmd: string; args?: string[]; description?: string };description is printed before the step runs when verbose is on.
buildSnapshot(config, opts?) → Promise<void>
Builds the snapshot. Workflow:
- Ensure the microsandbox runtime is installed (
install()if needed). - If a snapshot with the same name exists, error (or remove it if
force: true). Sandbox.builder(<name>-builder).image(...).createDetached().- Run each
setupstep viasandbox.shellStream(...)/sandbox.execStream(...). Output is piped to your stdout/stderr live whenverbose: true. sandbox.exec("sync")to flush the page cache. Required — withoutsync, the snapshot can miss recent writes from the last setup step.sandbox.stopAndWait().Snapshot.builder(builderName).name(config.name).create().- Remove the builder sandbox (unless
keepBuilder: true).
BuildOptions
| Field | Default | Notes |
|-------|---------|-------|
| force | false | Overwrite an existing snapshot with the same name. |
| keepBuilder | false | Keep the builder VM after snapshotting (for debugging). |
| builderSuffix | "-builder" | Suffix appended to config.name for the builder VM. |
| verbose | true | Stream step output live. |
launchSandbox(opts) → Promise<Sandbox>
Boots a sandbox from a snapshot in detached mode and returns the live
Sandbox handle. The sandbox's lifecycle is tied to your process — when
the process exits, the sandbox is stopped.
Use this when you want to drive the sandbox from the same script that launched it.
import { launchSandbox } from "@beamhop/lightbox";
// Returns a live Sandbox handle whose lifecycle is tied to THIS process —
// if we exit without stopping it, the SDK kills the VM.
const sb = await launchSandbox({
snapshot: "lightbox", // snapshot to boot from
name: "agent-1", // sandbox name (must be unique)
resources: { cpus: 2, memory: "2G" }, // per-sandbox override of snapshot defaults
replace: true, // tear down any existing "agent-1" first
});
// Run a command inside the VM. exec(cmd, args[]) takes argv directly.
const out = await sb.exec("copilot", ["--version"]);
// .stdout() decodes the captured stdout as UTF-8.
console.log(out.stdout());
await sb.stop(); // graceful shutdown; otherwise process exit would kill it.launchSandboxDetached(opts) → Promise<void>
Same as launchSandbox, but immediately calls sandbox.detach() so the
sandbox keeps running after your process exits. To control it later, look it
up via Sandbox.get(name):
// Sandbox is re-exported from lightbox — no need to import "microsandbox".
import { launchSandboxDetached, Sandbox } from "@beamhop/lightbox";
// Boots the sandbox AND immediately releases lifecycle ownership, so the
// VM keeps running once this script exits.
await launchSandboxDetached({ snapshot: "lightbox", name: "agent-1", replace: true });
// ... process exits, sandbox keeps running ...
// In a separate process:
// Sandbox.get() is a cheap DB lookup — returns metadata, doesn't connect.
const handle = await Sandbox.get("agent-1");
// .connect() opens a control channel without re-taking lifecycle ownership.
const sb = await handle.connect();
// .shell() runs a script via `sh -lc`; .stdout() decodes the result.
console.log((await sb.shell("pi --version")).stdout());LaunchOptions
| 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\|string, number\|string>? | { host: guest } port mappings. |
| replace | boolean? | Replace an existing sandbox with the same name. |
| verbose | true | Log [lightbox] launching ... to stdout. |
Helpers
import {
listSnapshots, // enumerate all snapshots on disk
snapshotExists, // shortcut: 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
} from "@beamhop/lightbox";
await listSnapshots(); // → SnapshotRecord[] (name, digest, size, ...)
await snapshotExists("lightbox"); // → boolean, true if "lightbox" is indexed
// force:true also removes if the snapshot has indexed children.
await removeSnapshot("lightbox", { force: true });
// force:true kills the sandbox first if it's still running.
await removeSandbox("agent-1", { force: true });
// Manually trigger the one-time runtime install (otherwise it happens
// lazily on the first SDK call). Idempotent — safe to call repeatedly.
await ensureRuntime();SDK re-exports
For convenience, the underlying SDK types are re-exported so callers don't
need to import microsandbox directly:
// Runtime classes — Sandbox.builder / .get / .list, Snapshot.builder / .list, etc.
import { Sandbox, Snapshot } from "@beamhop/lightbox";
// Types only — used to annotate streaming reads and exec results.
import type { ExecHandle, ExecOutput } from "@beamhop/lightbox";Anything not re-exported can still be imported from microsandbox (it's a
direct dep).
Presets
codingAgentsPreset(opts?)
Importable from lightbox/presets. Builds a SnapshotConfig 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";
// Presets live at a subpath so tree-shaking can drop them if unused.
import { codingAgentsPreset } from "@beamhop/lightbox/presets";
// No-arg call uses every default: name="lightbox", image="oven/bun".
// force:true lets you re-run this script idempotently.
await buildSnapshot(codingAgentsPreset(), { force: true });
// Snapshot "lightbox" is now ready.Override the defaults:
codingAgentsPreset({
name: "agents-xl", // give the snapshot a non-default name
image: "oven/bun", // (still the default — shown for clarity)
resources: { cpus: 4, memory: "4G" }, // bigger builder for parallel npm work
labels: { tier: "premium" }, // arbitrary metadata, merged with preset's labels
});CodingAgentsPresetOptions:
| Field | Default |
|-------|---------|
| name | "lightbox" |
| image | "oven/bun" |
| resources | { cpus: 2, memory: "2G" } |
| labels | undefined |
Recipes
Build a custom snapshot from scratch
import { buildSnapshot, defineSnapshot } from "@beamhop/lightbox";
await buildSnapshot(
defineSnapshot({
name: "rust-ci", // snapshot artifact name
image: "rust:1.82", // official Rust toolchain image
resources: { cpus: 4, memory: "4G" }, // cargo compiles benefit from extra RAM/cores
setup: [
// Add the linter and formatter components to the snapshot.
{ kind: "shell", script: "rustup component add clippy rustfmt" },
// Pre-install cargo-nextest so CI runs don't pay the install cost each time.
{ kind: "shell", script: "cargo install cargo-nextest --locked" },
],
}),
{ force: true }, // overwrite an existing "rust-ci" snapshot
);Extend a preset
import { buildSnapshot } from "@beamhop/lightbox";
import { codingAgentsPreset } from "@beamhop/lightbox/presets";
// Start from the preset, but give the resulting snapshot a different name
// so we don't clobber the vanilla "lightbox" one.
const cfg = codingAgentsPreset({ name: "lightbox-plus" });
// Append an extra setup step. `setup` is always defined on a preset, so
// the non-null assertion is safe here.
cfg.setup!.push({
kind: "shell",
description: "install ripgrep", // shown in build logs
script: "apt-get update && apt-get install -y ripgrep", // runs in oven/bun (Debian-based)
});
await buildSnapshot(cfg, { force: true });Launch a pool of sandboxes
import { launchSandboxDetached } from "@beamhop/lightbox";
// Launch three sandboxes in parallel. Each gets a unique name and boots
// from the same snapshot. Detached so they outlive this script.
await Promise.all(
["agent-1", "agent-2", "agent-3"].map((name) =>
launchSandboxDetached({ snapshot: "lightbox", name, replace: true }),
),
);Run code, then tear down
import { launchSandbox } from "@beamhop/lightbox";
// `await using` ties the sandbox's lifetime to this block. When `sb` goes
// out of scope, Sandbox[Symbol.asyncDispose]() runs and stops the VM.
await using sb = await launchSandbox({
snapshot: "lightbox",
name: "ephemeral",
replace: true,
});
// Combine two commands in one shell invocation — saves a round-trip.
const r = await sb.shell("pi --version && copilot --version");
console.log(r.stdout());
// Sandbox is stopped/disposed when `sb` goes out of scope.Debug a failed build
Pass keepBuilder: true to inspect the builder VM after a failure:
// keepBuilder:true skips the final `removeSandbox` cleanup, leaving the
// builder VM around so you can poke at it. .catch keeps the script alive
// long enough to investigate.
await buildSnapshot(cfg, { force: true, keepBuilder: true }).catch(console.error);
// Then attach with the SDK:
// const h = await Sandbox.get("<name>-builder"); // find the builder
// const sb = await h.connect(); // open a control channel
// await sb.attachShell(); // drop into an interactive shellGotchas
- 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. UselaunchSandboxDetached()(or callsandbox.detach()yourself) if the sandbox should outlive your script.bun install -gpostinstalls are blocked by default. That's a bun default. Most CLIs install their bin viapackage.jsonbinregardless. Runbun pm -g untrustedinside the sandbox to see what was skipped.
Types
All types are re-exported from the package root:
import type {
SnapshotConfig, // input to defineSnapshot / buildSnapshot
SetupStep, // tagged union for shell vs exec steps
SnapshotResources, // { cpus?, memory? }
BuildOptions, // second arg to buildSnapshot
LaunchOptions, // arg to launchSandbox / launchSandboxDetached
SnapshotRecord, // shape returned by listSnapshots()
Memory, // `${number}M` | `${number}G` template literal type
} from "@beamhop/lightbox";