Metadata-Version: 2.4
Name: sail-sdk
Version: 0.1.21
Summary: Python SDK for the Sail sandbox platform
Project-URL: Homepage, https://app.sailresearch.com
Project-URL: Documentation, https://github.com/sailresearch/sail/tree/main/sdk/python#readme
Project-URL: Repository, https://github.com/sailresearch/sail
Project-URL: Issues, https://github.com/sailresearch/sail/issues
Author: Sail
License-Expression: Apache-2.0
Keywords: grpc,sail,sailbox,sandbox,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: click>=8.1
Requires-Dist: cloudpickle==3.1.2
Requires-Dist: grpcio>=1.80.0
Requires-Dist: pathspec>=0.12
Requires-Dist: protobuf>=6.31.1
Requires-Dist: tomli>=2.0; python_version < '3.11'
Provides-Extra: dev
Requires-Dist: grpcio-tools>=1.80.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# sail-sdk

Python SDK for Sail sailboxes, inference, and Voyages.

Sailboxes are persistent Linux sandboxes managed by Sail. Use the `sail-sdk`
Python package to create a VM, run shell commands, expose ports, checkpoint
state, pause, resume, and terminate it. The SDK also includes Voyage
observability helpers and thin inference wrappers for Sail's public REST API.

Sailboxes are currently in beta. APIs and operational behavior may change as we
stabilize the product.

## Install

```bash
pip install sail-sdk
```

```bash
uv add sail-sdk
```

Set your API key before using the SDK:

```bash
export SAIL_API_KEY=sk_...
```

Set `SAIL_API_URL=https://...` to override the REST API endpoint directly.

## Voyages

Voyages are a flight recorder for long-running agent, evaluation, and
background-task trajectories. Your harness owns the work loop. Sail records
timeline events, spans, agent metadata, and correlated Sail inference calls.

```python
import sail

sail.voyage.init(name="overnight eval")

sail.voyage.event("planner.started", message="planning")

with sail.voyage.agent("agent_solver", name="Solver", role="executor"):
    with sail.voyage.span("call model"):
        resp = sail.inference.responses.create(
            model="openai/gpt-oss-20b",
            input="Say hello in one sentence.",
        )

sail.voyage.complete()

print(sail.voyage.id())
print(sail.voyage.dashboard_url())
```

`sail.voyage.init()` creates a Voyage and emits `voyage.started`. It also sets
the process-global current Voyage used by module-level helpers. If
`SAIL_API_KEY` is absent, `sail.voyage` becomes a safe no-op for local scripts:
no Voyage is created, no network calls are made, and `id()` /
`dashboard_url()` return `None`. Sailbox and inference APIs still require
`SAIL_API_KEY`.

### Voyage Environment

| Variable | Purpose |
| --- | --- |
| `SAIL_API_KEY` | Required for real Voyage events and inference. Use an org-bearing `sk_...` key. |
| `SAIL_MODE` | Selects SDK routing mode. |
| `SAIL_API_URL` | Overrides the REST API URL directly. |
| `SAILBOX_ID` | Default `sailbox_id` attached to new Voyages. |
| `SAIL_VOYAGE_ID` | Attaches to an existing Voyage instead of creating one. |
| `SAIL_AGENT_ID` | Default event `agent_id`. |
| `SAIL_AGENT_NAME` | Default event `agent_name`. |
| `SAIL_AGENT_ROLE` | Default event `agent_role`. |
| `SAIL_VOYAGE_DEBUG` | Enables warnings for no-op mode, swallowed background flush failures, and dropped events. |

### Events And Spans

```python
voyage = sail.voyage.init(name="batch run", metadata={"suite": "nightly"})

voyage.event("planner.started", message="Building plan")

with voyage.span("solve task", agent_id="agent_1", name="solver"):
    voyage.event("tool.called", payload={"tool": "search"})

voyage.complete(message="done")
```

Events are buffered locally. `event()` validates local input, enqueues quickly,
and does not raise network errors. A background flusher sends batches
periodically. `flush()`, `complete()`, and `fail()` block and raise delivery
errors. Process exit does a bounded best-effort flush only, so call
`complete()` or `fail()` for product-critical terminal state.

Span and agent contexts use Python `contextvars`, so they follow lexical
execution context. Raw `threading.Thread` does not inherit active span or agent
contexts automatically. The current Voyage itself is process-global, so raw
threads can still use the current Voyage for inference correlation.

### Attach From Child Processes

A parent process can create the Voyage and pass `SAIL_VOYAGE_ID` plus
`SAIL_API_KEY` to a child process or Sailbox command.

```python
import os
import subprocess

import sail

voyage = sail.voyage.init(name="parent run")

env = dict(os.environ)
env["SAIL_VOYAGE_ID"] = voyage.id
env["SAIL_API_KEY"] = os.environ["SAIL_API_KEY"]

subprocess.run(["python", "child.py"], env=env, check=True)
voyage.complete()
```

In the child:

```python
import sail

sail.voyage.init()
sail.voyage.event("child.started")
sail.voyage.flush()
```

Calling `init()` with `voyage_id=...` or with `SAIL_VOYAGE_ID` set attaches to
the existing Voyage and does not emit a second `voyage.started`.

### Inference

The SDK includes thin wrappers for Sail's inference endpoints. They POST the
JSON payload as given and return the raw JSON response dict.

```python
resp = sail.inference.responses.create(
    model="openai/gpt-oss-20b",
    input="hello",
)

chat = sail.inference.chat.completions.create(
    model="openai/gpt-oss-20b",
    messages=[{"role": "user", "content": "hello"}],
)
```

If a current Voyage exists, the wrappers add `X-Sail-Voyage-Id` so the
dashboard can correlate model calls. You can pass `voyage=...` to correlate
with an explicit Voyage, or call inference without any Voyage for normal
uncorrelated inference.

`stream=True` is not supported by the v0 wrapper. Use a raw client with
`sail.voyage.headers()` when you need streaming or a custom HTTP/OpenAI client.

```python
import json
import os
import urllib.request

import sail

sail.voyage.init(name="raw-client-smoke")

api_urls = {
    "prod": "https://api.sailresearch.com",
    "dev": "https://dev.sailresearch.com",
    "staging": "https://staging.sailresearch.com",
    "local": "https://dev.sailresearch.com",
}
mode = os.environ.get("SAIL_MODE", "prod").strip().lower() or "prod"
api_url = os.environ.get("SAIL_API_URL", "").strip() or api_urls[mode]

headers = sail.voyage.headers({"Content-Type": "application/json"})
headers["Authorization"] = "Bearer " + os.environ["SAIL_API_KEY"]

req = urllib.request.Request(
    api_url.rstrip("/") + "/v1/responses",
    data=json.dumps({"model": "openai/gpt-oss-20b", "input": "hello"}).encode(),
    headers=headers,
    method="POST",
)
```

### Voyage Limitations

The v0 Voyage SDK does not include native async helpers, streaming inference
wrappers, an agent framework, orchestration, swarms, memory graphs, tool
abstractions, an OpenAI client factory, or Sailbox auto-binding.

## Create a sailbox

Sailboxes are persistent sandboxes that can run indefinitely, pause and resume
their writable disk, memory, and open network connections, and automatically
sleep while Sail inference calls are in flight. You are only charged while a
sailbox is active, making them efficient for bursty agent and tool workloads.

```python
import sail

app = sail.App.find(name="example-app", mint_if_missing=True)

sb = sail.Sailbox.create(
    app=app,
    image=sail.Image.debian_arm64,
    name="sandbox-1",
    cpu=1,
    memory_mib=1024,
    disk_gib=8,
)

print(sb.sailbox_id)
print(sb.status)
```

`Sailbox.create()` returns after the VM is running. The main create arguments
are:

| Argument | Default | Description |
| --- | --- | --- |
| `app` | Required | A `sail.App`, usually from `sail.App.find()` |
| `image` | Required | A `sail.Image` value or custom image definition |
| `name` | Required | Human-readable sailbox name |
| `image_build_timeout` | `1800` | Seconds to wait for custom image builds before creating the VM |
| `cpu` | `1` | vCPU count; must be greater than `0` |
| `memory_mib` | `1024` | Memory in MiB; must be between `1024` and `65536` |
| `disk_gib` | `8` | Writable state disk in GiB; must be between `1` and `64` |
| `ingress_ports` | `None` | Guest ports to expose publicly |

Sailboxes should use `sail.Image.debian_arm64` or `sail.Image.debian_arm`. We
have plans to support AMD64 images soon - please contact us if you would like
us to prioritise this.

## Run commands

`exec()` starts a shell command and returns a `SailboxExecRequest`. Call `wait()`
to retrieve stdout, stderr, and the return code.

```python
result = sb.exec("echo hi", timeout=5).wait()

print(result.stdout)
print(result.stderr)
print(result.returncode)
```

`timeout` is the command runtime budget in seconds. Omit it to run without an
SDK-provided runtime limit. Only one exec request can be active on a sailbox at
a time; if another command is running, the SDK raises
`sail.SailboxExecAlreadyRunningError`.

Pass `cwd` to run the command from a specific working directory. With
`background=True`, Sail launches the process through a detached shell and
`wait()` only waits for that launcher to succeed.

## Run Python functions

Decorate a Python function with `@sail.function()` and pass it to `exec()` to
run it inside the sailbox. For functions, `exec()` waits for completion and
returns the function's return value directly. Sail runs the function with the
image's `python3`.

```python
@sail.function()
def add(x: int, y: int) -> int:
    return x + y

value = sb.exec(add, 2, 3, timeout=30)
print(value)  # 5
```

Python functions are currently only supported for sailboxes running custom
images. We plan to remove this limitation shortly.

Function execution is synchronous. `background=True` is not supported for
functions. Remote exceptions are raised as `sail.SailboxFunctionError` with the
remote traceback attached.

This beta path sends serialized function payloads and return values through the
existing exec RPC. Keep arguments and return values small; for large dataframes
or artifacts, write data from inside the sailbox and return a small reference.

## Expose ports

Pass `ingress_ports` when creating the sailbox, then start a service inside the
VM and fetch its listener URL.

```python
sb = sail.Sailbox.create(
    app=app,
    image=sail.Image.debian_arm64,
    name="web-demo",
    ingress_ports=[3000],
)

sb.exec("python3 -m http.server 3000", background=True, cwd="/srv/app").wait()

listener = sb.listener(3000)
listener.wait(timeout=60)

print(listener.url)
print(listener.route_status)
```

Use `listeners()` to list every exposed port:

```python
for listener in sb.listeners():
    print(listener.port, listener.url, listener.route_status)
```

Ports must be unique and between `1` and `65535`. Ports `22` and `10000` are
reserved by the platform.

## Long-running services

Create a normal sailbox and start long-running processes with background exec.
There is no separate daemon creation API. Existing guest-to-egress connections can
remain open while background processes run. Sailboxes are checkpointed only when
you call `checkpoint()`, `pause()`, or `sleep()`, or when Sail receives an AWS
preemption signal for the host.

```python
sb = sail.Sailbox.create(
    app=app,
    image=sail.Image.debian_arm64,
    name="web-daemon",
    ingress_ports=[3000],
)

sb.exec("python3 -m http.server 3000", background=True, cwd="/srv/app").wait()
sb.checkpoint()
```

## Lifecycle

```python
sb.checkpoint()  # Snapshot while keeping the sailbox running
sb.pause()       # Checkpoint and pause until explicit resume
sb.sleep()       # Checkpoint and sleep until network ingress, exec, or resume
sb.resume()      # Resume a paused or sleeping sailbox
sb.terminate()   # Permanently destroy the sailbox
```

After `pause()`, `exec()` raises `sail.SailboxExecutionError` until `resume()`
succeeds. Network traffic does not wake a paused sailbox; only `resume()` does.
After `sleep()`, network ingress, `exec()`, or `resume()` wakes the sailbox from
its latest checkpoint.
`terminate()` is permanent.

Sailboxes preserve their writable disk, in-memory state, and in-flight network
requests across checkpoints and resumes.

We highly recommend calling `sb.checkpoint()` after any non-deterministic
command. On host failure, Sail can provide best-effort recovery by replaying
commands from the latest checkpoint. Checkpointing immediately after a
non-deterministic command allows you to avoid diverging behaviour on replays.

To sleep a sailbox automatically while a foreground Sail inference call is in
flight, include its ID in the request with the `X-SailboxId` header. Sail will
resume the sailbox after the inference call completes.

```python
response = sail.inference.responses.create(
    model="zai-org/GLM-5",
    input="Summarize the current workspace state.",
    background=False,
    headers={"X-SailboxId": sb.sailbox_id},
)
```

## Custom images

Start from the arm64 Debian base image, add build steps, and pass the image
definition to `Sailbox.create()`:

```python
image = (
    sail.Image.debian_arm64
    .apt_install("git", "curl")
    .pip_install("requests")
    .run_commands("python3 -m pip show requests >/tmp/requests.txt")
    .env({"APP_ENV": "demo"})
)

sb = sail.Sailbox.create(
    app=app,
    image=image,
    name="custom-image-demo",
    image_build_timeout=1800,
)
```

Image definitions are immutable; each helper returns a new definition. Supported
helpers are `apt_install(*packages)`, `pip_install(*packages)`,
`run_commands(*commands)`, `env(dict[str, str])`, and `build(timeout=1800)`. The
SDK retries short-lived imagebuilder socket-close transport races during
`BuildImage` and status polling, but retry sleeps are capped by the same
`timeout` or `image_build_timeout` budget.
Calling `build()` eagerly builds the image before you create a sailbox. If the
same image was already built, the cached image is returned. This step is
optional because `Sailbox.create()` will build custom image definitions before
creating the VM.

## API surface

Common exported SDK types:

| Type | Description |
| --- | --- |
| `sail.App` | Sail application namespace; use `App.find(name=..., mint_if_missing=True)` |
| `sail.Image` | Image namespace with `debian_arm64` and `debian_arm` |
| `sail.SailFunction` | Decorated Python function accepted by `Sailbox.exec()` |
| `sail.Sailbox` | Standard sailbox handle |
| `sail.SailboxExecRequest` | Durable exec request returned by `exec()` |
| `sail.SailboxExecResult` | `stdout`, `stderr`, and `returncode` from `wait()` |
| `sail.SailboxListener` | Public listener metadata for an exposed guest port |
| `sail.voyage` | Module-level Voyage helpers for init, events, spans, agents, terminal state, and headers |
| `sail.Voyage` | Object API returned by `sail.voyage.init()` |
| `sail.inference` | Thin raw JSON wrappers for `/v1/responses` and `/v1/chat/completions` |

Common exceptions:

| Exception | Raised when |
| --- | --- |
| `sail.SailError` | Base SDK error |
| `sail.SailboxError` | Base sailbox-specific error |
| `sail.SailboxCreationError` | Creation, checkpoint, stop, start, or other lifecycle operation fails |
| `sail.SailboxExecutionError` | Exec or listener operation fails |
| `sail.SailboxExecAlreadyRunningError` | Another exec request is already active |
| `sail.SailboxExecRequestNotFoundError` | A durable exec request cannot be found |
| `sail.SailboxFunctionError` | A decorated Python function raised while running in a sailbox |
| `sail.SailboxFunctionSerializationError` | Function payload, runtime setup, or return-value serialization fails |
| `sail.SailboxTerminatedError` | The sailbox no longer exists on the worker |
| `sail.SailboxWorkerLostError` | The assigned worker was lost during exec wait |
| `sail.ImageBuildError` | Custom image build fails |
| `sail.VoyageError` | Voyage validation, buffering, or delivery fails |
| `sail.VoyageHTTPError` | Voyage API returns an HTTP error |
| `sail.VoyageNotFoundError` | Attaching to a Voyage fails because it is not visible to the API key |
| `sail.InferenceError` | Inference wrapper config or v0 feature validation fails |
| `sail.InferenceHTTPError` | Inference endpoint returns an HTTP error |
