JS Sandbox SDK
Use the Lightning Sandbox JS SDK to create ephemeral sandboxes, run commands, manage files, capture snapshots, and open interactive PTY sessions from Node.js.
For the command-line interface, see the CLI Reference.
Prerequisites
Install the SDK (requires Node.js 22+):
npm install @lightningai/sdkCheck your Node version first — the SDK's engines field requires Node
22+, so an older Node (e.g. the current LTS 20) fails at install, and PTY needs
the Node 22 built-in WebSocket:
node -v # must print v22.0.0 or higherThe package is ESM-only. In CommonJS, import it with
const { Sandbox } = await import("@lightningai/sdk").
After installation:
- Provide credentials through
LIGHTNING_SANDBOX_API_KEY(recommended: a teamspace-scoped key) orSandbox.configure({ apiKey }). See Authentication. - Import what you need from the package root.
- PTY sessions require Node 22+ for the built-in
WebSocketglobal (no extra dependency).
import { Sandbox } from "@lightningai/sdk";Core classes
| Class | What it does | Example |
|---|---|---|
Sandbox |
Client and handle — static methods create/list/get sandboxes and manage snapshots; instances run commands, manage files, and control lifecycle | const sandbox = await Sandbox.create(...) |
Command |
A running or finished command inside a sandbox | const cmd = await sandbox.runCommand(...) |
FileSystem |
Shell-based file operations, via sandbox.fs |
await sandbox.fs.readdir("/tmp") |
SandboxProcess |
Interactive PTY sessions, via sandbox.process |
await sandbox.process.createPty(...) |
NetworkPolicy |
Egress firewall policy passed at create time | new NetworkPolicy({ allowCidrs: [...] }) |
SandboxConfig |
API key and base URL | Sandbox.configure({ apiKey: "sk-lit-..." }) |
Sandbox.create() returns a Promise<Sandbox> that resolves once the sandbox
status is running. Remote sandboxes are not destroyed when the JS object
is garbage-collected — call delete() explicitly.
Unlike the Python SDK,
Sandboxis not a separately-constructed client. All client operations are static (Sandbox.create,Sandbox.get,Sandbox.list,Sandbox.listSnapshots, …); an awaitedSandboxis the per-sandbox handle. There is nonew Sandbox(config)— set process-wide credentials withSandbox.configure.
Quickstart
import { Sandbox } from "@lightningai/sdk";
const sandbox = await Sandbox.create({ name: "quickstart", instanceType: "cpu-1" });
const result = await sandbox.runCommand("echo", ["Hello, World!"]);
console.log(result.output.trimEnd()); // Hello, World!
console.log(result.exitCode); // 0
await sandbox.delete();Credentials are read from
LIGHTNING_SANDBOX_API_KEY(orLIGHTNING_API_KEY), or set them in-process withSandbox.configure({ apiKey }). See Authentication.
Choose the right API
| Need | Use |
|---|---|
| One-shot shell command | sandbox.runCommand("cmd", ["args"]) |
| Run a Python / TypeScript script | runtime + writeFile + runCommand — see Common tasks |
| Pick language/OS image | runtime on Sandbox.create() (see Runtimes) |
| Bring your own Docker image | image (+ imageSecretRef for private registries) on Sandbox.create() (see Custom Docker images) |
| Long-running / background command | runCommand({ cmd, detached: true }) then cmd.wait() or poll — see Long-running scripts |
| Write/read files via API | sandbox.writeFile / sandbox.readFile |
File ops via shell (ls, chmod, cp) |
sandbox.fs.exists, .stat, .readdir, etc. |
| Interactive shell (stdin, resize) | sandbox.process.createPty({ ... }) |
| Save disk image | sandbox.createSnapshot() then Sandbox.create({ snapshotId }) |
| Survive stop/resume | persistent: true, stop(), resume() |
| Lock down egress | networkPolicy on Sandbox.create() only |
Common tasks
These are the most frequent sandbox workflows. Pick the runtime that matches your
language, upload or inline the script, then run it with runCommand.
One language runtime per sandbox. A
python313box has nonodeand anode24box has nopython— the images do not bundle each other's interpreter, and there is no combined image. For a multi-language pipeline, either keep the extra step in shell,apt-get installthe other toolchain into the same sandbox (see Compiled languages and extra toolchains), or split the work across two sandboxes.
Run a simple Python script
Use a Python runtime image and run the interpreter synchronously (default
detached: false — the call resolves once the script exits).
Inline one-liner:
const sandbox = await Sandbox.create({ name: "py-script", instanceType: "cpu-1", runtime: "python313" });
const result = await sandbox.runCommand("python", ["-c", "print('hello from python')"]);
console.log(result.exitCode, result.output);
await sandbox.delete();Upload a file, then run it:
const sandbox = await Sandbox.create({ name: "py-script", instanceType: "cpu-1", runtime: "python313" });
await sandbox.writeFile({ path: "/tmp/work.py", content: "print('hello from file')\n" });
const result = await sandbox.runCommand("python", ["/tmp/work.py"]);
console.log(result.exitCode, result.output);
await sandbox.delete();Run a simple TypeScript script
Use a Node runtime. Upload the script with writeFile, then invoke it with
npx tsx (tsx ships in common Node runtimes) or compile with tsc first.
const sandbox = await Sandbox.create({ name: "ts-script", instanceType: "cpu-1", runtime: "node24" });
await sandbox.writeFile({
path: "/tmp/hello.ts",
content: "const msg: string = 'hello from typescript';\nconsole.log(msg);\n",
});
const result = await sandbox.runCommand("npx", ["tsx", "/tmp/hello.ts"]);
console.log(result.exitCode, result.output);
await sandbox.delete();For plain JavaScript, use runtime: "node24" and node /tmp/script.js.
Installing npm dependencies. npm install <pkg> works in an empty cwd — npm
auto-creates package.json, package-lock.json, and node_modules for you (you
get a notice/deprecation warning, but it exits 0; no npm init -y step is
required first):
await sandbox.runCommand("sh", ["-c", "mkdir -p /tmp/app && cd /tmp/app && npm install left-pad"]);
await sandbox.writeFile({ path: "/tmp/app/index.ts", content: "import pad from 'left-pad';\nconsole.log(pad('ok', 5));\n" });
const result = await sandbox.runCommand({ cmd: "npx", args: ["tsx", "index.ts"], cwd: "/tmp/app" });
console.log(result.exitCode, result.output);Dependencies land in /tmp/app/node_modules; run subsequent commands from that
directory ({ cwd: "/tmp/app" }) so Node resolves them. cwd is only available
via the options-object form of runCommand, not the runCommand("cmd", [args])
shorthand.
Run multiple commands
Sequential (each await waits for the previous to finish):
await sandbox.runCommand("mkdir", ["-p", "/tmp/demo"]);
await sandbox.runCommand({ cmd: "echo", args: ["step 1"], cwd: "/tmp/demo" });
await sandbox.runCommand({ cmd: "echo", args: ["step 2"], cwd: "/tmp/demo" });cwd, env, and detached are only accepted via the options-object form. The
runCommand("cmd", ["args"]) shorthand takes the command and args only.
Single shell pipeline — one round trip, shared cwd/env:
await sandbox.runCommand("sh", [
"-c",
"mkdir -p /tmp/demo && echo step 1 > /tmp/demo/log.txt && cat /tmp/demo/log.txt",
]);Parallel background work — start each command with detached: true, then
wait() or poll each handle separately (see below).
Fan-out is fine. Each
create/runCommand/listcall is an independent stateless request authenticated by the process-wide API key, so driving many sandboxes concurrently withPromise.allworks without extra locking.
Long-running and forever-running scripts
Required: Any command that may run longer than a quick shell invocation (installs, training jobs, servers, infinite loops) must use
detached: true. The default synchronousrunCommandwaits on the server until the remote process exits — fine forechoor a short script, wrong for multi-minute or unbounded work.
After starting a detached command you must either:
- Block until done —
await cmd.wait({ timeoutMs, pollIntervalMs }), or - Poll continuously — loop on
cmd.getStatus()/sandbox.getCommand(cmdId)untilrunningis false, printing logs between polls if you want live output.
wait()on timeout throws — it does not return a partial status. The command keeps running on the sandbox; the timeout only bounds your client-side wait. To enforce a wall-clock limit and stop a runaway job, wrap the wait andkill():await cmd.wait({ timeoutMs: 300_000, pollIntervalMs: 1000 }).catch(async () => { await cmd.kill(); // ran too long — stop it });
sandbox.waitForCommand(cmdId, { timeoutMs })behaves the same way.
const sandbox = await Sandbox.create({ name: "long-job", instanceType: "cpu-1", runtime: "python313" });
await sandbox.writeFile({
path: "/tmp/train.py",
content: "import time\nfor i in range(5):\n print(f'step {i}'); time.sleep(1)\n",
});
// Option A — wait() blocks until the process exits
const cmd = await sandbox.runCommand({ cmd: "python", args: ["/tmp/train.py"], detached: true });
await cmd.wait({ timeoutMs: 600_000, pollIntervalMs: 1000 });
console.log(cmd.exitCode, cmd.output);
// Option B — poll in a loop (useful for streaming partial output)
const server = await sandbox.runCommand({
cmd: "python",
args: ["-c", "import time\nwhile True: time.sleep(3600)"],
detached: true,
});
for (;;) {
const status = await sandbox.getCommand(server.cmdId);
console.log(status.output);
if (!status.running) break;
await new Promise((r) => setTimeout(r, 2000));
}
await server.kill();
await server.wait({ timeoutMs: 30_000 });
await sandbox.delete();Use cmd.kill() or sandbox.killCommand(cmdId) to stop a detached process you
no longer need. See Detached commands and
js/examples/detached-command.ts.
Authentication
Use a project-scoped (teamspace-scoped) API key. Create one in the Lightning UI under your teamspace → Members → API keys. The key selects its project, so it covers everything sandboxes need —
create, commands, files,createSnapshot(), and persistentstop()/resume(). The JS SDK has noteamspaceargument; the key is how you pick the teamspace.
Set the key
Set the key in your shell before the process starts:
export LIGHTNING_SANDBOX_API_KEY="sk-lit-..."
export LIGHTNING_CLOUD_URL="https://lightning.ai" # optionalimport { Sandbox } from "@lightningai/sdk";
// Key is picked up automatically — no configure() or teamspace needed.
const sandbox = await Sandbox.create({ name: "demo", instanceType: "cpu-1" });The SDK reads credentials lazily on each request, so unlike the Python SDK
there is no "set the env var before import" gotcha — setting process.env before
the first call works. To supply or switch the key in-process, use
Sandbox.configure():
import { Sandbox } from "@lightningai/sdk";
Sandbox.configure({ apiKey: "sk-lit-...", baseUrl: "https://lightning.ai" });
const sandbox = await Sandbox.create({ name: "demo", instanceType: "cpu-1" });Precedence: Sandbox.configure() values → LIGHTNING_SANDBOX_API_KEY →
LIGHTNING_API_KEY (legacy) for the key; Sandbox.configure({ baseUrl }) →
LIGHTNING_CLOUD_URL → production for the base URL. Organization scope is
implied by the API key — there is no organizationId setting.
Teamspace / project scope
The teamspace is selected entirely by your API key — the JS SDK takes no
teamspace argument. Use a teamspace-scoped key (Lightning UI → your
teamspace → Members → API keys); the key binds the sandbox to its project,
and the resulting project is read back on sandbox.projectId.
const sandbox = await Sandbox.create({ instanceType: "cpu-1" });
console.log(sandbox.projectId); // the key's project (read-back)An org-scoped key leaves
projectIdempty (""). Only a teamspace-scoped key binds the sandbox to a project. With an org-scoped key,create()still succeeds but the sandbox has no project, sosandbox.projectIdreads back"". This isn't reported at create time — instead it surfaces later as a confusing failure on the project-scoped operations (createSnapshot(), persistentstop()/resume()). Checksandbox.projectIdis non-empty right after create, and use a teamspace-scoped key for anything involving snapshots or persistence. (Same behavior in the Python SDK.)
Persistent stop/resume and
createSnapshot()are project-scoped operations. With a teamspace-scoped key they work with no extra arguments — the SDK reuses the project id read back from the create/get response.The Python SDK additionally accepts a
teamspace="owner/name"argument and resolves it via the Lightning AI projects API +lightning login. The JS SDK does not bundle that resolution path (and project ids are opaque to most users), so it omits teamspace selection entirely — pick the teamspace by choosing the right API key.
Creating sandboxes
Common options
const sandbox = await Sandbox.create({
name: "my-sandbox", // auto-generated if omitted
instanceType: "cpu-1", // machine slug (required)
runtime: "python313", // base runtime image (mutually exclusive with image)
spot: false,
ports: [8080],
snapshotId: "snap-uuid", // restore from snapshot
persistent: true, // survive stop() via auto-snapshot
storageGb: 40, // writable root-disk override; cpu-2+ only (cpu-1 is fixed)
timeout: 3_600_000, // initial max lifetime in ms; extend later with sandbox.extendTimeout()
networkPolicy: "allow-all", // or "deny-all" or new NetworkPolicy(...)
});instanceType is required (there is no default). name is auto-generated
(sandbox-YYYYMMDD-HHMMSS) when omitted.
Runtimes
Runtime is the base language/OS image inside the sandbox (pre-installed
Python, Node, shell tools). instanceType is the machine shape (CPU, RAM,
GPU) — pick both independently at create time. To bring your own Docker image
instead of a curated runtime, use image (mutually exclusive with runtime) —
see Custom Docker images.
Supported runtime slugs:
| Slug | Stack | Notes |
|---|---|---|
node24 |
Node.js 24 + npm | Default when runtime is omitted |
node22 |
Node.js 22 + npm | Older Node LTS |
python313 |
Python 3.13 + pip | Python workloads |
When you omit runtime, the sandbox uses node24 by default.
sandbox.runtime reflects the value you passed at create. The only supported
runtime values are python313, node24, and node22; for anything else,
supply a custom image (see Custom Docker images).
What's preinstalled. Runtimes ship the language interpreter and its package manager only — not a full test/build toolchain:
python313→ python + pip (nopytest,numpy, etc.).pip installyour test/build deps first, e.g.pip install pytestbefore running tests.node24/node22→ node + npm (tsxavailable innode24).
Prefer
pip installover a venv inpython313. The runtime's system interpreter already has pip (python -m pip install ...works), butpython -m venvfails (itsensurepipstep exits non-zero). Install directly with pip instead of building a virtualenv.
Compiled languages and extra toolchains. Each sandbox runs a single
runtime image. The common Python/Node images do not bundle a C/Go/Rust
toolchain (or each other's interpreter). Install at runtime in one sandbox:
const sandbox = await Sandbox.create({ name: "build", instanceType: "cpu-1", runtime: "python313" });
await sandbox.runCommand("sh", ["-c", "apt-get update && apt-get install -y gcc && gcc --version"]);Instance types
instanceType selects the machine. Pick the smallest slug that fits your
workload to keep cost down, and step up only when the job needs the extra
CPU/RAM. Supported CPU slugs:
| Slug | vCPU | RAM |
|---|---|---|
cpu-1 |
1 | 1 GB |
cpu-2 |
2 | 8 GB |
cpu-4 |
4 | 16 GB |
cpu-8 |
8 | 32 GB |
cpu-16 |
16 | 64 GB |
const sandbox = await Sandbox.create({ instanceType: "cpu-4", runtime: "python313" }); // 4 vCPU / 16 GBCustom Docker images
Instead of a curated runtime slug, point a sandbox at any OCI image with
image:
const sandbox = await Sandbox.create({
name: "my-sandbox",
instanceType: "cpu-1",
image: "docker.io/library/python:3.13-slim", // any OCI reference
});
console.log(sandbox.image); // "docker.io/library/python:3.13-slim"
console.log(sandbox.imageSecretRef); // "" for a public imagePrivate registries — imageSecretRef. For an image that needs pull
credentials, pass imageSecretRef — the name of a project-scoped Secret of
type Docker registry (the same mechanism Jobs use):
const sandbox = await Sandbox.create({
name: "my-sandbox",
instanceType: "cpu-1",
image: "ghcr.io/myorg/myimage:latest",
imageSecretRef: "my-docker-registry-secret", // project Secret name
});Rules and limits:
imageandruntimeare mutually exclusive — passing both throws.imageSecretRefis only valid together withimage— passing it alone throws.- Custom images are supported on CPU sandboxes only. GPU instance types
reject
imageserver-side. - You manage what's inside the image. Curated-runtime conveniences (e.g.
tsxinnode24, and the tooling required for cross-process PTY reattach) are only present if your image ships them.
Exposed ports
Pass ports: [8080] at create time to expose ports; sandbox.ports lists the
exposed port numbers back (as strings).
const sandbox = await Sandbox.create({ name: "web", instanceType: "cpu-1", ports: [8080] });
console.log(sandbox.ports); // ["8080"]Limitation — no public URL from the SDK. The sandbox API does not currently return an external hostname/URL for an exposed port; there is no
url/endpointproperty on aSandbox. Reach a server from inside the sandbox viahttp://127.0.0.1:<port>(notlocalhost, which may not resolve).
Lookup and listing
const existing = await Sandbox.get({ sandboxId: "sandbox-uuid" });
const page = await Sandbox.list({ limit: 10 });
for (const sb of page.sandboxes) {
console.log(sb.sandboxId, sb.status);
}
console.log(page.nextPageToken);Sandbox.list is scoped to the API key's project; a teamspace-scoped key returns
just that project's sandboxes.
Instance properties
sandbox.sandboxId;
sandbox.name;
sandbox.organizationId; // from API key scope (read-back)
sandbox.projectId; // teamspace scope (read-back); "" for an org-scoped key
sandbox.instanceType;
sandbox.spot;
sandbox.persistent;
sandbox.status; // e.g. "running", "paused"
sandbox.runtime;
sandbox.ports; // string[]
sandbox.image; // custom OCI image, if created with one ("" otherwise)
sandbox.imageSecretRef; // project secret used to pull image ("" otherwise)
sandbox.snapshotId; // source snapshot, if restored ("" otherwise)
sandbox.userId; // creator ("" if not tracked)
sandbox.storageGb; // writable disk size in GB (0 = instance default)
sandbox.timeout; // initial max lifetime in ms (0 = no timeout); NOT refreshed by extendTimeout()
sandbox.networkPolicy; // NetworkPolicy (see Network policy)
sandbox.createdAt; // Date
sandbox.updatedAt; // DateLifecycle
Delete
await sandbox.delete(); // destroys sandbox and any auto-snapshot
delete()is not idempotent. Deleting a sandbox that is already gone throwsLightning API error 404: sandbox not found. This bitesfinally-style cleanup and double-fires afterstop()on a non-persistent box (which is equivalent todelete()). Guard the second call:await sandbox.delete().catch((e) => { if (!String(e).includes("404")) throw e; // ignore "already deleted/stopped" });
Extending the timeout
The timeout you pass to create() sets the sandbox's initial auto-stop
deadline and is fixed once the sandbox exists. To keep a long-running sandbox
alive past that deadline, call extendTimeout(ms) — the only way to move the
deadline after creation:
const sandbox = await Sandbox.create({ instanceType: "cpu-1", timeout: 3_600_000 }); // initial 1h
await sandbox.extendTimeout(3_600_000); // push the deadline out by another hourextendTimeout(timeoutMs) takes the number of milliseconds to add to the
current deadline (not an absolute time, not a replacement). The minimum is
1000 (1s); a smaller value throws before any request is sent. Call it
repeatedly — e.g. as a heartbeat — to keep an agent's sandbox alive while work
remains:
while (await agentHasWork()) {
await doAChunkOfWork();
await sandbox.extendTimeout(600_000); // +10 min each iteration
}The deadline moves, but
sandbox.timeoutdoes not. The control plane returns an empty response, soextendTimeout()resolves tovoidand thetimeoutfield keeps reporting the create-time lifetime even after a successful extend — re-fetching viaSandbox.getwill still show the original value. Track the extended deadline yourself if you need it.
Persistent stop and resume
Requires a teamspace-scoped API key.
const persistent = await Sandbox.create({
name: "warm",
instanceType: "cpu-1",
persistent: true,
// No teamspace needed: a teamspace-scoped key already selects its project.
});
await persistent.writeFile({ path: "/root/state.txt", content: "saved\n" });
const { autoSnapshotId } = await persistent.stop(); // non-empty auto-snapshot id
const resumed = await persistent.resume(); // resolves once running again
const content = await resumed.readFile({ path: "/root/state.txt" });
console.log(content); // "saved\n"
await resumed.delete();stop() returns { autoSnapshotId }. For non-persistent sandboxes, stop() is
equivalent to delete() and returns an empty autoSnapshotId.
resume()returns a fresh runningSandboxinstance; the original handle is left unchanged.Sandbox.resume({ sandboxId })is the static equivalent.
Snapshots
Capture the filesystem (not running processes). createSnapshot() polls until
the snapshot is ready by default:
const snap = await sandbox.createSnapshot();
console.log(snap.id, snap.status, snap.sizeBytes);
console.log(snap.createdAt, snap.expiresAt); // createdAt is a Date; expiresAt is a Date or null (never expires)
console.log(snap.sourceSandboxId, snap.sourceSandboxName);
console.log(snap.auto); // false for user snapshots; true for persistent-stop auto-snapshotsOptions:
const snap = await sandbox.createSnapshot({
expiration: 86_400_000, // TTL in MILLISECONDS (here: 24h). 0 = never expire.
excludes: ["/root/cache"], // paths omitted from the snapshot tarball
wait: true, // poll until "ready" (default); wait: false returns the "saving" row
waitTimeoutMs: 600_000,
});Restore into a new sandbox:
const restored = await Sandbox.create({ snapshotId: snap.id, instanceType: "cpu-1" });
// create() blocks until status === "running" — no extra poll for restore
const content = await restored.readFile({ path: "/root/marker.txt" });
await restored.delete();Restore does not carry the source's network policy. Snapshots capture the filesystem only — not egress rules. A restored sandbox defaults to
allow-allunless you passnetworkPolicyoncreate({ snapshotId }). Onlyresume()(persistent stop → start) re-applies the original policy. See Network policy.
Client-side snapshot management:
const snap = await Sandbox.getSnapshot("snap-uuid");
const result = await Sandbox.listSnapshots({
sortOrder: "desc", // order by creation time ("asc" / "desc")
limit: 50,
});
for (const s of result.snapshots) {
console.log(s.id, s.status, s.createdAt, s.expiresAt);
}
if (result.nextPageToken) {
const more = await Sandbox.listSnapshots({ pageToken: result.nextPageToken });
}
await Sandbox.deleteSnapshot("snap-uuid");The
namefilter does a case-insensitive substring match on the source sandbox name.listSnapshots({ name: "box" })returns snapshots whosesourceSandboxNamecontainsbox(any case), e.g.My-Boxandsandbox-42:const result = await Sandbox.listSnapshots({ name: "my-box", sortOrder: "desc" }); for (const s of result.snapshots) console.log(s.id, s.sourceSandboxName);
SnapshotData exposes id, status, runtime, runtimeImage, sizeBytes,
projectId, organizationId, rootfsDigest, createdAt, updatedAt,
expiresAt, excludes, the source-sandbox lineage (sourceSandboxId,
sourceSandboxName, sourceSandboxInstanceType), and auto.
Timestamps are
Dateobjects.createdAtandupdatedAtare parsed toDate(consistent withsandbox.createdAt), andexpiresAtis aDateornullwhen the snapshot never expires. Use them directly, e.g.snapshots.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).
Commands
Synchronous (default)
const result = await sandbox.runCommand("uname", ["-a"]);
console.log(result.exitCode, result.output);You always have root. Every command runs as
root(uid 0) inside the sandbox, so there is nosudooption — just run privileged commands directly, e.g.apt-get install -y ....
Structured options
const result = await sandbox.runCommand({
cmd: "python",
args: ["-c", "print('hi')"],
cwd: "/root",
env: { FOO: "bar" },
detached: false,
});Detached commands
Detached mode returns immediately with a Command handle whose exitCode is
null and running is true; you must wait() or poll until running is false.
const cmd = await sandbox.runCommand({ cmd: "sleep", args: ["10"], detached: true });
cmd.cmdId; // server-assigned command id
cmd.running; // true while executing; exitCode is null until it exits
const status = await cmd.getStatus(); // refresh output/exitCode from the server
await cmd.wait({ timeoutMs: 30_000 }); // block until exit; exitCode now populated
await cmd.kill();
kill()is best-effort / asynchronous. It signals the process;runningcan still betrueimmediately afterward. PollgetStatus()orwait()afterkill()to confirm the process stopped.
A killed command exits non-zero. After
kill()+wait(), the command reportsexitCode === -1(not0). Don't treat a killed job as success — check the exit code before trusting its output.
Inspecting commands by id and history
getCommand(cmdId) returns a CommandStatus (output, exitCode, running).
listCommands() returns the sandbox's whole command history as Command
handles, with command, startedAt, and updatedAt populated.
commandis the program name only — not the full args. The command-history API records just the executable (e.g."echo"forrunCommand("echo", ["one"])); the arguments are not retained. You can replay which programs ran and their exit codes/timestamps, but not the exact invocation. (This is a server-side limitation, identical in the Python SDK.)
const status = await sandbox.getCommand(cmdId); // { output, exitCode, running }
for (const c of await sandbox.listCommands()) {
console.log(c.cmdId, c.command, c.exitCode, c.running, c.startedAt);
}Logs while running:
getCommandLogs(cmdId)returns whatever the server has buffered. For a still-running process whose stdout is buffered, that may be empty until output flushes — run unbuffered (e.g.python3 -u) to see incremental logs.
Lower-level command APIs
const status = await sandbox.getCommand(cmdId); // → CommandStatus
const { logs } = await sandbox.getCommandLogs(cmdId); // → CommandLog[]
await sandbox.killCommand(cmdId);
const final = await sandbox.waitForCommand(cmdId, { timeoutMs: 60_000, pollIntervalMs: 500 }); // → CommandStatusFiles
Direct API (files endpoint)
await sandbox.writeFile({ path: "/tmp/hello.txt", content: "hello\n" });
const content = await sandbox.readFile({ path: "/tmp/hello.txt" }); // null if missing
await sandbox.writeFiles([{ path: "/tmp/a.txt", content: "a\n" }]);
await sandbox.mkdir("/tmp/mydir"); // alias: createDirectory({ path })mkdir / createDirectory use the directories API on the Sandbox. For
shell-based directory creation (including parents), use sandbox.fs.mkdir.
Binary files (.ckpt, compiled binaries, datasets). writeFile / readFile
are text-only (content is a string, UTF-8). To move a binary artifact in
or out, base64-encode it through the shell:
import { readFileSync, writeFileSync } from "node:fs";
// Upload a local binary into the sandbox
const blob = readFileSync("model.ckpt").toString("base64");
await sandbox.writeFile({ path: "/tmp/model.b64", content: blob });
await sandbox.runCommand("sh", ["-c", "base64 -d /tmp/model.b64 > /tmp/model.ckpt"]);
// Download a binary produced inside the sandbox
await sandbox.runCommand("sh", ["-c", "base64 -w0 /tmp/out.bin > /tmp/out.b64"]);
const b64 = await sandbox.readFile({ path: "/tmp/out.b64" });
writeFileSync("out.bin", Buffer.from(b64 ?? "", "base64"));For large artifacts, prefer pushing/pulling from object storage inside the sandbox rather than base64 over the files API.
FileSystem helpers (sandbox.fs)
Shell-based helpers (except writeFile, which uses the files API).
const fs = sandbox.fs;
await fs.mkdir("/tmp/mydir");
await fs.mkdir("/tmp/a/b/c", { recursive: true }); // mkdir -p
await fs.writeFile("/tmp/mydir/hello.txt", "hello\n");
await fs.exists("/tmp/mydir/hello.txt");
const st = await fs.stat("/tmp/mydir/hello.txt");
// FileStat: fileType, size, mtime (Date), mode (string)
console.log(st.fileType, st.size, st.mtime, st.mode);
// st.mode is a STRING of octal digits as `stat -c %a` prints, e.g. "644"
// (no "0o" prefix). Convert explicitly, e.g. parseInt(st.mode, 8) === 0o644.
await fs.readdir("/tmp/mydir");
await fs.copyFile("/tmp/mydir/hello.txt", "/tmp/mydir/copy.txt");
await fs.rename("/tmp/mydir/copy.txt", "/tmp/mydir/renamed.txt");
await fs.chmod("/tmp/mydir/renamed.txt", 0o644); // int octal or string, e.g. "644"
await fs.symlink("/tmp/mydir/hello.txt", "/tmp/mydir/link");
await fs.rm("/tmp/mydir/link");
await fs.rm("/tmp/mydir", { recursive: true });PTY sessions
For interactive shells, use sandbox.process instead of runCommand. Requires
Node 22+ (built-in WebSocket).
import { Sandbox, writeToStdout } from "@lightningai/sdk";
const sandbox = await Sandbox.create({ name: "pty-demo", instanceType: "cpu-1" });
const pty = await sandbox.process.createPty({
sessionName: "shell",
cwd: "/root",
cols: 120,
rows: 30,
onData: writeToStdout, // default when omitted; forwards bytes to process.stdout
});
await pty.waitForConnection();
await pty.sendInput("uname -a\nexit\n");
await pty.wait();
await sandbox.delete();Suppress output with onData: () => {} (the JS SDK has no discard static).
First output arrives ~0.5–1.5s after connect.
waitForConnection()resolves on the WebSocketopenevent (a few ms), but the remote shell is spawned and initialized server-side, so the prompt shows up as the firstonDatabytes about a second later. This is normal and harmless: input yousendInputin the meantime is buffered and runs once the shell is up — just stream output viaonDatarather than reading once immediately afterwaitForConnection().
onData receives Uint8Array, not a string — each chunk is raw terminal
output (combined stdout/stderr) forwarded verbatim. Decode when you need text:
const buf: number[] = [];
const pty = await sandbox.process.createPty({
sessionName: "shell",
onData: (chunk) => buf.push(...chunk), // chunk is Uint8Array
});
// ...later...
const text = new TextDecoder().decode(Uint8Array.from(buf));Sending Ctrl+C (and other control bytes). pty.kill() sends Ctrl+C and
tears down the session. To interrupt a command without disconnecting, send the
raw control byte with sendInput:
await pty.sendInput(new Uint8Array([0x03])); // Ctrl+C — interrupts the foreground process
await pty.sendInput("\n"); // ...then keep using the same shellsendInput accepts a string or Uint8Array (Uint8Array is decoded as UTF-8
and sent as a text frame), so ASCII control bytes work directly.
Disconnect vs kill vs connectPty
| Action | What it does |
|---|---|
pty.disconnect() |
Closes the WebSocket; shell keeps running on the sandbox |
pty.kill() |
Ctrl+C + disconnect |
process.killPtySession(name) |
Kills the local handle + the remote session |
connectPty(name, opts?) |
Opens a new WebSocket to reattach to the session |
Same SDK process — typical flow:
const pty = await sandbox.process.createPty({ sessionName: "shell" });
await pty.waitForConnection();
await pty.sendInput("long-running-command\n");
await pty.disconnect(); // shell still running
const reattached = await sandbox.process.connectPty("shell", { onData: () => {} });
await reattached.waitForConnection();
// Prefer killPtySession("shell") to tear down, or send Ctrl+C before exit.
await reattached.kill();wait() resolves once the WebSocket closes. Sending exit\n is not a
reliable way to make wait() return promptly — prefer kill() /
killPtySession() to close any session deterministically.
Cross-process reattach (a fresh Node process → an old session) depends on the runtime image shipping the required session tooling.
Session bookkeeping:
const info = await sandbox.process.getPtySessionInfo("shell");
const sessions = await sandbox.process.listPtySessions();
await sandbox.process.killPtySession("shell");
await sandbox.process.resizePtySession("shell", 150, 40);Network policy
Egress firewall, set at create time only — no runtime mutation. A single
NetworkPolicy models every mode:
| Value | Behavior |
|---|---|
omit / "allow-all" / new NetworkPolicy({ mode: "allow-all" }) |
Open egress (default) |
"deny-all" / new NetworkPolicy({ mode: "deny-all" }) |
Block all egress |
new NetworkPolicy({ allowCidrs: [...] }) |
CIDR allowlist; deny everything else (mode becomes "default-deny") |
create() accepts the bare "allow-all" / "deny-all" strings as a shorthand.
import { NetworkPolicy, Sandbox } from "@lightningai/sdk";
const locked = await Sandbox.create({
name: "egress-test",
instanceType: "cpu-1",
networkPolicy: new NetworkPolicy({ allowCidrs: ["10.0.0.0/8"] }),
});Read-back: sandbox.networkPolicy always returns a NetworkPolicy. Open
egress / no stored policy normalizes to new NetworkPolicy({ mode: "allow-all" }):
const policy = locked.networkPolicy; // NetworkPolicy
console.log(policy.mode); // "default-deny"
console.log(policy.allowCidrs); // ["10.0.0.0/8"]Blocked egress hangs, then times out — it is not an instant refusal. Under
deny-allor a CIDR allowlist, a denied connection blocks for a long time rather than failing fast: a DNS lookup errors out only after the resolver gives up ("Temporary failure in name resolution", ~20s in testing), and a raw TCP connect with no timeout hangs for minutes (kernel SYN retransmits). Always set a short client-side timeout on any in-sandbox health check or probe so it fails fast instead of stalling.
⚠️ Security — restoring a snapshot does NOT preserve egress lockdown. If you snapshot a
deny-all/ CIDR-allowlist sandbox and thenSandbox.create({ snapshotId })without re-passingnetworkPolicy, the new sandbox comes backallow-all. Re-specifynetworkPolicyon every restore to keep a box locked down. Onlyresume()(persistent stop → start) preserves the original policy, because it reuses the same sandbox id.
API reference
Import path: import { ... } from "@lightningai/sdk".
Sandbox (client)
| Method | Returns | Notes |
|---|---|---|
Sandbox.configure({ apiKey?, baseUrl? }) |
void |
Process-wide defaults for all calls |
Sandbox.create(params) |
Promise<Sandbox> |
Blocks until running |
Sandbox.get({ sandboxId }) |
Promise<Sandbox> |
|
Sandbox.list({ pageToken?, limit? }) |
Promise<{ sandboxes, nextPageToken, previousPageToken, totalSize }> |
Scoped to the key's project |
Sandbox.resume({ sandboxId }) |
Promise<Sandbox> |
Persistent only |
Sandbox.getSnapshot(snapshotId) |
Promise<SnapshotData> |
|
Sandbox.listSnapshots({ name?, pageToken?, limit?, sortOrder? }) |
Promise<{ snapshots, nextPageToken, previousPageToken, totalSize }> |
name is a case-insensitive substring match on sourceSandboxName |
Sandbox.deleteSnapshot(snapshotId) |
Promise<void> |
Sandbox.create parameters: name, instanceType (required), runtime
(base image slug), image (custom OCI image; mutually exclusive with runtime,
CPU only), imageSecretRef (project Docker-registry Secret for private image),
spot, ports (number[]), snapshotId, persistent, storageGb,
timeout (ms), networkPolicy. (Teamspace/project is selected by the API key;
there is no teamspace parameter.)
SandboxConfig
| Member | Description |
|---|---|
apiKey |
Optional; falls back to LIGHTNING_SANDBOX_API_KEY / LIGHTNING_API_KEY |
baseUrl |
Control plane URL (LIGHTNING_CLOUD_URL) |
Organization scope is implied by the API key — there is no organizationId.
Sandbox (instance)
Properties: sandboxId, name, organizationId, projectId,
instanceType, spot, persistent, status, runtime, ports, image,
imageSecretRef, snapshotId, userId, storageGb, timeout, networkPolicy,
createdAt, updatedAt, fs, process
| Method | Returns | Notes |
|---|---|---|
delete() |
Promise<void> |
|
stop() |
Promise<{ autoSnapshotId }> |
Auto-snapshot id for persistent; empty otherwise |
resume() |
Promise<Sandbox> |
Persistent only; returns a fresh handle |
extendTimeout(timeoutMs) |
Promise<void> |
Add timeoutMs (min 1000) to the auto-stop deadline. Does not refresh sandbox.timeout |
createSnapshot({ excludes?, expiration?, wait?, waitTimeoutMs? }) |
Promise<SnapshotData> |
Polls until ready by default; expiration in ms (0 = never) |
runCommand(cmd, args?) or runCommand(opts) |
Promise<Command> |
|
getCommand(cmdId) |
Promise<CommandStatus> |
{ output, exitCode, running } |
listCommands() |
Promise<Command[]> |
Command history with timestamps |
getCommandLogs(cmdId) |
Promise<{ logs: CommandLog[] }> |
|
killCommand(cmdId) |
Promise<void> |
|
waitForCommand(cmdId, { timeoutMs?, pollIntervalMs? }) |
Promise<CommandStatus> |
Throws on timeout |
writeFile({ path, content }) |
Promise<void> |
|
readFile({ path }) |
Promise<string | null> |
null if missing |
writeFiles(files) |
Promise<void> |
WriteFileParams[] |
createDirectory({ path }) / mkdir(path) |
Promise<void> |
Single path; use fs.mkdir(..., { recursive: true }) for nested |
Command
Properties: cmdId, output, exitCode (null while running), running
(getter), command, startedAt, updatedAt (the last three are populated only
on handles from listCommands(); null/"" otherwise). command is the
program name only (e.g. "echo"), not the full command line with args.
Methods: stdout(), stderr() (single combined stream), logs() →
Promise<CommandLog[]> (discrete timestamped entries), getStatus() →
CommandStatus, wait({ timeoutMs?, pollIntervalMs? }) → Command, kill().
RunCommandOpts: cmd, args, cwd, env, detached
CommandStatus: output, exitCode, running — CommandLog: timestamp, message
FileSystem (sandbox.fs)
| Method | Notes |
|---|---|
writeFile(path, content) or writeFile(params) |
Files API |
exists(path) |
|
mkdir(path, { recursive? }) |
|
stat(path) |
→ FileStat: fileType, size, mtime (Date), mode (octal string, e.g. "644") |
readdir(path) |
→ string[] |
rm(path, { recursive? }) |
|
rename(oldPath, newPath) |
|
copyFile(src, dest) |
|
chmod(path, mode) |
int octal (0o644) or string ("644") |
symlink(target, path) |
SandboxProcess (sandbox.process)
| Method | Returns | Notes |
|---|---|---|
createPty(opts) |
Promise<PtyHandle> |
Opens an interactive PTY session |
connectPty(sessionName, opts?) |
Promise<PtyHandle> |
Reattach; use kill() if wait() hangs |
listPtySessions() |
Promise<PtySessionInfo[]> |
|
getPtySessionInfo(id) |
Promise<PtySessionInfo> |
|
killPtySession(id) |
Promise<void> |
|
resizePtySession(id, cols, rows) |
Promise<PtySessionInfo> |
Requires an attached handle in this process |
PtyCreateOpts: sessionName, cwd, envs, cols, rows, onData
((data: Uint8Array) => void — each chunk is bytes)
PtyConnectOpts: onData ((data: Uint8Array) => void)
PtySessionInfo: id, active, cols?, rows?, cwd?, createdAt?, processId?
PtyHandle: id, exitCode, error, size, isConnected(),
waitForConnection(timeoutMs?), sendInput(string | Uint8Array), resize(cols, rows),
kill(), disconnect(), wait() → PtyResult. Static PtyHandle.discard — a
no-op onData sink to suppress the default stdout output.
PtyResult: exitCode (0 clean / -1 abnormal / null running), error
NetworkPolicy
new NetworkPolicy({ mode?, allowCidrs? }) — mode is "allow-all",
"deny-all", or "default-deny"; supplying allowCidrs without a mode implies
"default-deny". Helpers: toV1NetworkPolicy, fromV1NetworkPolicy.
Environment variables
| Variable | Maps to | Notes |
|---|---|---|
LIGHTNING_SANDBOX_API_KEY |
SandboxConfig.apiKey |
Primary auth (preferred) |
LIGHTNING_API_KEY |
SandboxConfig.apiKey |
Legacy fallback |
LIGHTNING_CLOUD_URL |
SandboxConfig.baseUrl |
Optional |
Troubleshooting
Missing API key
Set LIGHTNING_SANDBOX_API_KEY (or LIGHTNING_API_KEY), or call
Sandbox.configure({ apiKey }) before the first request.
delete() throws 404 sandbox not found / double cleanup
delete() is not idempotent — deleting an already-gone sandbox throws
404 sandbox not found. This is easy to hit in finally-style cleanup and after
stop() on a non-persistent box. Swallow the 404:
await sandbox.delete().catch((e) => {
if (!String(e).includes("404")) throw e; // ignore "already deleted/stopped"
});Sandbox landed in the wrong teamspace
The JS SDK has no teamspace argument — scope is set by the API key. Use a
teamspace-scoped key for the target project (Lightning UI → teamspace →
Members → API keys). See Authentication.
Snapshot restore returns running but files missing
Sandbox.create() already waits for running. If readFile is null, check the
path (was it in the snapshot?), confirm the snapshot finished (status: "ready"),
and verify with runCommand("cat", [path]).
Restored sandbox has open egress
Working as intended: snapshots do not carry network policy, so
Sandbox.create({ snapshotId }) starts at allow-all unless you pass
networkPolicy. Only resume() re-applies the original policy.
PTY connection hangs
PTY requires Node 22+ (built-in WebSocket). Call
pty.waitForConnection(30_000) to bound the wait.
Transient 500 failed to create sandbox
Sandbox.create() does not auto-retry. Call it again (ideally with a short
backoff) if it fails:
const sandbox = await Sandbox.create({ name: "demo", instanceType: "cpu-1" });Was this page helpful?
Thanks for the feedback.