# LangSmith Sandbox

Sandboxed code execution for LangSmith. Run untrusted code safely in isolated containers.

## Quick Start

```python
from langsmith.sandbox import SandboxClient

# Client uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
client = SandboxClient()

# Create a sandbox with the default runtime and run code
with client.sandbox() as sb:
    result = sb.run("python -c 'print(2 + 2)'")
    print(result.stdout)  # "4\n"
    print(result.success)  # True

# Or create a sandbox to keep
sb = client.create_sandbox()
result = sb.run("python -c 'print(2 + 2)'")
client.delete_sandbox(sb.name)  # Don't forget to clean up when done

# Or use an existing sandbox by name
sb = client.get_sandbox(name="your-sandbox")
result = sb.run("python -c 'print(2 + 2)'")
```

Use a snapshot when you want to boot from a reusable custom filesystem image.

## Installation

The sandbox module is included with `langsmith` by default — no extra
install step is required. The `websockets` package is a core dependency,
so streaming output, `timeout=0`, and TCP tunnels work out of the box.

```bash
pip install langsmith
```

> The `langsmith[sandbox]` extra is still accepted for backward
> compatibility, but it no longer installs anything extra.

## Configuration

The client automatically uses LangSmith environment variables:

```python
from langsmith.sandbox import SandboxClient

# Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY
client = SandboxClient()

# Or configure explicitly
client = SandboxClient(
    api_endpoint="https://api.smith.langchain.com/v2/sandboxes",
    api_key="your-api-key",
    timeout=30.0,
)
```

## AWS Auth Proxy

Use the AWS auth proxy when sandbox code needs to call AWS services such as S3,
Bedrock, or another supported AWS HTTPS endpoint. You configure the AWS
credentials on the sandbox proxy, and the proxy signs outbound AWS requests with
SigV4. The real credentials stay outside the sandbox.

Store AWS credentials as LangSmith workspace secrets using names that make sense
for your workspace. Then reference those secret names in the proxy config:

```python
from langsmith.sandbox import (
    SandboxClient,
    aws_auth,
    proxy_config,
    workspace_secret,
)

client = SandboxClient()
auth_config = proxy_config(
    rules=[
        aws_auth(
            access_key_id=workspace_secret("SANDBOX_AWS_ACCESS_KEY_ID"),
            secret_access_key=workspace_secret("SANDBOX_AWS_SECRET_ACCESS_KEY"),
        )
    ],
)

with client.sandbox(
    name="aws-sandbox",
    proxy_config=auth_config,
) as sb:
    result = sb.run("python your_aws_script.py")
    print(result.stdout)
```

Inside `your_aws_script.py`, use AWS SDKs normally. For example, if `boto3` is
installed in the sandbox snapshot:

```python
import boto3

s3 = boto3.client("s3")
print([bucket["Name"] for bucket in s3.list_buckets()["Buckets"]])
```

If your application mints short-lived AWS credentials, pass them as write-only
opaque values instead:

```python
from langsmith.sandbox import aws_auth, opaque_secret, proxy_config

auth_config = proxy_config(
    rules=[
        aws_auth(
            access_key_id=opaque_secret(access_key_id),
            secret_access_key=opaque_secret(secret_access_key),
        )
    ],
)
```

Do not put real AWS credentials in sandbox environment variables. Plaintext AWS
credential values are not accepted directly by the AWS auth proxy; wrap
short-lived write-only values with `opaque_secret(...)`.

## GCP Auth Proxy

Use the GCP auth proxy when sandbox code needs to call Google APIs. You
configure service account JSON on the sandbox proxy, and the proxy injects OAuth
bearer tokens for Google API hosts matched automatically by the sandbox proxy.
The real service account JSON stays outside the sandbox.

Store the service account JSON as a LangSmith workspace secret, then reference
that secret name in the proxy config:

```python
from langsmith.sandbox import (
    SandboxClient,
    gcp_auth,
    proxy_config,
    workspace_secret,
)

client = SandboxClient()
auth_config = proxy_config(
    rules=[
        gcp_auth(
            service_account_json=workspace_secret(
                "SANDBOX_GCP_SERVICE_ACCOUNT_JSON"
            ),
            scopes=["https://www.googleapis.com/auth/devstorage.read_write"],
        )
    ],
)

with client.sandbox(
    name="gcp-sandbox",
    proxy_config=auth_config,
) as sb:
    result = sb.run("python your_gcp_script.py")
    print(result.stdout)
```

Use `opaque_secret("...")` for short-lived write-only service account JSON.
Plaintext service account JSON is not accepted directly.

## Sandbox Mounts

Use mounts when sandbox code needs filesystem access to external data such as
object storage buckets or public Git repositories. Mount specs contain only the
mount target. Provider credentials stay in `mount_config.auth`; the backend
expands them into runtime proxy auth rules. You can also pass `proxy_config` for
non-mount proxy behavior such as custom headers, callbacks, access control, and
generic egress rules. Explicit AWS/GCP proxy auth rules conflict with
`mount_config` auth for the same provider.

S3 mounts require AWS auth:

```python
from langsmith.sandbox import (
    aws_auth,
    mount_config,
    s3_mount,
    workspace_secret,
)

mount_cfg = mount_config(
    auth=[
        aws_auth(
            access_key_id=workspace_secret("SANDBOX_AWS_ACCESS_KEY_ID"),
            secret_access_key=workspace_secret("SANDBOX_AWS_SECRET_ACCESS_KEY"),
        )
    ],
    mounts=[
        s3_mount(
            id="customer_data",
            mount_path="/mnt/mounts/customer-data",
            bucket="example-bucket",
            prefix="datasets/customer-data",
            region="us-east-1",
            endpoint_url="https://s3.amazonaws.com",
            path_style=False,
            read_only=False,
        )
    ],
)

with client.sandbox(
    name="s3-mount-sandbox",
    mount_config=mount_cfg,
) as sb:
    result = sb.run("ls /mnt/mounts/customer-data")
    print(result.stdout)
```

GCS mounts require GCP auth:

```python
from langsmith.sandbox import (
    gcp_auth,
    gcs_mount,
    mount_config,
    workspace_secret,
)

mount_cfg = mount_config(
    auth=[
        gcp_auth(
            service_account_json=workspace_secret(
                "SANDBOX_GCP_SERVICE_ACCOUNT_JSON"
            )
        )
    ],
    mounts=[
        gcs_mount(
            id="customer_data",
            mount_path="/mnt/mounts/customer-data",
            bucket="example-bucket",
            prefix="datasets/customer-data",
        )
    ],
)

with client.sandbox(
    name="gcs-mount-sandbox",
    mount_config=mount_cfg,
) as sb:
    result = sb.run("ls /mnt/mounts/customer-data")
    print(result.stdout)
```

Public Git mounts do not require AWS or GCP auth:

```python
from langsmith.sandbox import git_mount, mount_config

mount_cfg = mount_config(
    mounts=[
        git_mount(
            id="repo",
            mount_path="/mnt/repo",
            remote_url="https://github.com/langchain-ai/langsmith-sdk.git",
            ref={"type": "branch", "name": "main"},
            refresh_interval_seconds=60,
        )
    ],
)

with client.sandbox(
    name="git-mount-sandbox",
    mount_config=mount_cfg,
) as sb:
    result = sb.run("ls /mnt/repo")
    print(result.stdout)
```

Private Git repositories can use low-level `proxy_config` rules when the remote
requires proxy-managed auth. There is not yet a high-level private Git auth
helper.

If one sandbox needs S3, GCS, and Git mounts, build one `mount_config` with the
bucket provider auth blocks and all mount specs:

```python
from langsmith.sandbox import (
    aws_auth,
    git_mount,
    gcp_auth,
    gcs_mount,
    mount_config,
    s3_mount,
    workspace_secret,
)

mount_cfg = mount_config(
    auth=[
        aws_auth(
            access_key_id=workspace_secret("SANDBOX_AWS_ACCESS_KEY_ID"),
            secret_access_key=workspace_secret("SANDBOX_AWS_SECRET_ACCESS_KEY"),
        ),
        gcp_auth(
            service_account_json=workspace_secret(
                "SANDBOX_GCP_SERVICE_ACCOUNT_JSON"
            )
        ),
    ],
    mounts=[
        s3_mount(
            id="s3_data",
            mount_path="/mnt/mounts/s3-data",
            bucket="example-s3-bucket",
        ),
        gcs_mount(
            id="gcs_data",
            mount_path="/mnt/mounts/gcs-data",
            bucket="example-gcs-bucket",
        ),
        git_mount(
            id="repo",
            mount_path="/mnt/repo",
            remote_url="https://github.com/langchain-ai/langsmith-sdk.git",
        ),
    ],
)
```

## Running Commands

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Run a command
    result = sb.run("echo 'Hello, World!'")

    print(result.stdout)     # "Hello, World!\n"
    print(result.stderr)     # ""
    print(result.exit_code)  # 0
    print(result.success)    # True

    # Commands that fail return non-zero exit codes
    result = sb.run("exit 1")
    print(result.success)    # False
    print(result.exit_code)  # 1
```

## Streaming Output

For long-running commands, you can stream output in real time. This uses the
`websockets` package, which ships with `langsmith` by default.

### Callbacks

The simplest way to get real-time output. Blocks until the command completes.

```python
import sys

with client.sandbox(snapshot_id=snapshot_id) as sb:
    result = sb.run(
        "make build",
        timeout=600,
        on_stdout=lambda s: print(s, end=""),
        on_stderr=lambda s: print(s, end="", file=sys.stderr),
    )
    print(f"\nBuild {'succeeded' if result.success else 'failed'}")
```

### Streaming with CommandHandle

For full control — access to the process handle, stream identity, kill, and
reconnection.

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run("make build", timeout=600, wait=False)

    print(f"Command ID: {handle.command_id}")

    for chunk in handle:
        prefix = "OUT" if chunk.stream == "stdout" else "ERR"
        print(f"[{prefix}] {chunk.data}", end="")

    result = handle.result
    print(f"\nExit code: {result.exit_code}")
```

### Killing a Running Command

```python
import threading
import time

with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run("sleep 3600", timeout=7200, wait=False)

    # Kill after 10 seconds from another thread
    def kill_after(h, seconds):
        time.sleep(seconds)
        h.kill()

    threading.Thread(target=kill_after, args=(handle, 10)).start()

    for chunk in handle:
        print(chunk.data, end="")

    result = handle.result
    print(f"Exit code: {result.exit_code}")  # non-zero (killed)
```

### Sending Stdin Input

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run(
        "python -c 'name = input(\"Name: \"); print(f\"Hello {name}\")'",
        timeout=30,
        wait=False,
    )

    for chunk in handle:
        if "Name:" in chunk.data:
            handle.send_input("World\n")
        print(chunk.data, end="")

    result = handle.result
```

### Auto-Reconnect

`CommandHandle` (returned by `sb.run(wait=False)`) automatically
reconnects on transient disconnects — hot-reloads, network blips, etc. No user
code needed:

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run("make build", timeout=600, wait=False)

    # Auto-reconnects on transient errors (hot-reload, network blips)
    for chunk in handle:
        print(chunk.data, end="")

    result = handle.result
```

For manual reconnection across process restarts:

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run("make build", timeout=600, wait=False)
    command_id = handle.command_id

    # ... later, possibly in a different process ...

    handle = sb.reconnect(command_id)
    for chunk in handle:
        print(chunk.data, end="")
    result = handle.result
```

### No Timeout (`timeout=0`)

With WebSocket enabled, you can set `timeout=0` to let a command run
indefinitely with no server-side deadline. This works with both `wait=False`
and callbacks. Useful for long-lived processes like dev servers, file watchers,
or background tasks that you control via `kill()`.

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run("python server.py", timeout=0, wait=False)

    for chunk in handle:
        print(chunk.data, end="")
        if "Ready" in chunk.data:
            break  # server is up, do other work

    handle.kill()  # stop when done
```

> **Note:** `timeout=0` requires WebSocket support, which is enabled by
> default via the bundled `websockets` dependency. If `websockets` is not
> available, `run()` falls back to HTTP, which has its own request-level
> timeout.

## Command Lifecycle & TTL

The sandbox daemon automatically manages command session lifecycles with two
timeout mechanisms:

### Session TTL (finished commands)

After a command finishes (exits), its session remains in memory for a TTL
period. During this window you can still reconnect to retrieve output. After the
TTL expires, the session is cleaned up and `reconnect()` will raise an error.

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = sb.run("make build", wait=False)
    command_id = handle.command_id

    # Even after the command finishes, you can reconnect within the TTL window
    handle = sb.reconnect(command_id)
    result = handle.result
    print(result.stdout)

    # After TTL expires, reconnect raises SandboxOperationError
```

### Idle Timeout (running commands)

Running commands with no connected clients are killed after an idle timeout
(default: 5 minutes). The idle timer resets each time a client connects. This
prevents orphaned long-running processes from consuming resources indefinitely.

You can set a per-command idle timeout via the `idle_timeout` parameter.
Set to `-1` for no idle timeout (the command runs indefinitely until explicitly
killed or it exits on its own).

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Start a long-running command with a 30-minute idle timeout
    handle = sb.run(
        "python server.py",
        timeout=0,
        idle_timeout=1800,
        wait=False,
    )

    # As long as a client is connected (iterating), the idle timer is paused
    for chunk in handle:
        print(chunk.data, end="")
        if "Ready" in chunk.data:
            break

    # After disconnecting, the idle timer starts
    # If no client reconnects within idle_timeout seconds, the process is killed
```

### Kill on Disconnect

By default, commands continue running after a client disconnects and can be
reconnected to later. Set `kill_on_disconnect=True` to kill the command
immediately when the last client disconnects:

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Command is killed as soon as the client disconnects
    handle = sb.run(
        "python server.py",
        kill_on_disconnect=True,
        wait=False,
    )

    for chunk in handle:
        print(chunk.data, end="")
        if "Ready" in chunk.data:
            break
    # Command is killed here when iteration stops and the WS disconnects
```

### Combining Lifecycle Options

All lifecycle parameters can be combined:

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Long-running task: 30-min idle timeout, 1-hour session TTL
    handle = sb.run(
        "python train.py",
        timeout=0,              # No command timeout
        idle_timeout=1800,      # Kill after 30min with no clients
        ttl_seconds=3600,       # Keep session for 1 hour after exit
        wait=False,
    )

    # Fire-and-forget: no idle timeout, infinite TTL
    handle = sb.run(
        "python background_job.py",
        timeout=0,
        idle_timeout=-1,        # Never kill due to idle
        ttl_seconds=-1,         # Keep session forever
        wait=False,
    )
```

## PTY (Pseudo-Terminal)

Set `pty=True` to allocate a pseudo-terminal for the command. This is useful
for interactive programs and commands that detect terminal capabilities:

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Run an interactive Python REPL with PTY
    handle = sb.run("python", pty=True, wait=False)

    for chunk in handle:
        if ">>>" in chunk.data:
            handle.send_input("print('hello')\n")
            break

    for chunk in handle:
        if ">>>" in chunk.data:
            handle.send_input("exit()\n")
            break

    result = handle.result

    # Commands that require a TTY
    result = sb.run("top -b -n 1", pty=True)
```

> **Note:** PTY mode merges stdout and stderr into a single stream (stdout).
> Only use PTY when the command requires it — most commands work fine without it.

## File Operations

Read and write files in the sandbox:

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Write a file
    sb.write("/app/script.py", "print('Hello from file!')")

    # Run the script
    result = sb.run("python /app/script.py")
    print(result.stdout)  # "Hello from file!\n"

    # Read a file (returns bytes)
    content = sb.read("/app/script.py")
    print(content.decode())  # "print('Hello from file!')"

    # Write binary files
    sb.write("/app/data.bin", b"\x00\x01\x02\x03")
```

## TCP Tunnel

Access any TCP service running inside a sandbox (databases, Redis, HTTP servers,
etc.) as if it were running on your local machine. The tunnel opens a local TCP
port and forwards connections through a multiplexed WebSocket to the target port
inside the sandbox.

Uses the `websockets` package, which ships with `langsmith` by default.

### Basic Usage — PostgreSQL

Build a snapshot from the `postgres:16` image. The entrypoint initializes and
starts Postgres automatically:

```python
import psycopg2

postgres_snapshot = client.create_snapshot(
    "postgres-snapshot",
    docker_image="postgres:16",
    fs_capacity_bytes=4 * 1024**3,
)

sb = client.create_sandbox(snapshot_id=postgres_snapshot.id)
pg_handle = sb.run(
    "POSTGRES_HOST_AUTH_METHOD=trust docker-entrypoint.sh postgres",
    timeout=0,
    wait=False,
)
import time; time.sleep(6)  # wait for Postgres to initialize and start

try:
    with sb.tunnel(remote_port=5432, local_port=25432) as t:
        conn = psycopg2.connect(
            host="127.0.0.1",
            port=t.local_port,
            user="postgres",
        )
        cursor = conn.cursor()
        cursor.execute("SELECT version()")
        print(cursor.fetchone())
        conn.close()
finally:
    pg_handle.kill()
    client.delete_sandbox(sb.name)
```

### Basic Usage — Redis

Build a snapshot from the `redis:7` image. Redis self-daemonizes:

```python
import redis

redis_snapshot = client.create_snapshot(
    "redis-snapshot",
    docker_image="redis:7",
    fs_capacity_bytes=2 * 1024**3,
)

with client.sandbox(snapshot_id=redis_snapshot.id) as sb:
    sb.run("redis-server --daemonize yes", timeout=10)

    with sb.tunnel(remote_port=6379, local_port=26379) as t:
        r = redis.Redis(host="127.0.0.1", port=t.local_port)
        r.set("key", "value")
        print(r.get("key"))  # b"value"
```

### HTTP Services

Works with any TCP service. Start long-running services with `wait=False` and
`timeout=0` so they stay alive across commands:

```python
sb = client.create_sandbox(snapshot_id=snapshot_id)
http_handle = sb.run("python3 -m http.server 3000", timeout=0, wait=False)
import time; time.sleep(2)

try:
    with sb.tunnel(remote_port=3000, local_port=13000) as t:
        import urllib.request
        resp = urllib.request.urlopen(f"http://127.0.0.1:{t.local_port}/")
        print(resp.status)  # 200
finally:
    http_handle.kill()
    client.delete_sandbox(sb.name)
```

### Multiple Tunnels

Open several tunnels simultaneously to different services:

```python
http_handle2 = sb.run("python3 -m http.server 3001", timeout=0, wait=False)
import time; time.sleep(1)

with sb.tunnel(remote_port=3000, local_port=23000) as t1, \
     sb.tunnel(remote_port=3001, local_port=23001) as t2:
    resp1 = urllib.request.urlopen(f"http://127.0.0.1:{t1.local_port}/")
    resp2 = urllib.request.urlopen(f"http://127.0.0.1:{t2.local_port}/")

http_handle2.kill()
```

### Explicit Lifecycle

For notebooks or long-lived sessions where a context manager isn't convenient:

```python
t = sb.tunnel(remote_port=3000, local_port=23002)

print(t.local_port)
# ... use the tunnel as long as needed ...

t.close()
```

### Async Usage

```python
async with await client.sandbox(snapshot_id=snapshot_id) as sb:
    async with await sb.tunnel(remote_port=5432) as t:
        conn = await asyncpg.connect(host="127.0.0.1", port=t.local_port)
```

## Service URLs

Access HTTP services running inside a sandbox without opening a TCP tunnel.
`service()` returns a `ServiceURL` object with a short-lived JWT that
auto-refreshes transparently. Built-in HTTP helpers inject the auth header
for you.

### Basic Usage

```python
with client.sandbox(snapshot_id=snapshot_id) as sb:
    # Start a web server inside the sandbox
    handle = sb.run("python -m http.server 3000", timeout=0, wait=False)
    import time; time.sleep(2)

    # Get a service URL for port 3000
    svc = sb.service(port=3000)

    # Make requests — token is injected automatically
    resp = svc.get("/")
    print(resp.status_code)  # 200

    # POST with JSON body
    resp = svc.post("/api/data", json={"key": "value"})

    # Access the raw token or URLs directly
    print(svc.token)        # JWT (auto-refreshes near expiry)
    print(svc.service_url)  # base URL for programmatic access
    print(svc.browser_url)  # URL that sets a cookie in a browser

    handle.kill()
```

### Custom Token TTL

Tokens default to 10 minutes. Set `expires_in_seconds` for longer or shorter
lifetimes (1 second to 24 hours):

```python
# Token valid for 1 hour
svc = sb.service(port=3000, expires_in_seconds=3600)
```

### Auto-Refresh

The `ServiceURL` object automatically refreshes its token before it expires.
You never need to worry about token rotation — just keep using the object:

```python
svc = sb.service(port=3000, expires_in_seconds=60)

# Even after 60 seconds, this still works — token refreshes transparently
resp = svc.get("/api/status")
```

### Async Usage

```python
async with await client.sandbox(snapshot_id=snapshot_id) as sb:
    svc = await sb.service(port=3000)

    # Async HTTP helpers
    resp = await svc.get("/api/data")

    # Async accessors for auto-refreshing properties
    token = await svc.get_token()
    url = await svc.get_service_url()
```

## Snapshots

Snapshots are the starting point for every sandbox. They're built from Docker
images or captured from running sandboxes, and many sandboxes can share the
same snapshot.

### Build a Snapshot from a Docker Image

```python
from langsmith.sandbox import SandboxClient

client = SandboxClient()

# Build a snapshot — blocks until ready (default timeout=60s)
snapshot = client.create_snapshot(
    "my-python-env",
    docker_image="python:3.12-slim",
    fs_capacity_bytes=4 * 1024**3,  # 4 GB
)

# Optionally create a sandbox from the snapshot (by ID)
with client.sandbox(snapshot_id=snapshot.id) as sb:
    result = sb.run("python --version")
    print(result.stdout)

# Or resolve by snapshot name. This is optional; omitting both snapshot_id and
# snapshot_name uses the default runtime.
with client.sandbox(snapshot_name="my-python-env") as sb:
    result = sb.run("python --version")
    print(result.stdout)
```

### Capture a Running Sandbox

Install packages or prepare files on a running sandbox, then capture the
result as a reusable snapshot. The returned snapshot has `source_sandbox_id`
set to the sandbox it was captured from, and can be used as the
`snapshot_id` for any later `create_sandbox` / `sandbox(...)` call.

```python
sb = client.create_sandbox(snapshot_id=base_snapshot_id, name="setup-box")
sb.run("pip install numpy pandas scikit-learn", timeout=180)
sb.write("/opt/config.yaml", "model: gpt-5\n")

# Either form works; the instance method just forwards to the client.
snapshot = sb.capture_snapshot("ml-ready", timeout=300)
# snapshot = client.capture_snapshot(sb.name, "ml-ready", timeout=300)
print(snapshot.id, snapshot.source_sandbox_id)

sb.delete()

# Later: spin up sandboxes from the captured snapshot
with client.sandbox(snapshot_id=snapshot.id) as sb:
    sb.run("python -c 'import numpy; print(numpy.__version__)'")
    assert sb.read("/opt/config.yaml") == b"model: gpt-5\n"
```

> **Note:** `capture_snapshot` preserves only the **persistent filesystem**.
> Installed packages (under `/usr/local`, `/root`, `/opt`, the home
> directory, etc.) and files you wrote to those paths are kept. Running
> processes, open sockets, in-memory state, and anything under `/tmp`
> (which is a tmpfs) are **not** carried over — restart the processes you
> need in the new sandbox.

### Snapshot CRUD

```python
# List snapshots (server paginates with a default page size of 50)
snapshots = client.list_snapshots()

# Filter and paginate — all three kwargs are optional and independent.
# `limit` must be between 1 and 500 (inclusive); `offset` must be >= 0.
snapshots = client.list_snapshots(
    name_contains="python",  # case-insensitive substring match on name
    limit=100,
    offset=0,
)

# Get a snapshot by ID
snapshot = client.get_snapshot("550e8400-...")

# Delete a snapshot
client.delete_snapshot("550e8400-...")

# Build with longer timeout for large images
snapshot = client.create_snapshot(
    "heavy-image",
    docker_image="nvidia/cuda:12.0-devel-ubuntu22.04",
    fs_capacity_bytes=16 * 1024**3,
    timeout=600,
)
```

## Start / Stop

Snapshot-based sandboxes can be stopped and restarted. The sandbox files are
preserved across stop/start cycles.

```python
sb = client.create_sandbox(snapshot_id=snapshot.id, name="my-vm")
sb.run("echo 'hello' > /tmp/state.txt")

# Stop the sandbox (preserves sandbox files)
sb.stop()

# Later: restart it
sb.start()  # blocks until ready (default timeout=120s)

result = sb.run("cat /tmp/state.txt")
assert result.stdout.strip() == "hello"
```

You can also use the client methods directly:

```python
client.stop_sandbox("my-vm")
sandbox = client.start_sandbox("my-vm")
```

## Sandbox Lifetime & TTL

Sandboxes follow a two-stage retention model anchored to **idle activity**
and the **`stopped`** state — there is no wall-clock "max lifetime" TTL:

- **`idle_ttl_seconds`** — Idle timeout. The launcher stops the sandbox
  after this many seconds of inactivity (any command execution or file I/O
  resets the timer). When omitted at creation, the server applies a default
  of `600` seconds (10 minutes); pass `0` explicitly to disable the idle
  stop and keep the sandbox running indefinitely.
- **`delete_after_stop_seconds`** — Stop-anchored deletion. Once a sandbox
  enters the `stopped` state (either via the idle timer above or an explicit
  `stop_sandbox` call), this timer starts. After the deadline passes, the
  sandbox row and its filesystem clone are permanently deleted by a
  server-side sweep. Pass `0` to disable stop-anchored deletion (manual
  cleanup required); when omitted, the server applies its configured default
  (typically 14 days).

Both values must be multiples of 60 (minute-resolution). The lifecycle is:

```
running ──(idle for idle_ttl_seconds)──▶ stopped ──(delete_after_stop_seconds)──▶ deleted
```

```python
# Default retention (server defaults: 10 min idle stop, 14 day delete)
with client.sandbox(snapshot_id=snapshot_id) as sb:
    result = sb.run("echo hello")

# Aggressive: stop after 5 min idle, delete 1 hour after stop
sb = client.create_sandbox(
    snapshot_id=snapshot_id,
    idle_ttl_seconds=300,
    delete_after_stop_seconds=3600,
)

# Long-running: never auto-stop, delete 7 days after manual stop
sb = client.create_sandbox(
    snapshot_id=snapshot_id,
    idle_ttl_seconds=0,
    delete_after_stop_seconds=604800,
)

# Inspect retention settings
print(sb.idle_ttl_seconds)            # e.g. 300
print(sb.delete_after_stop_seconds)   # e.g. 3600
print(sb.stopped_at)                  # None while running, ISO timestamp once stopped
```

### Updating retention on existing sandboxes

You can update either retention setting on a running or stopped sandbox.
Updating `delete_after_stop_seconds` on an already-stopped sandbox shifts
its deletion deadline (`stopped_at + delete_after_stop_seconds`):

```python
# Extend the idle stop to 30 minutes
sb = client.update_sandbox("my-sandbox", idle_ttl_seconds=1800)

# Push the deletion deadline out to 30 days after stop
sb = client.update_sandbox("my-sandbox", delete_after_stop_seconds=2592000)

# Disable both — sandbox keeps running and never auto-deletes
sb = client.update_sandbox(
    "my-sandbox",
    idle_ttl_seconds=0,
    delete_after_stop_seconds=0,
)
```

> **Migration note:** the previous `ttl_seconds` (hard wall-clock
> TTL) and `expires_at` fields were removed. The hard TTL never reliably
> deleted stopped sandboxes; replace any usage with `idle_ttl_seconds` for
> stopping and `delete_after_stop_seconds` for deletion.

## Reusing Existing Sandboxes

Get a sandbox that's already running:

```python
# Create a sandbox (requires explicit cleanup)
sb = client.create_sandbox(snapshot_id=snapshot_id)
print(sb.name)  # e.g., "sandbox-abc123"

# Later, get the same sandbox
sb = client.get_sandbox("sandbox-abc123")
result = sb.run("echo 'Still running!'")

# Clean up when done
client.delete_sandbox("sandbox-abc123")
```

## Async Sandbox Creation

By default, `create_sandbox()` blocks until the sandbox is ready. For
non-blocking creation, pass `wait_for_ready=False`:

```python
# Returns immediately with status="provisioning"
sb = client.create_sandbox(snapshot_id=snapshot_id, wait_for_ready=False)
print(sb.status)  # "provisioning"

# Poll until ready using the lightweight status endpoint
sb = client.wait_for_sandbox(sb.name, timeout=120, poll_interval=1.0)
print(sb.status)  # "ready"

# Now the sandbox is usable
result = sb.run("echo hello")
```

You can also poll manually for more control:

```python
sb = client.create_sandbox(snapshot_id=snapshot_id, wait_for_ready=False)

while True:
    status = client.get_sandbox_status(sb.name)
    if status.status == "ready":
        sb = client.get_sandbox(sb.name)
        break
    if status.status == "failed":
        print(f"Failed: {status.status_message}")
        break
    time.sleep(1)
```

> **Note:** Operations like `run()`, `write()`, and `read()` will raise
> `SandboxNotReadyError` if called on a sandbox that isn't ready yet.

## Async Support

Full async support for all operations:

```python
from langsmith.sandbox import AsyncSandboxClient

async def main():
    async with AsyncSandboxClient() as client:
        # Build a snapshot first
        snapshot = await client.create_snapshot(
            "async-python",
            docker_image="python:3.12-slim",
            fs_capacity_bytes=4 * 1024**3,
        )

        # Use the snapshot
        async with await client.sandbox(snapshot_id=snapshot.id) as sb:
            result = await sb.run("python -c 'print(1 + 1)'")
            print(result.stdout)  # "2\n"

            await sb.write("/app/test.txt", "async content")
            content = await sb.read("/app/test.txt")
            print(content.decode())
```

### Async Streaming

```python
async with await client.sandbox(snapshot_id=snapshot_id) as sb:
    handle = await sb.run("make build", timeout=600, wait=False)

    async for chunk in handle:
        print(chunk.data, end="")

    result = await handle.result
```

## Error Handling

The module provides type-based exceptions with a `resource_type` attribute for specific handling:

```python
from langsmith.sandbox import (
    SandboxClientError,       # Base exception for all sandbox errors
    ResourceCreationError,    # Resource provisioning failed (check resource_type, error_type)
    ResourceNotFoundError,    # Resource doesn't exist (check resource_type)
    ResourceTimeoutError,     # Operation timed out (check resource_type)
    SandboxNotReadyError,     # Sandbox not ready for operations yet
    SandboxConnectionError,   # Network/WebSocket error
    CommandTimeoutError,      # Command exceeded its timeout (extends SandboxOperationError)
    QuotaExceededError,       # Quota limit reached
    TunnelError,              # Base for tunnel errors
    TunnelPortNotAllowedError,       # Port blocked by daemon allowlist
    TunnelConnectionRefusedError,    # Nothing listening on remote port
    TunnelUnsupportedVersionError,   # Client/daemon protocol mismatch
)

try:
    with client.sandbox(snapshot_id=snapshot_id) as sb:
        result = sb.run("sleep 999", timeout=10)
except CommandTimeoutError as e:
    print(f"Command timed out: {e}")
except ResourceCreationError as e:
    print(f"{e.resource_type} creation failed: {e}")
except ResourceNotFoundError as e:
    print(f"{e.resource_type} not found: {e}")
except ResourceTimeoutError as e:
    print(f"Timeout waiting for {e.resource_type}: {e}")
except SandboxConnectionError as e:
    print(f"Connection error: {e}")
except SandboxClientError as e:
    print(f"Error: {e}")
```

## API Reference

### SandboxClient

| Method | Description |
|--------|-------------|
| `sandbox(snapshot_id=None, *, snapshot_name=None, mount_config=None, proxy_config=None, ...)` | Create a sandbox with the default runtime (auto-deleted on context exit). Pass `snapshot_id` or `snapshot_name` only to boot from a reusable snapshot. |
| `create_sandbox(snapshot_id=None, *, snapshot_name=None, mount_config=None, proxy_config=None, wait_for_ready=True, ...)` | Create a sandbox with the default runtime (requires explicit delete). Pass `snapshot_id` or `snapshot_name` only to boot from a reusable snapshot. |
| `get_sandbox(name)` | Get an existing sandbox by name |
| `get_sandbox_status(name)` | Get lightweight provisioning status (`ResourceStatus`) |
| `wait_for_sandbox(name, *, timeout=120, poll_interval=1.0)` | Poll until sandbox is ready or failed |
| `service(name, port, *, expires_in_seconds=600)` | Get a `ServiceURL` for an HTTP service on the given port |
| `list_sandboxes()` | List all sandboxes |
| `update_sandbox(name, *, new_name=None, idle_ttl_seconds=None, delete_after_stop_seconds=None)` | Update a sandbox's name or retention settings |
| `delete_sandbox(name)` | Delete a sandbox |
| `start_sandbox(name, *, timeout=120)` | Start a stopped sandbox, poll until ready |
| `stop_sandbox(name)` | Stop a running sandbox (preserves sandbox files) |
| `create_snapshot(name, docker_image, fs_capacity_bytes, *, timeout=60)` | Build a snapshot from a Docker image |
| `capture_snapshot(sandbox_name, name, *, timeout=60)` | Capture a snapshot from a running sandbox |
| `get_snapshot(snapshot_id)` | Get a snapshot by ID |
| `list_snapshots(*, name_contains=None, limit=None, offset=None)` | List a page of snapshots (server paginates, default limit 50, max 500; `name_contains` is a case-insensitive substring match) |
| `delete_snapshot(snapshot_id)` | Delete a snapshot |
| `wait_for_snapshot(snapshot_id, *, timeout=300)` | Poll until snapshot is ready or failed |

### Sandbox

| Property | Description |
|----------|-------------|
| `name` | Display name |
| `snapshot_id` | Snapshot ID used to create this sandbox |
| `status` | Lifecycle status: `"provisioning"`, `"ready"`, `"failed"`, or `"stopped"` |
| `status_message` | Human-readable details when status is `"failed"`, `None` otherwise |
| `dataplane_url` | URL for runtime operations (only functional when status is `"ready"`) |
| `id` | Unique identifier (UUID) |
| `idle_ttl_seconds` | Idle timeout in seconds before the launcher stops the sandbox (`0` means disabled, `None` means not set). New sandboxes get a server-side default of `600` (10 minutes) when not explicitly provided. |
| `delete_after_stop_seconds` | Seconds after entering `stopped` before the sandbox and its filesystem clone are permanently deleted (`0` means disabled, `None` means server default). |
| `stopped_at` | ISO 8601 timestamp when the sandbox transitioned to `stopped`, or `None` while running. |

| Method | Description |
|--------|-------------|
| `run(command, *, timeout=60, on_stdout=None, on_stderr=None, idle_timeout=300, kill_on_disconnect=False, ttl_seconds=600, pty=False, wait=True)` | Execute a shell command. Returns `ExecutionResult` or `CommandHandle` (when `wait=False`). |
| `reconnect(command_id, *, stdout_offset=0, stderr_offset=0)` | Reconnect to a running command. Returns `CommandHandle`. |
| `write(path, content)` | Write file (str or bytes) |
| `read(path)` | Read file (returns bytes) |
| `tunnel(remote_port, *, local_port=0)` | Open a TCP tunnel. Returns `Tunnel` (context manager). |
| `service(port, *, expires_in_seconds=600)` | Get a `ServiceURL` for an HTTP service. Auto-refreshes token. |
| `start(*, timeout=120)` | Start a stopped sandbox and wait until ready. |
| `stop()` | Stop a running sandbox (preserves sandbox files for later restart). |
| `delete()` | Delete this sandbox. |
| `capture_snapshot(name, *, timeout=60)` | Capture a snapshot from this sandbox. |

### ExecutionResult

| Property | Description |
|----------|-------------|
| `stdout` | Standard output (str) |
| `stderr` | Standard error (str) |
| `exit_code` | Exit code (int) |
| `success` | True if exit_code == 0 |

### ResourceStatus

Returned by `client.get_sandbox_status()`.

| Property | Description |
|----------|-------------|
| `status` | Lifecycle status: `"provisioning"`, `"ready"`, or `"failed"` |
| `status_message` | Human-readable details when `"failed"`, `None` otherwise |

### CommandHandle

Returned by `sb.run(wait=False)`. Iterable, yielding `OutputChunk` objects.

| Property / Method | Description |
|-------------------|-------------|
| `command_id` | Server-assigned command ID |
| `pid` | Process ID on the sandbox |
| `result` | Final `ExecutionResult` (blocks until complete) |
| `kill()` | Send SIGKILL to the running command |
| `send_input(data)` | Write string data to the command's stdin |
| `reconnect()` | Reconnect from last known offsets |

### OutputChunk

| Property | Description |
|----------|-------------|
| `stream` | `"stdout"` or `"stderr"` |
| `data` | Text content of this chunk (str) |
| `offset` | Byte offset within the stream (int) |

### Tunnel

Returned by `sb.tunnel(remote_port)`. Context manager that opens a local TCP
listener forwarding to a port inside the sandbox.

| Property / Method | Description |
|-------------------|-------------|
| `local_port` | Local port the tunnel is listening on (int) |
| `remote_port` | Target port inside the sandbox (int) |
| `close()` | Shut down the tunnel and all connections |

### ServiceURL

Returned by `sb.service(port)`. Holds a short-lived JWT for accessing an HTTP
service in the sandbox. Properties auto-refresh the token near expiry.

| Property | Description |
|----------|-------------|
| `token` | Raw JWT for programmatic use (auto-refreshes) |
| `service_url` | Base URL for programmatic HTTP access (auto-refreshes) |
| `browser_url` | URL that exchanges the JWT for a cookie in a browser (auto-refreshes) |
| `expires_at` | ISO 8601 expiration timestamp (auto-refreshes) |

| Method | Description |
|--------|-------------|
| `request(method, path="/", **kwargs)` | Make an HTTP request with auth header injected. Returns `httpx.Response`. |
| `get(path="/", **kwargs)` | HTTP GET |
| `post(path="/", **kwargs)` | HTTP POST |
| `put(path="/", **kwargs)` | HTTP PUT |
| `patch(path="/", **kwargs)` | HTTP PATCH |
| `delete(path="/", **kwargs)` | HTTP DELETE |

`AsyncServiceURL` is the async variant. Use `await svc.get_token()`,
`await svc.get_service_url()`, etc. for auto-refreshing access, and
`await svc.get(path)` for async HTTP helpers.
