About lightning-sandboxes
lightning-sandboxes is a skill for creating isolated virtual machines on Lightning AI and running code in them, for developers and agents that need temporary execution environments. It documents CLI and Python SDK workflows, persistent snapshots, network policies, and Docker-enabled runtimes.
Key features
- 01Create isolated, fast-starting virtual machines
- 02Run commands and read or write files
- 03Configure network policies and ports
- 04Use persistent snapshots to stop and resume
- 05Build and run containers in Docker runtimes
Use cases
- 01Run untrusted code in an isolated environment
- 02Test code in a clean virtual machine
- 03Create a development environment that can resume after stopping
- 04Build and inspect containers in a sandbox
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ lightning-ai/ skills --skill lightning-sandboxesAfter installing, type/lightning-sandboxesin Claude Code to confirm it loaded.
Details
From the project READMELightning AI Sandboxes
A Sandbox is a fast-booting isolated VM for code execution. It is ephemeral by default: stop deletes it, and only persistent=True enables stop/resume via auto-snapshots. Import from the subpackage with from lightning_sdk.sandbox import Sandbox. The top-level lightning_sdk.sandbox.py _Sandbox class is legacy; ignore it.
Setup & auth (sandbox-specific)
export DEBUG=0 LIGHTNING_DEBUG=0 # an inherited DEBUG=1 logs the Authorization header; set this in every new shell
# Use the Lightning AI CLI from the current env; install or upgrade it there if it's missing or older than 2026.9.18
v=$(lightning --version 2>/dev/null | sed -n 's/^Lightning CLI version //p')
[ -n "$v" ] && [ "$(printf '%s\n' 2026.9.18 "$v" | sort -V | head -1)" = 2026.9.18 ] \
|| uv pip install -U lightning-sdk || python3 -m pip install -U lightning-sdk
lightning --version # must print "Lightning CLI version …"; if not, see the table below
# `sandbox <cmd>` is also installed standalone == `lightning sandbox <cmd>`
This uses, and if needed installs into, the agent's current environment (project venv, conda env,
…); then call plain lightning … everywhere. If setup doesn't go cleanly:
| Symptom | Fix |
|---|---|
No active env, externally-managed-environment, or a conflict with the project's own pins | Install it on its own: uv tool install lightning-sdk (or pipx install lightning-sdk), and call "$(uv tool dir --bin)/lightning" if that isn't on PATH |
Still no Lightning CLI version line after installing | Another lightning (PyTorch Lightning's) or an inactive env comes first: check command -v lightning, then activate the env or call its bin/lightning |
No such command, No such option or unexpected extra argument | The CLI is too old: upgrade it where command -v lightning points (uv pip install -U lightning-sdk, or uv tool upgrade lightning-sdk) |
| Installing is blocked (read-only env or home, agent sandbox) | Run each command as UV_CACHE_DIR="${TMPDIR:-/tmp}/uv" UV_TOOL_DIR="${TMPDIR:-/tmp}/uv-tools" uvx lightning-sdk … |
SSL/CA errors on every call, even lightning --version, only inside an agent sandbox | A **/*.pem read-deny rule hides certifi's cacert.pem: allow that one path; don't disable the sandbox |
NameResolutionError, only inside an agent sandbox | If lightning api fails too, ask the user to allow lightning.ai and *.lightning.ai (/sandbox). If only studio/job commands or the SDK fail, they bypass the sandbox proxy: run just those outside it. Background pollers fail the same way, silently |
The install above also makes lightning_sdk importable in that env, so Python snippets run with
its python. If the CLI came from a uv tool or uvx fallback instead, run one-off scripts with
uv run --with lightning-sdk python script.py. For code that stays in the user's project, ask,
then declare lightning-sdk as a dependency with the project's own tool (uv add, poetry add).
Org scope comes from the API key — there is no org flag or LIGHTNING_ORG_ID env var (it's rejected). Sandboxes need an org- or teamspace-scoped API key in LIGHTNING_SANDBOX_API_KEY; a personal lightning login credential fails with "Use a teamspace- or org-scoped API key (Members → API keys), not your personal login key."
You can mint an org-scoped key from the CLI (requires working personal auth):
export LIGHTNING_SANDBOX_API_KEY=$(lightning api-key create --org my-org --name sandbox-agent)
# or reuse the org default key: lightning api-key get --org my-org
If the user belongs to multiple orgs and none is configured, ask which org to use:
lightning api /v1/memberships | jq -r '.memberships[] | select(.ownerType=="organization") | [.ownerId, .name] | @tsv'
Snapshot/stop of persistent sandboxes needs a teamspace-scoped key (org-scoped is not enough).
CLI reference
# create — blocks until running (usually seconds)
lightning sandbox create --name devbox \
[--instance-type cpu-1] # default cpu-1 \
[--runtime python313] # DEFAULT IS node24 (Node.js, NO python!) — use python313 for Python \
# node22 | node24 | python313, each with a -docker variant \
[--image ghcr.io/org/img:latest] # custom rootfs, CPU-only; mutually exclusive with --runtime \
[--image-secret-ref <docker-registry-secret>] \
[--docker] # dockerd at boot; == appending -docker to --runtime \
[--teamspace owner/teamspace] [--persistent] [--spot] [--port 8000] \
[--snapshot-id snap-...] # warm start from a snapshot \
[--timeout 3600000] # auto-stop lifetime in MILLISECONDS \
[--json] # prints the sandbox id — capture it for later commands
lightning sandbox list [--teamspace owner/teamspace] [--limit N] [--json]
# run a command (use -- before the command); CLI exits with the command's exit code
lightning sandbox run <SANDBOX_ID> [--cwd /workspace] [--env KEY=VALUE] -- python -c "print('hi')"
lightning sandbox run <SANDBOX_ID> --detached -- bash -lc "long-task" # prints cmd_id
lightning sandbox command <SANDBOX_ID> <COMMAND_ID> # status + output
lightning sandbox logs <SANDBOX_ID> <COMMAND_ID>
lightning sandbox commands <SANDBOX_ID> # history
# lifecycle
lightning sandbox stop <SANDBOX_ID> # persistent: auto-snapshot + pause; ephemeral: == delete
lightning sandbox start <SANDBOX_ID> # resume a stopped persistent sandbox
lightning sandbox delete <SANDBOX_ID> -y # destroys sandbox AND its auto-snapshot; -y required non-interactively
# snapshots (filesystem only — running processes are not preserved)
lightning sandbox snapshot create <SANDBOX_ID> [--expiration <MS>] [--exclude PATH]
lightning sandbox snapshot list [--name N] [--teamspace owner/teamspace]
lightning sandbox snapshot get <SNAPSHOT_ID>
lightning sandbox snapshot delete <SNAPSHOT_ID> -y
Runtime ids are node22, node24 and python313, each with a -docker variant. Any other id fails with invalid runtime: <id>. A custom --image runs on CPU under gVisor. In the SDK the same options are image= and, for a private registry, image_secret_ref= naming a Docker-registry secret.
There is no cp/upload CLI. Move files with run and shell commands, or with the SDK file API below.
Python SDK
from lightning_sdk.sandbox import Sandbox, SandboxConfig, RunCommandOpts, NetworkPolicy, PtyCreateOpts
# optional explicit config; otherwise env (LIGHTNING_SANDBOX_API_KEY) / lightning login creds are used
Sandbox.configure(api_key="...")
sb = Sandbox.create(
name="devbox",
instance_type="cpu-1",
runtime="python313", # default is node24 (Node.js only — no python!)
teamspace="owner/teamspace",
persistent=True, # enables stop()/resume()
docker=True, # dockerd at boot; see "Docker inside a sandbox"
ports=[8080], # exposes public HTTPS URLs for these ports
network_policy=NetworkPolicy(allow_cidrs=["10.0.0.0/8"]), # or "deny-all" / default open egress
timeout=30 * 60 * 1000, # auto-stop, MILLISECONDS
) # blocks until running
# commands — non-detached blocks until exit
cmd = sb.run_command("python -c 'print(42)'")
print(cmd.exit_code, cmd.output) # output = combined stdout+stderr
bg = sb.run_command(RunCommandOpts(cmd="python", args=["train.py"], cwd="/workspace",
env={"MODE": "test"}, detached=True))
bg.wait(timeout=600); print(bg.output) # or sb.kill_command(bg.cmd_id)
# files — content strings via REST; no host<->sandbox copy helper
sb.write_file("/workspace/app.py", "print('hi')")
text = sb.read_file("/workspace/out.txt") # None if missing
sb.fs.mkdir("/workspace/data", recursive=True)
sb.fs.exists("/workspace/app.py"); sb.fs.readdir("/workspace"); sb.fs.rm("/tmp/x", recursive=True)
# exposed ports (only those passed as ports=[...] at create) — proxied public HTTPS
sb.port_urls # {"8080": "https://8080-<sandbox-id>-s.cloudspaces.litng.ai"}
sb.get_port_url(8080) # ValueError if 8080 wasn't exposed at create
# normally populated by create; if empty, re-get: Sandbox().get(sb.sandbox_id).port_urls
# lifecycle
sb.extend_timeout(10 * 60 * 1000) # heartbeat; milliseconds, min 1000
snap = sb.snapshot() # filesystem snapshot; later: Sandbox.create(snapshot_id=snap.id)
auto_snap_id = sb.stop() # persistent only; sb.resume() brings it back with same id
sb.delete() # ALWAYS clean up — GC does not delete remote sandboxes
# find existing
client = Sandbox()
for s in client.list(teamspace="owner/teamspace").sandboxes: print(s.sandbox_id, s.status)
sb = client.get("sbx-...")
Interactive PTY (websocket-client ships with the SDK): sb.process.create_pty(PtyCreateOpts(session_name="main")) → pty.send_input("ls\n"), pty.wait(). The result's exit_code is the shell's real status on a clean close, 0 when the backend reports none, -1 if the connection broke (error says why) and None while running. Prefer run_command when you need a guaranteed exit code.
Docker inside a sandbox
The -docker runtimes ship Docker (engine, CLI, buildx, compose), and dockerd is already running when create returns, so docker version works at once. Pick the variant explicitly, or pass --docker / docker=True to append the suffix to the base runtime:
lightning sandbox create --runtime python313-docker --port 8080 --json # cpu-1 default is fine for Docker
lightning sandbox create --runtime python313 --docker # identical — resolves to python313-docker
lightning sandbox create --docker # no runtime -> node24-docker (Node, NOT Python)
docker=True is rejected together with image (a custom image opts in by carrying the OCI label ai.lightning.sandbox.docker=true) or snapshot_id (the runtime comes from the snapshot).
Where images are stored, and how much room you get
cpu-1 and cpu-4 keep images on disk; every other shape keeps them in RAM. Pick the instance type for where /var/lib/docker lands, not for its RAM. On the disk shapes an image never touches memory, so the default cpu-1 is a usable Docker box despite its small RAM.
--instance-type | RAM | /var/lib/docker | image room | storage_gb raises it? |
|---|---|---|---|---|
cpu-1 (default) | 1.25 GiB | disk | 5 GB | yes |
cpu-2 | 8.75 GiB | RAM (tmpfs) | 4.4 GB | no |
cpu-4 | 18.75 GiB | disk | 40 GB | yes |
cpu-8 | 38.75 GiB | RAM (tmpfs) | 20 GB | no |
cpu-16 | 77.5 GiB | RAM (tmpfs) | 39 GB | no |
df -h /var/lib/dockerreports the real ceiling; trust it over this table. The table holds measured current defaults, so use it to plan and checkdfbefore sizing a pull.- On the disk shapes there is one quota for everything.
/,/tmpand/var/lib/dockerdraw on the same pool, so a 3 GB file in/rootleaves 3 GB less for images. Raise it with the SDK-onlystorage_gb(Sandbox.create(..., instance_type="cpu-1", storage_gb=20)gives docker 20 GB). There is no CLI flag for it. - On the RAM shapes the cap is half the sandbox's memory, and it counts against your memory budget. An image plus a hungry process can still cause an out-of-memory kill.
storage_gbbuys disk for/, never image room. - Choose by workload:
cpu-1for image-heavy work on a budget,cpu-4for both room and RAM for builds,storage_gbwhen 5 GB is tight. Usecpu-2/cpu-8only when the workload needs the memory, and keep images small there.
A pull into a full data root fails with no space left on device, and the sandbox keeps running. Delete images and retry:
lightning sandbox run $SBX -- bash -lc 'docker system df; docker image prune -af; df -h /var/lib/docker'
Older clusters kill the sandbox instead. There df reports a meaningless size of hundreds of GB, nothing bounds docker but the sandbox's memory, and overrunning it terminates the sandbox: every later call returns 429 sandbox terminated: out of memory. Keep images well under the RAM column, or move to cpu-1/cpu-4.
Networking: the part you must adapt
dockerd runs --bridge=none --iptables=false --ip6tables=false, so only the host and none docker networks exist (docker network ls confirms it):
- Run containers with
--network=host, or they have no network at all. DNS fails withwget: bad address. Image pulls still work either way, since dockerd itself sits on the sandbox's netstack. - Build with
--network=host, or everyRUNstep dies withfailed to solve: ... network bridge not found. In compose:build: {context: ., network: host}. - Reach other containers at
127.0.0.1:<port>, never by compose service name. Host networking has no bridge DNS, sodborapidies withgaierror: [Errno -2] Name or service not known. Plainlocalhostresolves fine. -p 8080:80is silently ignored:docker run -pexits 0 and serves nothing. Port publishing belongs to the bridge. The process inside must listen on the port you want (nginxconfigured for 8080,uvicorn --port 8080). That port is what the sandbox's public URL serves, if you declared it at create (--port 8080/ports=[8080], thensb.get_port_url(8080)).
Compose template that works as-is (FastAPI + Redis). Run docker compose up -d --build detached with a generous timeout, since builds pull layers:
services:
web:
build: {context: ., network: host} # host net so pip install reaches PyPI
network_mode: host
environment: [REDIS_HOST=127.0.0.1] # NOT "redis" — no bridge DNS
depends_on: [redis]
redis:
image: redis:7-alpine
network_mode: host
Docker gotchas
- Image pulls fail under restricted egress; pull under the default open egress or bake images into a custom image. Under
network_policy="deny-all"dockerd still starts, but every pull stalls, then fails withGet "https://registry-1.docker.io/v2/": context deadline exceeded. A CIDR allowlist that includes the resolvers (see the DNS gotcha below) fixes name resolution, but the pull then waits on registry IPs, which Docker Hub serves from a shifting CDN. - Docker Engine is pinned to v27; don't upgrade it in-sandbox. v28+ is known-broken here and unsupported.
docker infoshows the exact build. Docker is CPU-sandbox only, never GPU. - You cannot add Docker to a plain runtime after create; pick a
-dockerruntime up front. On a plain runtimedockeranddockerdare absent andmountreturnspermission denied. - The REST API has no
dockerfield. If you drive/v1/core/sandboxesdirectly, send"runtime": "node24-docker".
Example workflows
Prompts this skill handles: "run this untrusted script somewhere safe", "test my code in a clean VM", "spin up a container from ghcr.io/... and poke around", "give me a devbox that survives restarts".
Safely execute untrusted/generated code (no network egress, auto-cleanup):
from lightning_sdk.sandbox import Sandbox
sb = Sandbox.create(name="quarantine", teamspace="my-org/my-teamspace", runtime="python313",
network_policy="deny-all", timeout=15 * 60 * 1000) # hard kill after 15 min
try:
sb.write_file("/workspace/suspect.py", open("suspect.py").read())
cmd = sb.run_command("python /workspace/suspect.py")
print(cmd.exit_code, cmd.output)
finally:
sb.delete()
Test code in a clean environment from the CLI:
SBX=$(lightning sandbox create --name test-run --teamspace my-org/my-teamspace --runtime python313 --timeout 1800000 --json | jq -r .id)
lightning sandbox run $SBX -- python --version
lightning sandbox run $SBX --cwd /workspace -- bash -lc "pip install requests && python -c 'import requests; print(requests.__version__)'"
lightning sandbox run $SBX --detached -- bash -lc "pytest -q > /workspace/test.log 2>&1" # prints cmd_id
lightning sandbox command $SBX <cmd_id> # poll status + output
lightning sandbox delete $SBX -y
Run a containerized app and preview it on a public URL:
SBX=$(lightning sandbox create --name preview --runtime python313-docker \
--port 8080 --timeout 1800000 --json | jq -r .id) # cpu-1 default: images go to disk
# --network=host is mandatory, and the container must LISTEN on 8080 (-p is ignored)
lightning sandbox run $SBX -- docker run -d --network=host --name web \
-v /workspace:/srv -w /srv python:3.13-alpine python -m http.server 8080
lightning sandbox run $SBX -- curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080
lightning sandbox list --json | jq -r ".sandboxes[] | select(.id==\"$SBX\") | .port_urls[\"8080\"]"
lightning sandbox delete $SBX -y
Try out a custom image:
lightning sandbox create --name img-test --image ghcr.io/myorg/myimage:latest \
--teamspace my-org/my-teamspace --timeout 1800000
# private registry: --image-secret-ref <docker-registry-secret-name>
Persistent devbox with snapshot-based branching:
sb = Sandbox.create(name="devbox", teamspace="my-org/my-teamspace", runtime="python313", persistent=True)
sb.run_command("pip install torch numpy pandas") # slow setup, do once
snap = sb.snapshot() # golden image
sb.stop() # pause billing; sb.resume() later, same id
fresh = Sandbox.create(name="experiment-1", snapshot_id=snap.id,
teamspace="my-org/my-teamspace") # warm clone with deps preinstalled
Raw API fallback
lightning api /v1/core/sandboxes -X GET -f "organizationId=${ORG_ID}" -f "projectId=${PROJECT_ID}" -f limit=20
lightning api /v1/core/sandboxes -X GET ... | jq -r '.sandboxes[] | .name // .id'
lightning api "/v1/core/sandboxes/${SANDBOX_ID}" -X GET -f "organizationId=${ORG_ID}" # -X GET: fields without it send a POST
lightning api "/v1/core/sandboxes/${SANDBOX_ID}/commands" -X POST -f command=ls -F detached=false
ORG_ID from memberships: lightning api /v1/memberships | jq -r '[.memberships[] | select(.ownerType == "organization") | .ownerId][0]'.
Gotchas
- Timeout units differ.
create --timeout,extend_timeout()and snapshot--expirationtake milliseconds.sandbox run --timeout(detached wait) takes seconds. - Sandboxes bill until stopped or deleted, and garbage collection never removes them. Always
stop()/delete(), and set a create-timetimeoutas a safety net. - Without
-y,sandbox deleteandsandbox snapshot deleteexit without deleting. Non-interactively they read the prompt from a closed stdin, printAre you sure you want to delete? [y/N]: Aborted.and leave the sandbox billing. This is the easiest way to leak money here. - There is no cost surface for sandboxes, so budget with a create-time
timeout./v1/billing/usage,/v1/projects/<pid>/usageand/v1/core/sandboxes/<id>/usageall 404. No CLI reports spend, and sandbox instance types (cpu-1,cpu-2) are missing from the priced accelerator catalog and fromlightning machine list, so a run cannot be priced even by hand. - Egress policy is SDK-only and fixed at create.
sandbox createhas no network-policy flag, sodeny-alland CIDR allowlists needlightning_sdk. Everything else here is CLI-doable. You cannot change egress rules on a running sandbox. The default is open egress (allow-all); use"deny-all"or a CIDR allowlist for untrusted code. - A CIDR allowlist does not permit DNS by itself.
allow_cidrsis enforced at the IP layer, and/etc/resolv.confpoints at1.1.1.1and8.8.8.8. So every hostname lookup fails withTemporary failure in name resolutionwhile raw-IP connections work. Add1.1.1.1/32and8.8.8.8/32to the allowlist, or point the sandbox at a resolver inside it. - Files restored from a snapshot come back with mtime
1970-01-01T00:00:00Z. Epoch-zero timestamps breakmake,ccache, and pip/setuptools staleness checks. Touch files you depend on, or avoid mtime-based staleness logic in a restored sandbox. - Commands run as root inside the sandbox.
- Scoped-key errors come back as hints from the CLI and SDK.
Use a teamspace- or org-scoped API key ...(raw: "organization_id is required") → you're on a personal login key.Your teamspace-scoped API key is not authorized for the project requested via teamspace= ...(raw: "API key is not authorized for this project") → the key is bound to a different teamspace.This operation requires a teamspace-scoped API key ...→ snapshot/stop with an org-scoped key. Only rawlightning apicalls show the raw wording.