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/sdk

Check 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 higher

The 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) or Sandbox.configure({ apiKey }). See Authentication.
  • Import what you need from the package root.
  • PTY sessions require Node 22+ for the built-in WebSocket global (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, Sandbox is not a separately-constructed client. All client operations are static (Sandbox.create, Sandbox.get, Sandbox.list, Sandbox.listSnapshots, …); an awaited Sandbox is the per-sandbox handle. There is no new Sandbox(config) — set process-wide credentials with Sandbox.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 (or LIGHTNING_API_KEY), or set them in-process with Sandbox.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 python313 box has no node and a node24 box has no python — 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 install the 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 / list call is an independent stateless request authenticated by the process-wide API key, so driving many sandboxes concurrently with Promise.all works 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 synchronous runCommand waits on the server until the remote process exits — fine for echo or a short script, wrong for multi-minute or unbounded work.

After starting a detached command you must either:

  1. Block until done — await cmd.wait({ timeoutMs, pollIntervalMs }), or
  2. Poll continuously — loop on cmd.getStatus() / sandbox.getCommand(cmdId) until running is 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 and kill():

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 persistent stop()/resume(). The JS SDK has no teamspace argument; 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"   # optional
import { 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 projectId empty (""). 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, so sandbox.projectId reads back "". This isn't reported at create time — instead it surfaces later as a confusing failure on the project-scoped operations (createSnapshot(), persistent stop() / resume()). Check sandbox.projectId is 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 (no pytest, numpy, etc.). pip install your test/build deps first, e.g. pip install pytest before running tests.
  • node24 / node22 → node + npm (tsx available in node24).

Prefer pip install over a venv in python313. The runtime's system interpreter already has pip (python -m pip install ... works), but python -m venv fails (its ensurepip step 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 GB

Custom 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 image

Private 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:

  • image and runtime are mutually exclusive — passing both throws.
  • imageSecretRef is only valid together with image — passing it alone throws.
  • Custom images are supported on CPU sandboxes only. GPU instance types reject image server-side.
  • You manage what's inside the image. Curated-runtime conveniences (e.g. tsx in node24, 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/endpoint property on a Sandbox. Reach a server from inside the sandbox via http://127.0.0.1:<port> (not localhost, 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;        // Date

Lifecycle

Delete

await sandbox.delete(); // destroys sandbox and any auto-snapshot

delete() is not idempotent. Deleting a sandbox that is already gone throws Lightning API error 404: sandbox not found. This bites finally-style cleanup and double-fires after stop() on a non-persistent box (which is equivalent to delete()). 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 hour

extendTimeout(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.timeout does not. The control plane returns an empty response, so extendTimeout() resolves to void and the timeout field keeps reporting the create-time lifetime even after a successful extend — re-fetching via Sandbox.get will 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 running Sandbox instance; 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-snapshots

Options:

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-all unless you pass networkPolicy on create({ snapshotId }). Only resume() (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 name filter does a case-insensitive substring match on the source sandbox name. listSnapshots({ name: "box" }) returns snapshots whose sourceSandboxName contains box (any case), e.g. My-Box and sandbox-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 Date objects. createdAt and updatedAt are parsed to Date (consistent with sandbox.createdAt), and expiresAt is a Date or null when 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 no sudo option — 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; running can still be true immediately afterward. Poll getStatus() or wait() after kill() to confirm the process stopped.

A killed command exits non-zero. After kill() + wait(), the command reports exitCode === -1 (not 0). 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.

command is the program name only — not the full args. The command-history API records just the executable (e.g. "echo" for runCommand("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 }); // → CommandStatus

Files

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 WebSocket open event (a few ms), but the remote shell is spawned and initialized server-side, so the prompt shows up as the first onData bytes about a second later. This is normal and harmless: input you sendInput in the meantime is buffered and runs once the shell is up — just stream output via onData rather than reading once immediately after waitForConnection().

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 shell

sendInput 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-all or 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 then Sandbox.create({ snapshotId }) without re-passing networkPolicy, the new sandbox comes back allow-all. Re-specify networkPolicy on every restore to keep a box locked down. Only resume() (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?

What went wrong?

↑↓ Navigate↵ Open⌘↵ New tabesc Close