Python Sandbox SDK
Use the Lightning Sandbox Python SDK to create ephemeral sandboxes, run commands, manage files, capture snapshots, and open interactive PTY sessions from Python.
For the command-line interface, see the CLI Reference.
Prerequisites
Install the Lightning SDK:
pip install lightning-sdk # Python 3.10+After installation:
- Provide credentials through
LIGHTNING_SANDBOX_API_KEY(recommended: a teamspace-scoped key) orlightning login. See Authentication. - Import everything from
lightning_sdk.sandbox. - PTY sessions additionally require
websocket-client(installed withlightning-sdk).
from lightning_sdk.sandbox import SandboxCore classes
| Class | What it does | Example |
|---|---|---|
Sandbox |
Client — creates sandboxes and lists/gets them, plus snapshot management | sandbox = Sandbox.create(...) |
SandboxInstance |
Handle for one sandbox — commands, files, lifecycle, fs, process |
sandbox.run_command(...) |
Command |
A running or finished command inside a sandbox | cmd = sandbox.run_command(...) |
Snapshot |
A saved sandbox filesystem you can restore later | snap = sandbox.snapshot() |
FileSystem |
Shell-based file operations, via sandbox.fs |
sandbox.fs.readdir("/tmp") |
SandboxProcess |
Interactive PTY sessions, via sandbox.process |
sandbox.process.create_pty(...) |
SandboxConfig |
API key and base URL | SandboxConfig(api_key="sk-lit-...") |
Sandbox.create() blocks until the sandbox status is running and returns a
SandboxInstance. Remote sandboxes are not destroyed when the Python object
is garbage-collected — call delete() explicitly.
Quickstart
from lightning_sdk.sandbox import Sandbox
sandbox = Sandbox.create(name="quickstart", instance_type="cpu-1")
result = sandbox.run_command("echo", ["Hello, World!"])
print(result.output.rstrip()) # Hello, World!
print(result.exit_code) # 0
sandbox.delete()Or with an explicit client instance:
client = Sandbox()
sandbox = client.create(name="quickstart", instance_type="cpu-1")Credentials are read from
LIGHTNING_SANDBOX_API_KEY(orlightning login). See Authentication for the recommended teamspace-scoped key and in-process configuration.
Choose the right API
| Need | Use |
|---|---|
| One-shot shell command | sandbox.run_command("cmd", ["args"]) |
| Run a Python / TypeScript script | runtime= + write_file + run_command — see Common tasks |
| Pick language/OS image | runtime= on Sandbox.create() (see Runtimes) |
| Bring your own Docker image | image= (+ image_secret_ref= for private registries) on Sandbox.create() (see Custom Docker images) |
| Long-running / background command | run_command(RunCommandOpts(cmd=..., detached=True)) then cmd.wait() or poll — see Long-running scripts |
| Write/read files via API | sandbox.write_file / sandbox.read_file |
File ops via shell (ls, chmod, cp) |
sandbox.fs.exists, .stat, .readdir, etc. |
| Interactive shell (stdin, resize) | sandbox.process.create_pty(PtyCreateOpts(...)) |
| Save disk image | sandbox.snapshot() then Sandbox.create(snapshot_id=...) |
| Survive stop/resume | persistent=True, stop(), resume() |
| Lock down egress | network_policy= 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 run_command.
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 blocks until the script exits).
The
run_command("cmd", ["args"])shorthand takes the command and args only. To setcwd,env, ordetached, userun_command(RunCommandOpts(cmd=..., args=..., cwd=...))instead (see Structured options).
Inline one-liner:
from lightning_sdk.sandbox import Sandbox
sandbox = Sandbox.create(name="py-script", instance_type="cpu-1", runtime="python313")
result = sandbox.run_command("python", ["-c", "print('hello from python')"])
print(result.exit_code, result.output)
sandbox.delete()Upload a file, then run it:
sandbox = Sandbox.create(name="py-script", instance_type="cpu-1", runtime="python313")
sandbox.write_file("/tmp/work.py", "print('hello from file')\n")
result = sandbox.run_command("python", ["/tmp/work.py"])
print(result.exit_code, result.output)
sandbox.delete()Run a simple TypeScript script
Use a Node runtime. Upload the script (or paste source with write_file), then
invoke it with npx tsx. tsx is not pre-installed, so npx tsx downloads
it on first use (needs egress); to avoid the download, npm install tsx into
your project first.
from lightning_sdk.sandbox import Sandbox
sandbox = Sandbox.create(name="ts-script", instance_type="cpu-1", runtime="node24")
sandbox.write_file(
"/tmp/hello.ts",
"const msg: string = 'hello from typescript';\nconsole.log(msg);\n",
)
result = sandbox.run_command("npx", ["tsx", "/tmp/hello.ts"])
print(result.exit_code, result.output)
sandbox.delete()For plain JavaScript, use runtime="node24" and node /tmp/script.js.
Installing npm dependencies first. A bare npm install <pkg> fails (exit 1) in
an empty cwd because there is no package.json. Initialize a project first, then
install into it and run from that directory:
from lightning_sdk.sandbox import RunCommandOpts
sandbox.run_command("sh", ["-c", "mkdir -p /tmp/app && cd /tmp/app && npm init -y && npm install left-pad"])
sandbox.write_file("/tmp/app/index.ts", "import pad from 'left-pad';\nconsole.log(pad('ok', 5));\n")
result = sandbox.run_command(
RunCommandOpts(cmd="npx", args=["tsx", "index.ts"], cwd="/tmp/app"),
)
print(result.exit_code, result.output)Dependencies land in /tmp/app/node_modules; run subsequent commands from that
directory (RunCommandOpts(..., cwd="/tmp/app")) so Node resolves them. cwd is
only available via RunCommandOpts, not the run_command("cmd", [args]) shorthand.
Run multiple commands
Sequential (each call waits for the previous to finish):
from lightning_sdk.sandbox import RunCommandOpts
sandbox.run_command("mkdir", ["-p", "/tmp/demo"])
sandbox.run_command(RunCommandOpts(cmd="echo", args=["step 1"], cwd="/tmp/demo"))
sandbox.run_command(RunCommandOpts(cmd="echo", args=["step 2"], cwd="/tmp/demo"))cwd, env, and detached are only accepted via RunCommandOpts. The
run_command("cmd", ["args"]) shorthand takes the command and args only.
Single shell pipeline — one round trip, shared cwd/env:
sandbox.run_command(
"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 across threads is fine. Each
create/run_command/listcall is an independent stateless request over the shared process-wide client, so driving many sandboxes from aThreadPoolExecutor(matrix runs, sweeps, fleet operations) works without extra locking. Use a distinctSandbox(config=...)per client only when you need different keys.
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 synchronousrun_commandblocks your client 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 —
cmd.wait(timeout=..., poll_interval=...), or - Poll continuously — loop on
cmd.get_status()/sandbox.get_command(cmd_id)untilrunningis false, printing logs between polls if you want live output.
wait()on timeout raisesTimeoutError— 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():try: result = cmd.wait(timeout=300, poll_interval=1.0) except TimeoutError: cmd.kill() # ran too long — stop it
sandbox.wait_for_command(cmd_id, timeout=...)behaves the same way.
import time
from lightning_sdk.sandbox import RunCommandOpts, Sandbox
sandbox = Sandbox.create(name="long-job", instance_type="cpu-1", runtime="python313")
sandbox.write_file("/tmp/train.py", "import time\nfor i in range(5):\n print(f'step {i}'); time.sleep(1)\n")
# Option A — wait() blocks until the process exits
cmd = sandbox.run_command(
RunCommandOpts(cmd="python", args=["/tmp/train.py"], detached=True),
)
result = cmd.wait(timeout=600, poll_interval=1.0)
print(result.exit_code, result.output)
# Option B — poll in a loop (useful for streaming partial output)
server = sandbox.run_command(
RunCommandOpts(cmd="python", args=["-c", "import time\nwhile True: time.sleep(3600)"], detached=True),
)
while True:
status = sandbox.get_command(server.cmd_id)
print(status.output)
if not status.running:
break
time.sleep(2)
server.kill()
server.wait(timeout=30)
sandbox.delete()Use cmd.kill() or sandbox.kill_command(cmd_id) to stop a detached process
you no longer need. See Detached commands and
python/examples/detached_command.py.
Authentication
Recommended: use a project-scoped (teamspace-scoped) API key. Create one in the Lightning UI under your teamspace → Members → API keys. The key already selects its project, so it covers everything sandboxes need —
create, commands, files,snapshot(), and persistentstop()/resume()— and you do not passteamspace=.
Set the key
Set the key in your shell before the process starts — env vars are read once,
at import, so setting os.environ[...] after import has no effect:
export LIGHTNING_SANDBOX_API_KEY="sk-lit-..."
export LIGHTNING_CLOUD_URL="https://lightning.ai" # optionalfrom lightning_sdk.sandbox import Sandbox
# Key is picked up automatically — no config= or teamspace= needed.
sandbox = Sandbox.create(name="demo", instance_type="cpu-1")Common gotcha — setting the env var after import does nothing. The SDK reads credentials once, at import time. This fails:
import os from lightning_sdk.sandbox import Sandbox # credentials read here, before the key is set os.environ["LIGHTNING_SANDBOX_API_KEY"] = "sk-lit-..." # too late — ignored Sandbox.create(name="demo", instance_type="cpu-1") # fails: "...not your personal login key..."For scripts that set the key in-process, use
Sandbox.configure(...)(below) — it just works regardless of import order.
To supply or switch the key inside a running process (env vars can't change
after import), use Sandbox.configure() or per-call config=:
from lightning_sdk.sandbox import Sandbox, SandboxConfig
Sandbox.configure(api_key="sk-lit-...", base_url="https://lightning.ai")
sandbox = Sandbox.create(name="demo", instance_type="cpu-1")
# Or per call:
config = SandboxConfig(api_key="sk-lit-...")
sandbox = Sandbox.create(config=config, name="demo", instance_type="cpu-1")Precedence: config= → Sandbox.configure() defaults → env vars →
lightning login. This applies to both Sandbox.create() and the
Sandbox() client constructor: a bare Sandbox() (no config=) shares the
process-wide client, so Sandbox.configure(...) defaults reach list,
get, list_snapshots, delete_snapshot, etc. — not just create(). Pass
Sandbox(config=SandboxConfig(api_key=...)) to use an isolated key for one
client instead.
When to pass teamspace=
LIGHTNING_SANDBOX_API_KEY should ideally be a teamspace-scoped key. When it
is, the key already selects the project — omit teamspace=. Pass
teamspace="<teamspace-owner>/<teamspace-name>" only when the key is
org-scoped or not set (i.e. you rely on lightning login).
LIGHTNING_SANDBOX_API_KEY |
teamspace= |
|---|---|
| Teamspace-scoped (recommended) | Omit it — the key selects the project. Mixing in teamspace= is not recommended. |
| Org-scoped | Pass "<teamspace-owner>/<teamspace-name>"; the owner org must match the key's org, or the call fails. Requires lightning login creds — see the caveat below. |
Not set (lightning login) |
Pass "<teamspace-owner>/<teamspace-name>" to choose the project |
<teamspace-owner> is an org slug or username (see
Teamspace). After create, confirm
placement with print(sandbox.project_id).
⚠️ Resolving a
teamspace="owner/name"string requireslightning logincredentials —LIGHTNING_SANDBOX_API_KEYalone is NOT used for name resolution. Any call that must resolve a teamspace name (create,list,list_snapshotswithteamspace=) goes through the user-level (lightning login) auth path, not the sandbox API key. With only an API key set (env,config=, orSandbox.configure()), passingteamspace="org/general"fails withConnectionError: Authentication failed. Please run 'lightning login'.Practical consequences:
- The recommended API-key-only setup is a teamspace-scoped key with
teamspace=omitted — the key already scopes to its project, so no name resolution is needed.client.list()with noteamspace=then returns just that project's sandboxes (verified).- The org-scoped-key story is only achievable if you also have
lightning logincredentials available (for the name lookup), or you pass a pre-resolved project id.- The
client.list(..., teamspace=...)/list_snapshots(..., teamspace=...)examples below assumelightning logincreds are present; with an API key alone, omitteamspace=.
Org-scoped key + omitted
teamspace=fails late, not loudly. With an org-scoped key,create()succeeds even withoutteamspace=— but it leaves the sandbox without a project, sosandbox.project_idis empty (""). No error is raised at create time; instead the missing project surfaces later as a confusing failure onsnapshot()/ persistentstop()/resume(). Always passteamspace=with an org-scoped key and checksandbox.project_idis non-empty right after create.
Creating sandboxes
Common options
sandbox = Sandbox.create(
name="my-sandbox", # auto-generated if omitted
instance_type="cpu-1", # machine slug (default: cpu-1)
runtime="python313", # base runtime image (mutually exclusive with image=)
spot=False,
ports=[8080],
teamspace="owner/teamspace", # resolved to project_id
snapshot_id="snap-uuid", # restore from snapshot
persistent=True, # survive stop() via auto-snapshot
storage_gb=40, # writable disk override (CPU sandboxes)
timeout=3_600_000, # initial max lifetime in ms; extend later with sandbox.extend_timeout()
network_policy="allow-all", # or "deny-all" or NetworkPolicy(...)
)Runtimes
Runtime is the base language/OS image inside the sandbox (pre-installed Python,
Node, shell tools). instance_type 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.
| Parameter | What it controls |
|---|---|
runtime="python313" |
Language stack / base image |
instance_type="cpu-1" |
vCPU, memory, accelerator |
py = Sandbox.create(name="py-worker", instance_type="cpu-1", runtime="python313")
js = Sandbox.create(name="js-worker", instance_type="cpu-1", runtime="node24")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 |
The only supported runtime= values are python313, node24, and node22
(node24 is the default when runtime= is omitted). For anything else, bring a
custom image with image= (see Custom Docker images).
What each runtime ships. python313 provides Python + pip only — no
pytest/poetry, and no node. node24/node22 provide node + npm/npx
only — no python, and tsx is not pre-installed. Anything else (test
runners, tsx, compilers) must be installed at runtime, which needs egress
(see Network policy).
When you omit runtime=, the sandbox uses node24 by default.
sandbox.runtime on the instance is the value you passed at create (often None
when omitted), not necessarily the image the cluster actually started.
Slugs not enabled on your cluster fail at Sandbox.create() with an API error.
To confirm what your org supports, check the runtime picker in the Lightning UI
when creating a sandbox, or read snapshot.runtime from an existing snapshot
(client.get_snapshot(snap.id).runtime).
The same slugs apply to the CLI via --runtime (e.g. --runtime python313); omit
it to get the node24 default.
No programmatic runtime catalog. There is no list_runtimes()-style call to
enumerate slugs at runtime; use the supported list above (and the Lightning UI
runtime picker for cluster-specific availability).
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), and there is no documented compiled-language
slug. For compiled languages or mixed-language pipelines, install the toolchain at
runtime in one sandbox, then build/run:
sandbox = Sandbox.create(name="build", instance_type="cpu-1", runtime="python313")
# Install a toolchain (needs egress — see Network policy) then use it:
sandbox.run_command("sh", ["-c", "apt-get update && apt-get install -y gcc && gcc --version"])If a tool is missing, probe with command -v <tool> before relying on it. To
combine languages, install the extra toolchain(s) into the same sandbox rather
than expecting one image to ship several.
Snapshots: snapshot() captures the filesystem and records the runtime.
Sandbox.create(snapshot_id=...) restores from that snapshot; only pass runtime=
if you need to override the snapshot's image.
Instance types
instance_type= selects the machine (separate from runtime=, which selects the
language image). Pick the smallest slug that fits your workload to keep cost down —
choose the one that suits your purpose. Supported CPU slugs:
| Slug | vCPU | RAM |
|---|---|---|
cpu-1 (default) |
1 | 1 GB |
cpu-2 |
2 | 8 GB |
cpu-4 |
4 | 16 GB |
cpu-8 |
8 | 32 GB |
cpu-16 |
16 | 64 GB |
sandbox = Sandbox.create(runtime="python313", instance_type="cpu-4") # 4 vCPU / 16 GBCustom Docker images
Instead of a curated runtime= slug, point a sandbox at any OCI image with
image=. The control plane uses it as the sandbox rootfs:
sandbox = Sandbox.create(
name="my-sandbox",
instance_type="cpu-1",
image="docker.io/library/python:3.13-slim", # any OCI reference
)
print(sandbox.image) # "docker.io/library/python:3.13-slim"
print(sandbox.image_secret_ref) # "" for a public imageThe reference can be a tag (ghcr.io/org/img:latest) or a digest
(ghcr.io/org/img@sha256:...). Read it back with sandbox.image
("" when the sandbox was created from a runtime= slug instead).
Private registries — image_secret_ref. For an image that needs pull
credentials, pass image_secret_ref= — the name of a project-scoped Secret
of type Docker registry (the same mechanism Jobs use). The control plane
resolves it to the registry credentials at pull time:
sandbox = Sandbox.create(
name="my-sandbox",
instance_type="cpu-1",
image="ghcr.io/myorg/myimage:latest",
image_secret_ref="my-docker-registry-secret", # project Secret name
)Create that Secret ahead of time in the Lightning UI (teamspace → Secrets),
in the same teamspace the sandbox runs in. Omit image_secret_ref for public
images (anonymous pull).
Rules and limits:
image=andruntime=are mutually exclusive — passing both raisesValueError. Pick a curated runtime or bring your own image.image_secret_ref=is only valid together withimage=— passing it alone raisesValueError.- 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.
npm/npxinnode24) are only present if your image ships them.
For the --image / --image-secret-ref CLI flags, see the
CLI Reference.
Exposed ports
Pass ports=[8080] at create time to expose ports; sandbox.ports lists the
exposed port numbers back (as strings).
sandbox = Sandbox.create(name="web", instance_type="cpu-1", ports=[8080])
print(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 onSandboxInstance(portsonly echoes the numbers). You can start a server on the port inside the sandbox, but reaching it from outside requires an endpoint the SDK does not surface today — obtain it from the Lightning UI / your cluster's ingress, or connect from another sandbox/process on the same network. Treat in-sandbox / sandbox-to-sandbox traffic as the supported path until a public-URL accessor is added.
Inside the sandbox, reach a local server via
127.0.0.1, notlocalhost. The sandbox may not resolvelocalhost—http://localhost:8080can fail withsocket.gaierror: [Errno -2] Name or service not known, whilehttp://127.0.0.1:8080works.
Lookup and listing
client = Sandbox()
existing = client.get("sandbox-uuid")
page = client.list(limit=10, teamspace="owner/teamspace")
for sb in page.sandboxes:
print(sb.sandbox_id, sb.status)
print(page.next_page_token)Passing
teamspace="owner/teamspace"here resolves the name and so needslightning logincredentials — an API key alone is not enough (see Authentication). With a teamspace-scoped key, callclient.list(limit=10)withoutteamspace=; the key already scopes the result to its project.
A teamspace-scoped key lists only its own project. To inventory across the
whole org (all teamspaces), use an org-scoped key and call list()
without teamspace= — it returns every sandbox in the org and needs no name
resolution (no lightning login). Alternatively, call list(teamspace=...) once
per teamspace (name resolution requires lightning login credentials — see
Authentication), or use a per-project key for each.
Teamspace
The teamspace argument is a human-readable name, not a UUID. The SDK
resolves it to project_id on the server. sandbox.project_id on an instance
is that UUID — you cannot reverse it to owner/teamspace from the sandbox SDK alone.
String format: owner/teamspace
owner |
Meaning |
|---|---|
| Organization slug | Org-owned teamspace, e.g. my-org/engineering |
| Username | User-owned teamspace, e.g. alice/personal |
How to find the string:
- Lightning UI — teamspace URL or settings (same format as deployment CLI)
- CLI —
lightning sandbox list --teamspace owner/teamspace(see--helpexamples) - From a sandbox you already created — note the teamspace you picked at create time;
project_idalone is not enough to reconstruct the string
Teamspace object (alternative to the string):
from lightning_sdk.sandbox import Sandbox
from lightning_sdk.teamspace import Teamspace
# Org-owned teamspace
ts = Teamspace("engineering", org="my-org")
sandbox = Sandbox.create(name="demo", instance_type="cpu-1", teamspace=ts)
# User-owned teamspace
ts = Teamspace("personal", user="alice")
sandbox = Sandbox.create(name="demo", instance_type="cpu-1", teamspace=ts)
# String equivalent
Sandbox.create(..., teamspace="my-org/engineering")When to pass teamspace: Only when LIGHTNING_SANDBOX_API_KEY is
org-scoped or not set (lightning login). With an org-scoped key, the
<teamspace-owner> must match the key's org or the call fails. With a
teamspace-scoped key (recommended) the key already selects its project — omit
teamspace= and check sandbox.project_id after create. See
Authentication.
Instance properties
sandbox.sandbox_id
sandbox.name
sandbox.organization_id # from control plane / API key scope
sandbox.project_id # teamspace scope
sandbox.instance_type
sandbox.spot
sandbox.persistent
sandbox.status # e.g. "running", "paused"
sandbox.runtime
sandbox.ports
sandbox.image # custom OCI image, if created with one ("" otherwise)
sandbox.image_secret_ref # project secret used to pull image ("" otherwise)
sandbox.snapshot_id # source snapshot, if restored ("" otherwise)
sandbox.user_id # creator ("" if not tracked)
sandbox.storage_gb # writable disk size in GB (0 = instance default)
sandbox.timeout # initial max lifetime in ms (0 = no timeout); NOT refreshed by extend_timeout()
sandbox.network_policy # NetworkPolicy (see Network policy)
sandbox.created_at
sandbox.updated_atLifecycle
Delete
sandbox.delete() # destroys sandbox and any auto-snapshotdelete() works on a sandbox in any state, including a stopped
(paused) persistent sandbox — you do not need to resume() first to reap
one you stopped and no longer want. delete() is also a safe no-op if the
sandbox is already gone: a missing sandbox (HTTP 404) returns cleanly instead
of raising, so it's safe to call unconditionally in a finally: / cleanup block
for guaranteed teardown.
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 extend_timeout(ms) — the only way to move the
deadline after creation:
sandbox = Sandbox.create(runtime="python313", timeout=3_600_000) # initial 1h deadline
sandbox.extend_timeout(3_600_000) # push the deadline out by another hourextend_timeout(timeout) 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 raises ValueError before any request is sent.
Call it repeatedly — e.g. as a heartbeat — to keep an agent's sandbox alive
while work remains:
while agent_has_work():
do_a_chunk_of_work()
sandbox.extend_timeout(600_000) # +10 min each iterationThe deadline moves, but
sandbox.timeoutdoes not. The control plane returns an empty response, soextend_timeout()returnsNoneand the cachedsandbox.timeoutproperty keeps reporting the create-time lifetime even after a successful extend — re-fetching the sandbox 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. With such a key you normally do not
need to pass teamspace= — the key supplies its project and sandbox.project_id
is populated. Org-wide keys may return a snapshot id from stop() but fail to
restore files on resume().
persistent = Sandbox.create(
name="warm",
instance_type="cpu-1",
persistent=True,
# No teamspace= needed: a teamspace-scoped key already selects its project.
)
persistent.write_file("/root/state.txt", "saved\n")
auto_snapshot_id = persistent.stop() # returns auto-snapshot id (non-empty)
persistent.resume() # polls until running again
content = persistent.read_file("/root/state.txt")
assert content == "saved\n"
persistent.delete()For non-persistent sandboxes, stop() is equivalent to delete() and returns an
empty snapshot id.
If read_file is None after resume(), verify you used a teamspace-scoped
key. See python/examples/snapshot_examples.py.
Snapshots
Capture the filesystem (not running processes):
snap = sandbox.snapshot() # polls until status == "ready"
print(snap.id, snap.status, snap.size_bytes)
print(snap.created_at, snap.expires_at) # timestamps now surfaced
print(snap.source_sandbox_id, snap.source_sandbox_name) # lineage
print(snap.auto) # False for user snapshots; True for persistent-stop auto-snapshotsOptions:
snap = sandbox.snapshot(
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
wait_timeout=600,
)expirationis a TTL in milliseconds (integer) — this is the unit declared by the API contract (SandboxesServiceCreateSandboxSnapshotBody: "Optional expiration in milliseconds … Use 0 to request no expiration"). Pass0for no expiration, or omit it for the platform default. A non-numeric value such as"24h"is rejected (400: invalid value for uint64 field expiration). Cleanup is not immediate — a small TTL won't delete the snapshot the instant it elapses (a background reaper handles it, and the platform may apply a minimum), so don't rely on sub-minute expirations behaviorally.excludesis a list of paths to leave out of the captured filesystem.
Restore into a new sandbox. Restore-by-id derives project context from the snapshot, so an org-scoped key restores fine (within the same org) — you do not need the original teamspace-scoped key just to restore:
restored = Sandbox.create(snapshot_id=snap.id, instance_type="cpu-1")
# Sandbox.create() blocks until status == "running" — no extra poll for restore
content = restored.read_file("/root/marker.txt")
if content is None:
# Path not in snapshot, snapshot still empty, or a cross-org id
restored.run_command("cat", ["/root/marker.txt"]) # cross-check via shell
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 passnetwork_policy=oncreate(snapshot_id=...). To keep a locked-down box locked down, re-specify it. See Network policy.
Creating a snapshot requires a storage-backed cluster and a teamspace-scoped
API key (an org-scoped key fails snapshot() with a clear "requires a
teamspace-scoped API key" message). snap.project_id shows which project owns the
snapshot.
client = Sandbox()
snap = client.get_snapshot("snap-uuid")
result = client.list_snapshots( # returns ListSnapshotsResult (paginated)
teamspace="owner/teamspace", # scope to a project
sort_order="desc", # order by creation time ("asc" / "desc")
limit=50,
)
for snap in result.snapshots:
print(snap.id, snap.status, snap.created_at, snap.expires_at)
if result.next_page_token:
more = client.list_snapshots(page_token=result.next_page_token)
client.delete_snapshot("snap-uuid")The
name=filter does a case-insensitive substring match on the source sandbox name.list_snapshots(name="box")returns snapshots whosesource_sandbox_namecontainsbox(any case), e.g.My-Boxandsandbox-42:result = client.list_snapshots(name="my-source", sort_order="desc", limit=50) for snap in result.snapshots: print(snap.id, snap.source_sandbox_name)
Snapshot exposes id, status, runtime, runtime_image, size_bytes,
project_id, organization_id, rootfs_digest, created_at, updated_at,
expires_at, excludes, and the source-sandbox lineage
(source_sandbox_id, source_sandbox_name, source_sandbox_instance_type).
auto is True for auto-snapshots captured when a persistent sandbox is
stopped. To prune old snapshots ("keep the last N", "delete anything older than
X"), use created_at / expires_at together with sort_order instead of
encoding the time in the snapshot name.
Note:
snapshot()uploads to object storage. Requires a storage-backed cluster and typically a project-scoped API key.
Commands
Synchronous (default)
result = sandbox.run_command("uname", ["-a"])
print(result.exit_code, result.output)
sandbox.run_command("echo hello world") # shell-split shorthandStructured options
from lightning_sdk.sandbox import RunCommandOpts
result = sandbox.run_command(
RunCommandOpts(
cmd="python",
args=["-c", "print('hi')"],
cwd="/root",
env={"FOO": "bar"},
detached=False,
)
)You always have root. Every command runs as
root(uid 0) inside the sandbox, so there is nosudooption (and no--sudoCLI flag) — it would be redundant, and thesudobinary isn't installed on the stock runtimes anyway. Just run privileged commands directly, e.g.apt-get install -y ....
Detached commands
Use for any command that outlives a quick shell call. Synchronous
run_command (default) blocks until exit; detached mode returns immediately
with a Command handle — you must wait() or poll until running is false.
See Long-running and forever-running scripts.
cmd = sandbox.run_command(RunCommandOpts(cmd="sleep", args=["10"], detached=True))
cmd.id # server-assigned command id (alias of cmd.cmd_id)
cmd.running # True while executing; exit_code is None until it exits
status = cmd.get_status() # refresh output/exit_code from the server
logs = cmd.logs() # list[CommandLog] (timestamp, message)
cmd.wait(timeout=30) # block until exit; exit_code now populated
cmd.kill()
kill()is asynchronous. It only signals the process —cmd.running/get_command(...).runningcan still beTrueimmediately afterward. To confirm the process actually stopped, pollget_status()/get_command()or callcmd.wait()afterkill().
Inspecting commands by id and history
get_command(cmd_id) re-fetches a command as a Command handle (status only —
no timestamps); list_commands() returns the sandbox's whole command history as
Command handles, with started_at / updated_at populated.
cmd = sandbox.get_command(cmd_id)
print(cmd.id, cmd.exit_code, cmd.running) # started_at is None here
for c in sandbox.list_commands():
print(c.id, c.command, c.exit_code, c.running, c.started_at)Logs while running:
logs()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
These take a cmd_id directly; prefer the Command methods above when you have a
handle.
status = sandbox.get_command(cmd_id) # → Command
logs = sandbox.get_command_logs(cmd_id) # → list[CommandLog]
sandbox.kill_command(cmd_id)
final = sandbox.wait_for_command(cmd_id, timeout=60, poll_interval=0.5) # → CommandStatusFiles
Direct API (files endpoint)
sandbox.write_file("/tmp/hello.txt", "hello\n")
content = sandbox.read_file("/tmp/hello.txt") # None if missing
from lightning_sdk.sandbox import WriteFileParams
sandbox.write_files([
WriteFileParams(path="/tmp/a.txt", content="a\n"),
])
sandbox.mkdir("/tmp/mydir") # alias: create_directorymkdir / create_directory use the directories API on SandboxInstance.
For shell-based directory creation (including parents), use sandbox.fs.mkdir.
Binary files (.ckpt, compiled binaries, datasets). write_file / read_file
are text-only (content is a str, decoded as UTF-8) — they cannot carry raw
binary. To move a binary artifact in or out, base64-encode it through the shell:
import base64
# Upload a local binary into the sandbox
blob = base64.b64encode(open("model.ckpt", "rb").read()).decode()
sandbox.write_file("/tmp/model.b64", blob)
sandbox.run_command("sh", ["-c", "base64 -d /tmp/model.b64 > /tmp/model.ckpt"])
# Download a binary produced inside the sandbox
sandbox.run_command("sh", ["-c", "base64 -w0 /tmp/out.bin > /tmp/out.b64"])
data = base64.b64decode(sandbox.read_file("/tmp/out.b64"))
open("out.bin", "wb").write(data)For large artifacts, prefer pushing/pulling from object storage inside the sandbox
(e.g. run_command with your storage CLI) rather than base64 over the files API.
FileSystem helpers (sandbox.fs)
Shell-based helpers (except write_file, which uses the files API).
fs = sandbox.fs
fs.mkdir("/tmp/mydir")
fs.mkdir("/tmp/a/b/c", recursive=True) # mkdir -p
fs.write_file("/tmp/mydir/hello.txt", "hello\n")
fs.exists("/tmp/mydir/hello.txt")
st = fs.stat("/tmp/mydir/hello.txt")
# FileStat: file_type, size, mtime, mode
print(st.file_type, st.size, st.mtime, st.mode)
# st.mode is a STRING of octal digits as `stat -c %a` prints them, e.g. "644"
# (no "0o" prefix). Don't run oct()/int() conversions on it; compare/convert
# explicitly, e.g. int(st.mode, 8) == 0o644.
fs.readdir("/tmp/mydir")
fs.copy_file("/tmp/mydir/hello.txt", "/tmp/mydir/copy.txt")
fs.rename("/tmp/mydir/copy.txt", "/tmp/mydir/renamed.txt")
fs.chmod("/tmp/mydir/renamed.txt", 0o644) # int octal or str, e.g. "644"
fs.symlink("/tmp/mydir/hello.txt", "/tmp/mydir/link")
fs.rm("/tmp/mydir/link")
fs.rm("/tmp/mydir", recursive=True)PTY sessions
For interactive shells, use sandbox.process instead of run_command.
Requires websocket-client (installed with lightning-sdk).
from lightning_sdk.sandbox import Sandbox, PtyCreateOpts, PtyHandle, write_to_stdout
sandbox = Sandbox.create(name="pty-demo", instance_type="cpu-1")
pty = sandbox.process.create_pty(
PtyCreateOpts(
session_name="shell",
cwd="/root",
cols=120,
rows=30,
on_data=write_to_stdout, # default when omitted; flushes per chunk
)
)
pty.wait_for_connection()
pty.send_input("uname -a\nexit\n")
pty.wait()
sandbox.delete()Suppress output: on_data=PtyHandle.discard (static no-op callback).
on_data receives bytes, not str — the signature is
Callable[[bytes], None]. Each chunk is raw terminal output (combined
stdout/stderr) forwarded verbatim from the wire. A custom callback must handle
bytes (accumulate as bytes, or decode), e.g.:
buf = bytearray()
pty = sandbox.process.create_pty(
PtyCreateOpts(
session_name="shell",
on_data=lambda chunk: buf.extend(chunk), # chunk is bytes
)
)
# ...later...
text = buf.decode() # decode when you need a str
# "".join(chunks) # WRONG if chunks are bytes — TypeError: expected str, bytes foundSending Ctrl+C (and other control bytes). pty.kill() sends Ctrl+C and
tears down the session. To interrupt a command running in the PTY without
disconnecting, send the raw control byte with send_input:
pty.send_input(b"\x03") # Ctrl+C — interrupts the foreground process, session stays open
pty.send_input("\n") # ...then keep using the same shellsend_input accepts str, bytes, or bytearray (bytes are decoded as
UTF-8), so ASCII control bytes work directly, e.g. b"\x04" for Ctrl+D / EOF.
Disconnect vs kill vs connect_pty
| Action | What it does |
|---|---|
pty.disconnect() |
Closes the WebSocket; shell keeps running on the sandbox |
pty.kill() |
Ctrl+C + disconnect |
process.kill_pty_session(name) |
Kills local handle + remote screen session |
connect_pty(name, ...) |
Opens a new WebSocket with restore=True to reattach |
Same SDK process — typical flow:
pty = sandbox.process.create_pty(PtyCreateOpts(session_name="shell"))
pty.wait_for_connection()
pty.send_input("long-running-command\n")
pty.disconnect() # shell still running
reattached = sandbox.process.connect_pty(
"shell",
PtyConnectOpts(on_data=PtyHandle.discard),
)
reattached.wait_for_connection()
# Reattached session may not exit cleanly on "exit\n" if the shell is mid-command.
# Prefer kill_pty_session("shell") to tear down, or send Ctrl+C before exit.
reattached.kill()wait() blocks until the WebSocket closes. Sending exit\n is not a reliable
way to make wait() return promptly — it can hang / TimeoutError on a
reattached (connect_pty) session and on a freshly created one. Don't
rely on exit\n + wait() to tear a PTY down; prefer kill() /
kill_pty_session() to close any session deterministically.
Session bookkeeping:
info = sandbox.process.get_pty_session_info("shell")
sessions = sandbox.process.list_pty_sessions()
sandbox.process.kill_pty_session("shell")
sandbox.process.resize_pty_session("shell", 150, 40)Network policy
Egress firewall, set at create time only — no runtime mutation. A single
NetworkPolicy models every mode (mirrors Vercel's NetworkPolicy):
| Value | Behavior |
|---|---|
omit / "allow-all" / NetworkPolicy("allow-all") |
Open egress (default) |
"deny-all" / NetworkPolicy("deny-all") |
Block all egress |
NetworkPolicy(allow_cidrs=[...]) |
CIDR allowlist; deny everything else (mode becomes "default-deny") |
create() accepts the bare "allow-all" / "deny-all" strings as a shorthand
for the equivalent NetworkPolicy(...).
How blocked egress behaves. A connection to a destination the policy denies is dropped, not refused. From inside the sandbox the connection attempt hangs until your client's own timeout rather than failing fast — so set an explicit connect timeout on outbound requests to a locked-down sandbox (e.g.
requests.get(url, timeout=5)). Each blocked destination is logged to the sandbox's own log stream asnetwork egress blocked by policy: <ip>(rate-limited), so you can see which address a workload was denied. The log names the destination IP, not the domain, because enforcement is IP-based.
from lightning_sdk.sandbox import NetworkPolicy, Sandbox
locked = Sandbox.create(
name="egress-test",
instance_type="cpu-1",
network_policy=NetworkPolicy(allow_cidrs=["10.0.0.0/8"]),
)
locked.delete()Read-back: SandboxInstance.network_policy always returns a NetworkPolicy.
Open egress / no stored policy normalizes to NetworkPolicy("allow-all"), matching
the create-time default — so you never get None or a bare string back.
policy = locked.network_policy # NetworkPolicy
print(policy.mode) # "default-deny"
print(policy.allow_cidrs) # ["10.0.0.0/8"]Restore vs resume behave differently — restore does NOT carry the policy:
| Operation | Network policy on the new/restarted sandbox |
|---|---|
Sandbox.create(snapshot_id=...) (restore) |
Does NOT inherit the source. Snapshots do not carry network policy. The restored sandbox uses the network_policy= you pass, or defaults to allow-all (open egress) if you omit it. |
resume() after persistent stop() |
Re-applies the original policy. Resume keeps the same sandbox id, so its stored policy is restored automatically. |
⚠️ Security — restoring a snapshot does NOT preserve egress lockdown. If you snapshot a
deny-all/ CIDR-allowlist sandbox and thenSandbox.create(snapshot_id=...)without re-passingnetwork_policy=, the new sandbox comes backallow-alland can reach the public internet. This is by design (the policy lives on the sandbox record, not the snapshot). You MUST re-specifynetwork_policy=on every restore to keep a box locked down:# WRONG — restored box is open (allow-all), even though the source was locked restored = Sandbox.create(snapshot_id=snap.id, instance_type="cpu-1") # RIGHT — re-apply the lockdown explicitly on restore restored = Sandbox.create( snapshot_id=snap.id, instance_type="cpu-1", network_policy=NetworkPolicy(allow_cidrs=["10.0.0.0/8"]), # or "deny-all" )Only
resume()(persistent stop → start) preserves the original policy, because it reuses the same sandbox id.
Command-line interface
Every SDK capability is also available through the lightning sandbox command
group for shell scripting and CI. It shares the same credentials
(LIGHTNING_SANDBOX_API_KEY, LIGHTNING_CLOUD_URL, or --api-key).
lightning sandbox create --name demo --instance-type cpu-1 --runtime python313
lightning sandbox run sbx-42 -- python -c "print('hello')"See the full CLI Reference for every command, flag, and
--json scripting pattern.
API reference
Import path: from lightning_sdk.sandbox import ...
FileSystem is not re-exported from the package root; use sandbox.fs or
from lightning_sdk.sandbox.filesystem import FileSystem, FileStat.
Sandbox (client)
| Method | Returns | Notes |
|---|---|---|
Sandbox(config=None) |
Sandbox |
When config omitted, shares the process-wide client (honors Sandbox.configure() + env vars); pass config=SandboxConfig(...) for an isolated client |
Sandbox.configure(*, api_key, base_url) |
None |
Process-wide defaults for Sandbox.create() |
Sandbox.create(...) |
SandboxInstance |
Blocks until running; config= class method only |
client.create(...) |
SandboxInstance |
Same as class method; do not pass config= here |
client.get(sandbox_id) |
SandboxInstance |
|
client.list(*, page_token, limit, teamspace) |
ListSandboxesResult |
|
client.get_snapshot(snapshot_id) |
Snapshot |
|
client.list_snapshots(*, name, page_token, limit, teamspace, sort_order) |
ListSnapshotsResult |
name= is a case-insensitive substring match on source_sandbox_name |
client.delete_snapshot(snapshot_id) |
None |
Sandbox.create / client.create parameters: config (class method only),
name, instance_type (default cpu-1), runtime (base image slug — see
Runtimes), image (custom OCI image; mutually exclusive
with runtime, CPU sandboxes only — see Custom Docker
images), image_secret_ref (project
Docker-registry Secret name for private image), spot, ports,
teamspace (str or Teamspace), snapshot_id, persistent,
storage_gb (writable disk override, CPU sandboxes), timeout (max lifetime in
milliseconds), network_policy.
SandboxConfig
| Member | Description |
|---|---|
api_key |
Optional; falls back to lightning login credentials |
base_url |
Control plane URL (LIGHTNING_CLOUD_URL) |
from_env() |
Load from environment |
merge(other) |
Non-None fields from other override self |
Organization scope is implied by the API key.
SandboxInstance
Properties: sandbox_id, name, organization_id, project_id,
instance_type, spot, persistent, status, runtime, ports,
image, image_secret_ref, snapshot_id, user_id, storage_gb, timeout,
network_policy, created_at, updated_at, fs, process
| Method | Returns | Notes |
|---|---|---|
delete() |
None |
Destroys sandbox and auto-snapshot. Works on a stopped/paused sandbox (no resume() needed); idempotent — a 404 (already gone) returns cleanly |
stop() |
str |
Auto-snapshot id for persistent; empty for non-persistent |
resume() |
SandboxInstance |
Persistent only; teamspace-scoped key |
extend_timeout(timeout) |
None |
Add timeout ms (min 1000) to the auto-stop deadline. Does not refresh sandbox.timeout |
snapshot(*, expiration, excludes, wait=True, wait_timeout=600) |
Snapshot |
Teamspace-scoped key. expiration = TTL in milliseconds (int; 0 = never), excludes = list[str] |
run_command(cmd, args=None) or run_command(RunCommandOpts(...)) |
Command |
|
get_command(cmd_id) |
Command |
Status by id (no timestamps; use list_commands() for those) |
list_commands() |
list[Command] |
This sandbox's command history |
get_command_logs(cmd_id) |
list[CommandLog] |
Low-level; prefer cmd.logs() |
kill_command(cmd_id) |
None |
Low-level; prefer cmd.kill() |
wait_for_command(cmd_id, *, timeout, poll_interval) |
CommandStatus |
Low-level; prefer cmd.wait() |
write_file(path, content) |
None |
|
read_file(path) |
str | None |
None if missing |
write_files(files) |
None |
list[WriteFileParams] |
mkdir(path) / create_directory(path) |
None |
Single path; use fs.mkdir(..., recursive=True) for nested |
Command
Attributes: id (alias cmd_id), command (program name only, not the full
args), output, exit_code (None while running), running, started_at,
updated_at.
Methods: stdout(), stderr(), logs() → list[CommandLog],
get_status() → CommandStatus, wait(*, timeout, poll_interval), kill().
started_at/updated_atare the server-sidecreated_at/updated_attimestamps. They are only populated on handles fromlist_commands(); they areNoneon handles fromrun_command()andget_command(), whose endpoints don't return timestamps (we don't make an extra call to backfill them).output/stdout()/stderr()share a single combined stream — the API does not split stdout from stderr.
RunCommandOpts: cmd, args, cwd, env, detached
CommandStatus: output, exit_code, running — CommandLog: timestamp, message
FileSystem (sandbox.fs)
| Method | Notes |
|---|---|
write_file(path, content) or write_file(WriteFileParams) |
Files API |
exists(path) |
|
stat(path) |
→ FileStat: file_type, size, mtime, mode. mode is a str of octal digits (e.g. "644"), not an int |
readdir(path) |
|
mkdir(path, *, recursive=False) |
|
rm(path, *, recursive=False) |
|
rename(old, new) |
|
copy_file(src, dest) |
|
chmod(path, mode) |
input: int octal (0o644) or str ("644"). Note stat().mode reads back as a str |
symlink(target, path) |
SandboxProcess (sandbox.process)
| Method | Returns | Notes |
|---|---|---|
create_pty(PtyCreateOpts) |
PtyHandle |
Requires websocket-client |
connect_pty(session_name, opts=None) |
PtyHandle |
Reattach; use kill() if wait() hangs |
list_pty_sessions() |
list[PtySessionInfo] |
|
get_pty_session_info(session_id) |
PtySessionInfo |
|
kill_pty_session(session_id) |
None |
|
resize_pty_session(session_id, cols, rows) |
PtySessionInfo |
PtyCreateOpts: session_name, cwd, envs, cols, rows,
on_data (Callable[[bytes], None] — each chunk is bytes)
PtyConnectOpts: on_data (Callable[[bytes], None] — each chunk is bytes)
PtySessionInfo: id (the session name — pass it to get_pty_session_info,
resize_pty_session, kill_pty_session), active, cols, rows, cwd,
created_at, process_id
PtyHandle: discard (static no-op on_data(bytes)), id, exit_code, error, size,
is_connected(), wait_for_connection(timeout), send_input(), resize(),
kill(), disconnect(), wait(timeout) → PtyResult
Network policy, Snapshot, ListSandboxesResult, ListSnapshotsResult
See Network policy above. Snapshot: id, status,
runtime, runtime_image, size_bytes, project_id, organization_id,
rootfs_digest, source_sandbox_id, source_sandbox_name,
source_sandbox_instance_type, auto, excludes, created_at, updated_at,
expires_at (SnapshotInfo remains as a deprecated alias).
ListSandboxesResult / ListSnapshotsResult: sandboxes / snapshots,
next_page_token, previous_page_token, total_size.
Environment variables
| Variable | Maps to | Notes |
|---|---|---|
LIGHTNING_SANDBOX_API_KEY |
SandboxConfig.api_key |
Primary auth |
LIGHTNING_CLOUD_URL |
SandboxConfig.base_url |
Optional |
Public imports
from lightning_sdk.sandbox import (
Command,
CommandLog,
CommandStatus,
ListSandboxesResult,
NetworkPolicy,
PtyConnectOpts,
PtyCreateOpts,
PtyHandle,
PtyResult,
PtySessionInfo,
PtySize,
RunCommandOpts,
Sandbox,
SandboxConfig,
SandboxInstance,
SandboxProcess,
Snapshot,
WriteFileParams,
write_to_stdout,
)Troubleshooting
project_id is required / snapshot creation failures
Use a teamspace-scoped API key to create snapshots (snapshot()) and for
persistent stop() / resume(). An org-scoped key fails snapshot() with a
clear "requires a teamspace-scoped API key" message. Restoring an existing
snapshot (Sandbox.create(snapshot_id=...)) works with an org-scoped key too
(within the same org).
With a teamspace-scoped key you do not need teamspace= on create — the key
selects its project. Pass teamspace="owner/teamspace" only to place an
org-scoped key under a specific project. See
Teamspace.
Snapshot restore returns running but files missing
Sandbox.create() already waits for running — restore is not async beyond that.
If read_file is None, check the path (was it in the snapshot?), confirm the
snapshot finished (status == "ready"), and verify with run_command("cat", [path]). Compare snap.project_id with sandbox.project_id on the restored
instance; a cross-org snapshot id is the main case that won't restore.
Restored sandbox has open egress / ignores the source's network policy
Working as intended: snapshots do not carry network policy, so
Sandbox.create(snapshot_id=...) starts at allow-all unless you pass
network_policy=. Re-specify the policy on every restore to keep a box locked
down. (Only resume() after a persistent stop() re-applies the original
policy, because it reuses the same sandbox id.) See
Network policy.
Persistent resume missing files
Use a teamspace-scoped key — the key supplies its project, so teamspace= on
create is normally unnecessary. An empty stop() snapshot id on non-persistent
sandboxes is expected.
connect_pty + wait() times out
The restored shell may still be running a prior command. Use kill() or
kill_pty_session() rather than only exit\n. See Disconnect vs kill.
ValueError: User org does not exist. when passing teamspace=
This is the real error you get when a teamspace="owner/teamspace" name is
found but the owner/teamspace is wrong or misspelled. The message is
misleading: the resolver tries the owner as both an org and a user, and reports
the user failure even when an org with that slug exists. Double-check the
exact owner/teamspace string (UI teamspace URL), or skip resolution entirely
by using a teamspace-scoped key and omitting teamspace=.
A missing-credentials failure surfaces differently. If the name resolution auth path has no credentials (e.g. you set only
LIGHTNING_SANDBOX_API_KEYand never ranlightning login), you get aConnectionError: Authentication failed. Please run 'lightning login'.— not thisValueError. The sandbox API key is not used for name resolution; see Authentication. The fix is a teamspace-scoped key withteamspace=omitted.
…not your personal login key… even though a key is set
The env var was set after the SDK was imported. Env credentials are read once,
at import — set them in the shell before the process starts, or use config= /
Sandbox.configure() to supply the key in-process. See
Authentication.
Sandbox did not become ready
Creation polls until running. Terminal states (error, stopped, shutdown)
raise RuntimeError.
Transient 500 failed to create sandbox
Sandbox.create() can occasionally fail with an opaque
RuntimeError: Lightning API error 500: failed to create sandbox that is not
a quota issue (it happens even with only a few sandboxes in the org). The SDK does
not auto-retry create, so call it again (ideally with a short backoff) if it
fails:
from lightning_sdk.sandbox import Sandbox
sandbox = Sandbox.create(name="demo", instance_type="cpu-1")PTY connection hangs
Install websocket-client. Set LIGHTNING_SDK_PTY_DEBUG=1 for wire trace.
Call pty.wait_for_connection(timeout=30).
Common mistakes
| Wrong | Correct |
|---|---|
Sandbox(name=..., api_key=...) |
Sandbox.create(name=..., instance_type=...) |
SandboxInstance.create(...) |
Sandbox.create(...) |
SandboxInstance.configure(...) |
Sandbox.configure(...) |
client.create(config=...) |
Sandbox.create(config=...) (class method only) |
sandbox.mkdir("/a/b/c") for nested paths |
sandbox.fs.mkdir("/a/b/c", recursive=True) |
Org-wide key for snapshot() / persistent |
Teamspace-scoped key |
connect_pty then wait() hangs |
kill() or kill_pty_session() |
Was this page helpful?
Thanks for the feedback.