Skip to content
This repository was archived by the owner on May 17, 2026. It is now read-only.

beamhop/lightbox

Repository files navigation

lightbox

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    │
                                            └──────────────┘

What is this?

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.

Why?

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

Install directly from this git repository:

npm install github:beamhop/lightbox
# or pin a tag / branch / sha
npm install github:beamhop/lightbox#v0.2.0

npm 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"
  }
}

Quick start

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.

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 lightbox spins 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 live Sandbox handle whose lifecycle is tied to your process. Call sb.detach() to let it survive process exit, or use runInSandbox to scope the sandbox to a callback.

API

defineSnapshot(config) → SnapshotConfig

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" },
});

SnapshotConfig fields

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 stepmsb 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

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");

buildSnapshot(config, opts?) → Promise<void>

Builds the snapshot. Idempotent by default — overwrites any existing snapshot of the same name. Workflow:

  1. Ensure the microsandbox runtime is installed.
  2. If a snapshot with the same name exists, remove it (or error if overwrite: false).
  3. Create a builder sandbox from config.image.
  4. Run each setup step in order.
  5. sync to flush the page cache. Required — without this, the last setup step's writes can vanish from the snapshot.
  6. Stop the builder and snapshot it under config.name.
  7. Remove the builder sandbox (unless debugBuilder: true).

BuildOptions

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.

launchSandbox(opts) → Promise<Sandbox>

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.

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, 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.

Mounts

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.

runInSandbox(opts, fn) → Promise<T>

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.

connectToSandbox(name) → Promise<Sandbox>

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());

Helpers

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();

SDK re-exports

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.

Presets

codingAgentsPreset(opts?)

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);

Recipes

Build a custom snapshot from scratch

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",
  ],
}));

Launch a pool of sandboxes

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
  }),
);

Run code, then auto-teardown

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.

Drive an existing sandbox from another process

import { connectToSandbox } from "@beamhop/lightbox";

const sb = await connectToSandbox("agent-1");
console.log((await sb.shell("pi --version")).stdout());

Debug a failed build

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

Gotchas

  • Don't set workdir to a path that doesn't exist in the base image. The SDK validates the working dir at create time. lightbox doesn't pass workdir to the builder for this reason — create the dir as the first setup step.
  • The page cache must be synced before snapshotting. buildSnapshot runs sync for you; without it, the last setup step's writes can vanish.
  • launchSandbox() ties lifecycle to your process. Call sb.detach() to let the sandbox outlive your script, or use runInSandbox() for a scoped sandbox.
  • bun install -g postinstalls are blocked by default (a Bun default). Most CLIs install their bin via package.json bin regardless. Run bun pm -g untrusted inside the sandbox to see what was skipped.

Migrating from 0.2.x → 0.3.0

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.

Migrating from 0.1.x

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: false if you want the old "throw on collision" behavior.
  • Streaming output: pass verbose: true explicitly.

Types

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";

Repo layout

lightbox/
├── src/              ← the library source
├── examples/basic/   ← end-to-end demo scripts
└── apps/docs/        ← documentation site (https://beamhop.github.io/lightbox/)

Requirements

  • Node ≥ 18 (or Bun ≥ 1.3)
  • The microsandbox SDK auto-installs msb + libkrunfw under ~/.microsandbox/ on first use. No global tooling needed.
  • Supported hosts: macOS arm64, Linux x86_64 / arm64 (glibc).

License

Apache License 2.0.

About

Bake disposable VM snapshots. Boot them in under 100 ms. Declarative microsandbox snapshots for Bun.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors