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) or lightning login. See Authentication.
  • Import everything from lightning_sdk.sandbox.
  • PTY sessions additionally require websocket-client (installed with lightning-sdk).
from lightning_sdk.sandbox import Sandbox

Core 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 (or lightning 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 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 blocks until the script exits).

The run_command("cmd", ["args"]) shorthand takes the command and args only. To set cwd, env, or detached, use run_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 / list call is an independent stateless request over the shared process-wide client, so driving many sandboxes from a ThreadPoolExecutor (matrix runs, sweeps, fleet operations) works without extra locking. Use a distinct Sandbox(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 synchronous run_command blocks your client 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 — cmd.wait(timeout=..., poll_interval=...), or
  2. Poll continuously — loop on cmd.get_status() / sandbox.get_command(cmd_id) until running is false, printing logs between polls if you want live output.

wait() on timeout raises TimeoutError — 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():

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 persistent stop()/resume() — and you do not pass teamspace=.

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"   # optional
from 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 requires lightning login credentials — LIGHTNING_SANDBOX_API_KEY alone is NOT used for name resolution. Any call that must resolve a teamspace name (create, list, list_snapshots with teamspace=) goes through the user-level (lightning login) auth path, not the sandbox API key. With only an API key set (env, config=, or Sandbox.configure()), passing teamspace="org/general" fails with ConnectionError: 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 no teamspace= then returns just that project's sandboxes (verified).
  • The org-scoped-key story is only achievable if you also have lightning login credentials available (for the name lookup), or you pass a pre-resolved project id.
  • The client.list(..., teamspace=...) / list_snapshots(..., teamspace=...) examples below assume lightning login creds are present; with an API key alone, omit teamspace=.

Org-scoped key + omitted teamspace= fails late, not loudly. With an org-scoped key, create() succeeds even without teamspace= — but it leaves the sandbox without a project, so sandbox.project_id is empty (""). No error is raised at create time; instead the missing project surfaces later as a confusing failure on snapshot() / persistent stop()/resume(). Always pass teamspace= with an org-scoped key and check sandbox.project_id is 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 GB

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

The 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= and runtime= are mutually exclusive — passing both raises ValueError. Pick a curated runtime or bring your own image.
  • image_secret_ref= is only valid together with image= — passing it alone raises ValueError.
  • 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/npx in node24) 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/endpoint property on SandboxInstance (ports only 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, not localhost. The sandbox may not resolve localhost — http://localhost:8080 can fail with socket.gaierror: [Errno -2] Name or service not known, while http://127.0.0.1:8080 works.

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 needs lightning login credentials — an API key alone is not enough (see Authentication). With a teamspace-scoped key, call client.list(limit=10) without teamspace=; 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 --help examples)
  • From a sandbox you already created — note the teamspace you picked at create time; project_id alone 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_at

Lifecycle

Delete

sandbox.delete()  # destroys sandbox and any auto-snapshot

delete() 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 hour

extend_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 iteration

The deadline moves, but sandbox.timeout does not. The control plane returns an empty response, so extend_timeout() returns None and the cached sandbox.timeout property 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-snapshots

Options:

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,
)
  • expiration is 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"). Pass 0 for 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.
  • excludes is 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-all unless you pass network_policy= on create(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 whose source_sandbox_name contains box (any case), e.g. My-Box and sandbox-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 shorthand

Structured 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 no sudo option (and no --sudo CLI flag) — it would be redundant, and the sudo binary 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(...).running can still be True immediately afterward. To confirm the process actually stopped, poll get_status() / get_command() or call cmd.wait() after kill().

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)  # → CommandStatus

Files

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_directory

mkdir / 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 found

Sending 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 shell

send_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 as network 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 then Sandbox.create(snapshot_id=...) without re-passing network_policy=, the new sandbox comes back allow-all and can reach the public internet. This is by design (the policy lives on the sandbox record, not the snapshot). You MUST re-specify network_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_at are the server-side created_at / updated_at timestamps. They are only populated on handles from list_commands(); they are None on handles from run_command() and get_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_KEY and never ran lightning login), you get a ConnectionError: Authentication failed. Please run 'lightning login'. — not this ValueError. The sandbox API key is not used for name resolution; see Authentication. The fix is a teamspace-scoped key with teamspace= 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?

What went wrong?

↑↓ Navigate↵ Open⌘↵ New tabesc Close