---
title: Python Sandbox SDK
description: >-
  Use the Lightning Sandbox Python SDK to create ephemeral sandboxes, run
  commands, manage files, capture snapshots, and open interactive PTY sessions
  from...
canonical: 'https://lightning.ai/docs/platform/developers/sdk/sandbox/python-sandbox-sdk'
---
# Python Sandbox SDK {#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](/docs/platform/developers/cli/sandbox.md).

## Prerequisites {#prerequisites}

Install the Lightning SDK:

```bash
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](#authentication).
- Import everything from `lightning_sdk.sandbox`.
- PTY sessions additionally require `websocket-client` (installed with
  `lightning-sdk`).

```python
from lightning_sdk.sandbox import Sandbox
```

## Core classes {#core-classes}

| Class | What it does | Example |
| --- | --- | --- |
| [`Sandbox`](#sandbox-client) | Client — creates sandboxes and lists/gets them, plus snapshot management | `sandbox = Sandbox.create(...)` |
| [`SandboxInstance`](#sandboxinstance) | Handle for one sandbox — commands, files, lifecycle, `fs`, `process` | `sandbox.run_command(...)` |
| [`Command`](#command) | A running or finished command inside a sandbox | `cmd = sandbox.run_command(...)` |
| [`Snapshot`](#snapshots) | A saved sandbox filesystem you can restore later | `snap = sandbox.snapshot()` |
| [`FileSystem`](#filesystem-helpers-sandbox-fs) | Shell-based file operations, via `sandbox.fs` | `sandbox.fs.readdir("/tmp")` |
| [`SandboxProcess`](#pty-sessions) | Interactive PTY sessions, via `sandbox.process` | `sandbox.process.create_pty(...)` |
| [`SandboxConfig`](#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 {#quickstart}

```python
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:

```python
client = Sandbox()
sandbox = client.create(name="quickstart", instance_type="cpu-1")
```

> Credentials are read from `LIGHTNING_SANDBOX_API_KEY` (or `lightning login`).
> See [Authentication](#authentication) for the recommended teamspace-scoped key
> and in-process configuration.

## Choose the right API {#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](#common-tasks) |
| Pick language/OS image | `runtime=` on `Sandbox.create()` (see [Runtimes](#runtimes)) |
| Bring your own Docker image | `image=` (+ `image_secret_ref=` for private registries) on `Sandbox.create()` (see [Custom Docker images](#custom-docker-images)) |
| Long-running / background command | `run_command(RunCommandOpts(cmd=..., detached=True))` then `cmd.wait()` or poll — see [Long-running scripts](#long-running-and-forever-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 {#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](#runtimes)), or split the work across two sandboxes.

### Run a simple Python script {#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](#structured-options)).

**Inline one-liner:**

```python
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:**

```python
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 {#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.

```python
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:

```python
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 {#run-multiple-commands}

**Sequential (each call waits for the previous to finish):**

```python
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:

```python
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 {#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()`:
>
> ```python
> 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.

```python
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](#detached-commands) and
`python/examples/detached_command.py`.

## Authentication {#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}

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:

```bash
export LIGHTNING_SANDBOX_API_KEY="sk-lit-..."
export LIGHTNING_CLOUD_URL="https://lightning.ai"   # optional
```

```python
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:
>
> ```python
> 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=`:

```python
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= {#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](#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 {#creating-sandboxes}

### Common options {#common-options}

```python
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 {#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](#custom-docker-images).

| Parameter | What it controls |
|-----------|------------------|
| `runtime="python313"` | Language stack / base image |
| `instance_type="cpu-1"` | vCPU, memory, accelerator |

```python
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](#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](#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:

```python
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-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 |

```python
sandbox = Sandbox.create(runtime="python313", instance_type="cpu-4")  # 4 vCPU / 16 GB
```

### Custom Docker images {#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:

```python
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:

```python
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](/docs/platform/developers/cli/sandbox.md#create).

### Exposed ports {#exposed-ports}

Pass `ports=[8080]` at create time to expose ports; `sandbox.ports` lists the
exposed port numbers back (as strings).

```python
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 {#lookup-and-listing}

```python
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](#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](#authentication)), or use a per-project key for each.

### Teamspace {#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):

```python
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](#authentication).

### Instance properties {#instance-properties}

```python
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 {#lifecycle}

### Delete {#delete}

```python
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 {#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:

```python
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:

```python
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 {#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()`.

```python
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 {#snapshots}

Capture the filesystem (not running processes):

```python
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:**

```python
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:

```python
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](#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.

```python
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`:
>
> ```python
> 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 {#commands}

### Synchronous (default) {#synchronous-default}

```python
result = sandbox.run_command("uname", ["-a"])
print(result.exit_code, result.output)

sandbox.run_command("echo hello world")  # shell-split shorthand
```

### Structured options {#structured-options}

```python
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 {#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](#long-running-and-forever-running-scripts).

```python
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 {#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.

```python
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 {#lower-level-command-apis}

These take a `cmd_id` directly; prefer the `Command` methods above when you have a
handle.

```python
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 {#files}

### Direct API (files endpoint) {#direct-api-files-endpoint}

```python
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:

```python
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) {#filesystem-helpers-sandbox-fs}

Shell-based helpers (except `write_file`, which uses the files API).

```python
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 {#pty-sessions}

For interactive shells, use `sandbox.process` instead of `run_command`.
Requires `websocket-client` (installed with `lightning-sdk`).

```python
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.:

```python
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`:

```python
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 {#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:

```python
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:

```python
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 {#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.

```python
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.

```python
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:
>
> ```python
> # 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 {#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`).

```bash
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](/docs/platform/developers/cli/sandbox.md) for every command, flag, and
`--json` scripting pattern.

## API reference {#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) {#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](#runtimes)), `image` (custom OCI image; mutually exclusive
with `runtime`, CPU sandboxes only — see [Custom Docker
images](#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 {#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 {#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 {#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) {#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) {#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 {#network-policy-snapshot-listsandboxesresult-listsnapshotsresult}

See [Network policy](#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 {#environment-variables}

| Variable | Maps to | Notes |
|----------|---------|-------|
| `LIGHTNING_SANDBOX_API_KEY` | `SandboxConfig.api_key` | Primary auth |
| `LIGHTNING_CLOUD_URL` | `SandboxConfig.base_url` | Optional |

### Public imports {#public-imports}

```python
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 {#troubleshooting}

### project_id is required / snapshot creation failures {#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](#teamspace).

### Snapshot restore returns running but files missing {#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 {#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](#network-policy).

### Persistent resume missing files {#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 {#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](#disconnect-vs-kill-vs-connect-pty).

### ValueError: User org does not exist. when passing teamspace= {#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](#authentication). The fix is a teamspace-scoped key with
> `teamspace=` omitted.

### …not your personal login key… even though a key is set {#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](#authentication).

### Sandbox did not become ready {#sandbox-did-not-become-ready}

Creation polls until `running`. Terminal states (`error`, `stopped`, `shutdown`)
raise `RuntimeError`.

### Transient 500 failed to create sandbox {#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:

```python
from lightning_sdk.sandbox import Sandbox

sandbox = Sandbox.create(name="demo", instance_type="cpu-1")
```

### PTY connection hangs {#pty-connection-hangs}

Install `websocket-client`. Set `LIGHTNING_SDK_PTY_DEBUG=1` for wire trace.
Call `pty.wait_for_connection(timeout=30)`.

### Common mistakes {#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()` |

---

**Navigation**

- Parent: [Sandbox](/docs/platform/developers/sdk/sandbox.md)
