Sandboxes
Create, control, and expose Firecracker microVM sandboxes, lifecycle, exec, PTY, files, preview URLs, and auto-pause.
A sandbox is a full Linux microVM, its own kernel, its own filesystem, nothing shared with other tenants. Sandal boots them on bare metal (Hetzner, OVH, Leaseweb) with Firecracker and Cloud Hypervisor, which is why cold create is under a second and a warm claim from the pool is under 200ms.
You pay by the second. A paused sandbox costs $0, you're billed per second while it's running, not for the disk sitting there.
State enum#
A sandbox moves through one state machine for its whole life:
creating → running ⇄ paused → archived → deleted
↓
stopped| State | Meaning | Billed? |
|---|---|---|
creating | MicroVM is booting from template | No |
running | Kernel is live, CPU/RAM metered | Yes, active-CPU + provisioned-RAM |
paused | Memory snapshotted to disk, CPU released | No |
stopped | Process exited, disk retained, needs a fresh boot to resume | No |
archived | Encrypted snapshot moved to cold storage, compute reclaimed | Storage only |
deleted | Gone. Irreversible. | , |
paused and stopped look similar but aren't: pause freezes exact in-memory state for a fast resume (sub-second); stop tears down the VM process and a subsequent start re-boots from the last snapshot, which is slower but frees the pause-slot. Use pause for short idle gaps, stop/archive for anything measured in hours or days.
Lifecycle#
import { Sandal } from "@sandal/sdk";
const sandal = new Sandal({ apiKey: process.env.SANDAL_API_KEY });
// create, boots a fresh microVM from a template
const sb = await sandal.sandboxes.create({ template: "node-24" });
// pause / resume, sub-second, memory-preserving
await sb.pause();
await sb.resume();
// stop / start, process torn down, reboots from snapshot
await sb.stop();
await sb.start();
// archive, cold storage, compute reclaimed, disk kept
await sb.archive();
// fork, new sandbox, same disk state, fresh identity
const child = await sb.fork();
// delete, irreversible
await sb.delete();See Snapshots & forks for what a fork actually copies and what it doesn't.
Running commands: exec and PTY#
exec runs a command to completion and gives you stdout/stderr/exit code. Use the PTY interface when you need an interactive shell, REPLs, top, anything that checks isatty() or reacts to terminal resize.
// one-shot, buffered
const result = await sb.exec("pnpm test");
console.log(result.stdout, result.exitCode);
// streaming
for await (const chunk of sb.exec.stream("pnpm build")) {
process.stdout.write(chunk.data);
}
// PTY, interactive terminal
const term = await sb.pty.create({ cols: 120, rows: 32 });
term.onData((data) => process.stdout.write(data));
term.write("python3\n");
term.resize({ cols: 100, rows: 40 });
await term.kill();Every exec and PTY session runs inside the sandbox's own kernel, there's no shared process namespace with any other tenant, ever.
Files API#
Read, write, and stream files directly against the sandbox filesystem, no need to shell out to cat/scp.
await sb.fs.write("/app/config.json", JSON.stringify({ port: 3000 }));
const contents = await sb.fs.read("/app/config.json");
await sb.fs.mkdir("/app/data");
const entries = await sb.fs.list("/app");
await sb.fs.remove("/app/tmp.log");
// large files: stream instead of buffering
const stream = fs.createReadStream("./build.tar.gz");
await sb.fs.uploadStream("/tmp/build.tar.gz", stream);Expose ports: preview URLs#
Any process listening on a port inside the sandbox can be exposed as a public HTTPS URL, useful for previewing a dev server, a webhook receiver, or an API you want an agent to hit from outside.
await sb.exec.stream("pnpm dev"); // starts a server on :3000, don't await to completion
const url = await sb.expose(3000);
console.log(url); // https://3000-sb_8f2c1a.sandal.devPreview URLs are DPoP-scoped per sandbox, they route only to that sandbox's port and die with the sandbox. Calling expose again on the same port returns the same URL (idempotent).
TTL and auto-pause semantics#
Every sandbox has a TTL. When it elapses with no activity, Sandal auto-pauses the sandbox, not stops, not deletes. Pausing is the safe default because it's free and instant to reverse.
const sb = await sandal.sandboxes.create({
template: "node-24",
ttl: "10m", // idle timeout before auto-pause
});
// any exec/fs call resets the idle clock
await sb.exec("echo alive");Footguns:
- A backgrounded process does not count as activity. If you
exec.stream("pnpm dev")and then go idle from the SDK's perspective, the TTL clock still runs against your API calls, not the child process's CPU usage. A long-running dev server can get auto-paused out from under itself. Poll or explicitly extend the TTL if you're relying on a background process staying up unattended. - Auto-pause is not auto-stop. If you assumed idle sandboxes get torn down and reclaimed, they don't, a paused sandbox keeps its disk and still needs an explicit
archive()ordelete()if you actually want to stop paying storage. Paused compute is free; paused storage is not. - Resume after a long pause can be slow the first time. Sandal prefetches likely-needed pages on resume, but a sandbox paused for days may need to pull cold blocks back from snapshot storage before it's fully responsive. Warm it up with a no-op
execbefore routing real traffic toexpose()'d ports. - TTL resets on API activity, not wall-clock scheduling. Setting
ttl: "10m"doesn't mean "pause at exactly +10m", it means "pause 10 minutes after the last call." Don't rely on TTL for precise scheduled teardown; usearchive()on a timer instead.
Resources and templates#
Sandboxes are created from a template, a base filesystem image (node-24, python-3.12, ubuntu-24.04, or a custom image you push). Resource limits are set at create time and can be resized on a running sandbox.
const sb = await sandal.sandboxes.create({
template: "python-3.12",
resources: { vcpus: 4, memoryMb: 8192, diskGb: 20 },
region: "eu", // "eu" | "us"
});
await sb.resize({ vcpus: 8, memoryMb: 16384 });Custom templates are built from a Dockerfile-like spec and cached at the block level, so a template rebuild only re-snapshots the layers that changed, subsequent create() calls from that template still boot in under a second.