# Building a tool-calling agent
Source: https://docs.sailresearch.com/agents
Multi-turn tool-use conversations with the Sail API
Sail supports tool calling through the Responses API, letting you build agents that call external tools and reason over the results across multiple turns.
## How it works
1. Send a user message along with **tool definitions** to `/v1/responses`.
2. The model may return one or more `function_call` items instead of (or alongside) text.
3. Execute the tools locally, then send the results back as `function_call_output` items in a new request, together with the full conversation history.
4. Repeat until the model responds with text only.
Each request includes the entire conversation so far. Append `response.output` items directly to your conversation list (they are valid input items with no conversion needed), then append the `function_call_output` results.
## Full example: multi-turn weather agent
This example uses `zai-org/GLM-5.3` to build a two-turn conversation where the model calls a weather tool and then answers a follow-up question using context from the first turn.
```python theme={null}
import json
import time
from openai import OpenAI
client = OpenAI(
base_url="https://api.sailresearch.com/v1",
api_key="YOUR_SAIL_API_KEY",
)
MODEL = "zai-org/GLM-5.3"
TOOLS = [
{
"type": "function",
"name": "get_weather",
"description": "Get the current weather for a location.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state, e.g. San Francisco, CA",
}
},
"required": ["location"],
"additionalProperties": False,
},
"strict": True,
},
]
TOOL_DISPATCH = {"get_weather": lambda location: '{"temperature": "62°F", "condition": "Foggy"}'}
def poll(response, timeout=300):
start = time.time()
terminal_statuses = ("completed", "incomplete", "failed", "cancelled")
while response.status not in terminal_statuses:
if time.time() - start > timeout:
raise TimeoutError(f"{response.id} did not complete within {timeout}s")
time.sleep(2)
response = client.responses.retrieve(response.id)
if response.status in ("failed", "cancelled"):
raise RuntimeError(f"{response.id} status: {response.status}")
return response
def agent_turn(conversation, user_message):
"""Send a user message and loop until the model stops calling tools."""
conversation.append({"role": "user", "content": user_message})
while True:
response = client.responses.create(
model=MODEL,
input=conversation,
tools=TOOLS,
max_output_tokens=4096,
background=True,
)
response = poll(response)
# Incomplete is terminal, but content-filtered output is not a turn result.
if response.status == "incomplete":
reason = response.incomplete_details.reason
if reason == "content_filter":
raise RuntimeError("Response stopped by a content filter")
if reason != "max_output_tokens":
raise RuntimeError(f"Response incomplete: {reason}")
# Token-limited responses preserve partial output for the caller.
return response
tool_calls = [
item for item in (response.output or [])
if getattr(item, "type", None) == "function_call"
]
conversation.extend(response.output)
if not tool_calls:
return response
for call in tool_calls:
args = json.loads(call.arguments)
output = TOOL_DISPATCH[call.name](**args)
conversation.append(
{"type": "function_call_output", "call_id": call.call_id, "output": output}
)
conversation = []
# Turn 1: triggers a get_weather tool call, then the model summarizes the result
response = agent_turn(conversation, "What's the weather in San Francisco?")
print("Turn 1:", response.output_text)
# Turn 2: follow-up reuses conversation context
response = agent_turn(conversation, "How about New York, warmer or colder?")
print("Turn 2:", response.output_text)
```
### What happens under the hood
1. **Turn 1**: the model receives the user question plus the tool definition. It calls `get_weather` for San Francisco. After we send the tool result back, a second request is made and the model produces a text summary.
2. **Turn 2**: the full conversation (including Turn 1's tool call and result) is sent again. The model calls `get_weather` for New York, gets the result, and compares it with the San Francisco data it already has in context.
## Tips
* **Optionally choose a completion window for your agent loop.** `balanced` buys more tokens per dollar for autonomous work and `flex` offers the lowest prices for time-insensitive workloads. See [Completion windows](/completion-windows) for the cost and latency tradeoff.
* **`background=True`** is recommended for long-running agents and required for `flex`. Background mode avoids HTTP timeouts and lets you poll for completion.
* **Treat `incomplete` as terminal.** It is not an error and can contain partial
output and usage. Inspect `incomplete_details.reason` before deciding whether
to continue with the partial result or submit a new request. For a normalized
`max_output_tokens` reason, consider both the request's output limit and its
total input plus output context usage. Depending on which limit stopped the
response, reduce the input or adjust the output budget.
* **Do not assume every terminal response is `completed`.** Stop polling on
`completed`, `incomplete`, `failed`, or `cancelled`. Only `completed` and
`incomplete` can contain output to consume.
* **Send the full conversation** in each request. Include all prior messages, `response.output` items, and tool results. Output items from previous responses can be appended directly. No serialization or conversion is needed.
* **`strict: true`** on tool parameters enables structured output guarantees: the model's `arguments` JSON will always conform to your schema.
* **Parallel tool calls** are supported by default. The model may return multiple `function_call` items in a single response.
* **Add a per-request `Idempotency-Key` header** so retries will use the stored response instead of re-running inference and double charging. See [Idempotent Requests](/idempotency).
# AI Quickstart
Source: https://docs.sailresearch.com/ai-quickstart
Set up your coding agent with Sail's docs MCP and workflow skills for migration and agent building.
Sail works with the coding agent you already use. Claude Code and local Codex
sessions can install the full Sail plugin with its workflow skills. Other
tools can still use the documentation MCP server.
## Overview
Start using Sail by providing your agent with Sail's docs and the patterns to
migrate existing apps or build observable agents on Sail.
Connects your agent to Sail's documentation: the API, models, pricing, and
SDK. Served live from these docs, so it stays current.
Gives your agent the patterns to migrate existing apps and to build,
instrument, and debug observable agents on Sail.
## Step 1: Give your agent Sail's docs
Connect your tool to the Sail docs MCP server at
`https://docs.sailresearch.com/mcp`. It gives your agent a live connection to
these docs, so answers stay current:
* **Claude Code:** `claude mcp add --transport http sail-docs https://docs.sailresearch.com/mcp`
* **Codex:** `codex mcp add sail-docs --url https://docs.sailresearch.com/mcp`
* **Claude Desktop:** Settings → Connectors → Add custom connector, then paste the URL.
* **ChatGPT:** Where your workspace allows Developer mode and custom connectors, go to Settings → Apps & Connectors → Developer mode → Add new connector, then paste the URL.
* **Cursor:** Settings → MCP, then add the URL.
See the [MCP server guide](/mcp-server) for more clients and example
questions.
**Prefer not to install anything?** Every docs page has a copy button at the
top if you want to paste content directly into your agent interface. Or point
your tool at [llms.txt](https://docs.sailresearch.com/llms.txt) or
[llms-full.txt](https://docs.sailresearch.com/llms-full.txt).
## Step 2: Give your agent Sail's workflow skills
The canonical package is
[sailresearchco/sail-skills](https://github.com/sailresearchco/sail-skills).
It packages skills for migrating existing apps to Sail and building
observable agents. An agent run is recorded as a Voyage: a trace of
named agents, spans, and events, with every model call and Sailbox command
attributed to the right step.
| Skill | Use it when |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| `sail-migrate` | Migrate an existing app, agent, or workflow to Sail while preserving behavior. |
| `sail-voyage` | Build or instrument a Voyage with agents, spans, events, Sailbox commands, and terminal lifecycle. |
| `sail-inference-with-voyage` | Attribute Sail inference model calls to the active agent and span. |
| `sail-voyage-debugging` | Diagnose a Voyage that ran but appears incomplete or incorrect in the dashboard. |
| `sail-gpu-marketplace` | Allocate and use preemptible GPU compute or recover checkpointed work after an interruption. |
Install for your agent:
```text Claude Code theme={null}
/plugin marketplace add sailresearchco/sail-skills
/plugin install sail@sail
```
```bash Codex theme={null}
codex plugin marketplace add sailresearchco/sail-skills
codex plugin add sail@sail
```
Before you run generated SDK code, install `sail`. The Sail SDK can use a
credential stored by `sail auth login` on the same machine. For CI,
deployments, Sailbox guests, or third-party inference SDKs configured for
Sail, provide `SAIL_API_KEY` in that runtime environment.
## Next steps
**Already have an agent or workflow?** Install the skills above, then ask
your coding agent to "Migrate this app to Sail". The `sail-migrate` skill
guides the whole migration, including moving sandboxed execution to Sail.
Prefer not to install anything? Copy the migration prompt from
[Migrate to the Sail API](/migrate#copy-migration-prompt) instead.
**Starting fresh?** Use the `sail-voyage` skill to build your own observable AI
workflow. Ask your coding agent:
```text theme={null}
Build a small background research agent on Sail and make the whole run show
up as a trace I can open in the dashboard, each step, the model calls it
makes, and the sandbox commands it runs, attributed to the right part of the
workflow.
```
The `sail-voyage` skill takes it from there.
**Or** make a plain inference call first. See the [inference Quickstart](/quickstart).
# Find or create an app
Source: https://docs.sailresearch.com/api-reference/apps/find-or-create-an-app
/sailbox-openapi.json post /apps/find
Looks up an app by name and returns its id, which every Sailbox create needs. An app groups Sailboxes that belong to the same workload, and a listener allowlist that names an app lets every Sailbox in it through.
Set `mint_if_missing` to create the app when it does not exist yet. That makes this safe to call on every start.
Apps are managed on `https://api.sailresearch.com/v1`, not on the Sailbox base URL. The same API key works on both.
# List apps
Source: https://docs.sailresearch.com/api-reference/apps/list-apps
/sailbox-openapi.json get /apps
Returns every app in your organization, including apps with no Sailboxes yet.
Apps are managed on `https://api.sailresearch.com/v1`, not on the Sailbox base URL. The same API key works on both.
# Create a batch
Source: https://docs.sailresearch.com/api-reference/batches-api/create-a-batch
/openapi.json post /batches
Submit a batch of requests for asynchronous processing.
# Get batch request result
Source: https://docs.sailresearch.com/api-reference/batches-api/get-batch-request-result
/openapi.json get /batches/{batch_id}/{custom_id}
Retrieve the result of a specific request within a batch by its custom_id.
# Get batch status
Source: https://docs.sailresearch.com/api-reference/batches-api/get-batch-status
/openapi.json get /batches/{batch_id}
Retrieve the current status of a batch.
# List batches
Source: https://docs.sailresearch.com/api-reference/batches-api/list-batches
/openapi.json get /batches
List batches with optional pagination.
# Create a chat completion
Source: https://docs.sailresearch.com/api-reference/chat-completions-api/create-a-chat-completion
/openapi.json post /chat/completions
OpenAI-compatible Chat Completions endpoint. Supports streaming via stream: true, which returns a Server-Sent Events stream of chat.completion.chunk objects.
# Checkpoint a Sailbox
Source: https://docs.sailresearch.com/api-reference/checkpoints/checkpoint-a-sailbox
/sailbox-openapi.json post /sailboxes/{sailbox_id}/checkpoint
Saves the Sailbox's filesystem and memory as a checkpoint you can start new Sailboxes from. The Sailbox is left as it was, so a running Sailbox keeps running and a sleeping or paused one stays down.
# Start a Sailbox from a checkpoint
Source: https://docs.sailresearch.com/api-reference/checkpoints/start-a-sailbox-from-a-checkpoint
/sailbox-openapi.json post /sailboxes/from_checkpoint
Creates a new Sailbox from a checkpoint you took earlier. It comes up with the memory and filesystem saved in the checkpoint, so processes the original was running carry on there.
Commands run through an SDK or the CLI stop there. Everything they had written by then is on the filesystem, and one started with their background option keeps running. Start whatever else you need running again. A Sailbox that mounts a volume always comes up cold, with the disk intact and nothing running, and any Sailbox can come up cold, so write code that expects it.
Volumes mounted on the original Sailbox are mounted on the new one at the same paths. They are the same volumes, not copies, so both Sailboxes read and write the same files. The new Sailbox keeps the original's network policy.
# Cancel an exec
Source: https://docs.sailresearch.com/api-reference/exec/cancel-an-exec
/sailbox-openapi.json post /sailboxes/{sailbox_id}/exec/{exec_id}/cancel
Sends SIGINT by default. Set `force` to send SIGKILL. Repeated requests are safe.
# Resize an exec PTY
Source: https://docs.sailresearch.com/api-reference/exec/resize-an-exec-pty
/sailbox-openapi.json post /sailboxes/{sailbox_id}/exec/{exec_id}/resize
Sets the latest terminal dimensions. An unknown or non-PTY exec is a no-op.
# Resync an exec PTY
Source: https://docs.sailresearch.com/api-reference/exec/resync-an-exec-pty
/sailbox-openapi.json post /sailboxes/{sailbox_id}/exec/{exec_id}/resync
Requests a terminal repaint on the live exec stream. An unknown or non-PTY exec is a no-op.
# Run a command
Source: https://docs.sailresearch.com/api-reference/exec/run-a-command
/sailbox-openapi.json post /sailboxes/{sailbox_id}/exec
Starts a command or reconnects to one with the same idempotency key. The response is newline-delimited JSON. Its first event is `started`. Output `data` and terminal repaint `data` are base64. The highest stdout and stderr `seq` values are reconnect cursors. `exit` is terminal. A `heartbeat` can appear after 30 seconds without another event.
# Wait for an exec
Source: https://docs.sailresearch.com/api-reference/exec/wait-for-an-exec
/sailbox-openapi.json post /sailboxes/{sailbox_id}/exec/{exec_id}/wait
Waits for the guest's authoritative result. It can wake a sleeping Sailbox.
# Write exec stdin
Source: https://docs.sailresearch.com/api-reference/exec/write-exec-stdin
/sailbox-openapi.json put /sailboxes/{sailbox_id}/exec/{exec_id}/stdin
Writes raw bytes at an absolute offset. Retries can overlap bytes already accepted.
# Read a file
Source: https://docs.sailresearch.com/api-reference/files/read-a-file
/sailbox-openapi.json get /sailboxes/{sailbox_id}/files
Streams one regular file without buffering it in the service.
# Write a file
Source: https://docs.sailresearch.com/api-reference/files/write-a-file
/sailbox-openapi.json put /sailboxes/{sailbox_id}/files
Streams raw bytes into one regular file. A complete retry safely replaces the target.
# Attach an HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/attach-an-http-policy
/sailbox-openapi.json put /sailboxes/{sailbox_id}/http-policy
Attaches a policy to a Sailbox, replacing any policy already attached. New HTTPS connections use the new policy. A connection that is already open keeps its previous policy until it closes.
# Clear a Sailbox's HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/clear-a-sailboxs-http-policy
/sailbox-openapi.json delete /sailboxes/{sailbox_id}/http-policy
Removes the attached policy. This succeeds when no policy is attached. New HTTPS connections have no policy. A connection that is already open keeps its previous policy until it closes.
# Create an HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/create-an-http-policy
/sailbox-openapi.json post /http-policies
Creates a named policy for your organization. Every secret named in the document must already exist. A policy document cannot change after creation, but its name can. Sail saves a normalized form of the document (for example, host names are lowercased and defaults are filled in), so reading the policy back can return a different shape with the same behavior.
# Delete an HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/delete-an-http-policy
/sailbox-openapi.json delete /http-policies/{policy_id}
Deletes a policy. Clear it from every Sailbox first.
# Get a Sailbox's HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/get-a-sailboxs-http-policy
/sailbox-openapi.json get /sailboxes/{sailbox_id}/http-policy
Returns the HTTP policy currently attached to a Sailbox.
# Get an HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/get-an-http-policy
/sailbox-openapi.json get /http-policies/{policy_id}
Returns one policy, including its document.
# List HTTP policies
Source: https://docs.sailresearch.com/api-reference/http-policies/list-http-policies
/sailbox-openapi.json get /http-policies
Returns a page of your organization's HTTP policies. Each item includes usage counts and secret names, but not the policy document.
# Rename an HTTP policy
Source: https://docs.sailresearch.com/api-reference/http-policies/rename-an-http-policy
/sailbox-openapi.json patch /http-policies/{policy_id}
Changes the policy's display name. Its document stays the same.
# whoami
Source: https://docs.sailresearch.com/api-reference/identity/whoami
/sailbox-openapi.json get /whoami
Returns the organization behind the API key, and the user when the key is user-scoped. Use it to tell your own Sailboxes apart from a teammate's by comparing `user_id` against `created_by_user_id`.
# Create a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/create-a-sailbox
/sailbox-openapi.json post /sailboxes
Creates a Sailbox and waits for startup to finish, which can take a few minutes. `image` has to describe an image that is ready to boot: a base image on its own, or one already built from that same spec through an SDK.
Send an `Idempotency-Key` so a network retry replays this answer instead of starting a second Sailbox. A server error can still arrive once the Sailbox exists, so read [Retrying safely](/sailboxes-http-api#retrying-safely) before you retry one.
# List Sailboxes
Source: https://docs.sailresearch.com/api-reference/lifecycle/list-sailboxes
/sailbox-openapi.json get /sailboxes
Returns Sailboxes in your organization, most recently active first. Terminated Sailboxes stay in the list, so filter by `status` if you only want live ones.
# Pause a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/pause-a-sailbox
/sailbox-openapi.json post /sailboxes/{sailbox_id}/pause
Saves the Sailbox's memory and filesystem and stops charging for compute. Published listeners stop answering. Call resume to bring it back with its processes intact. A Sailbox that mounts a volume, or that has an upgrade waiting, comes back cold instead, with the disk intact and nothing running. Any Sailbox can come back cold, so write code that expects it.
# Resume a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/resume-a-sailbox
/sailbox-openapi.json post /sailboxes/{sailbox_id}/resume
Brings a paused or sleeping Sailbox back with its memory and running processes intact.
A Sailbox that mounts a volume, or that has an upgrade waiting, comes back cold instead, with the disk intact and nothing running. Any Sailbox can come back cold, so write code that expects it.
The call returns 200 whether or not the Sailbox came back, so read `resume_state` before you use it. `running` and `already_running` both mean it is ready. `terminal_unavailable` means it can never resume, and `error_message` says why.
# Retrieve a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/retrieve-a-sailbox
/sailbox-openapi.json get /sailboxes/{sailbox_id}
Returns one Sailbox, including its latest observed resource usage.
# Schedule a wake
Source: https://docs.sailresearch.com/api-reference/lifecycle/schedule-a-wake
/sailbox-openapi.json post /sailboxes/{sailbox_id}/wake_at
Sets the time a sleeping Sailbox comes back on its own. Give a time in the future.
Sending this to a running Sailbox records the wake without putting it to sleep. Sleep it separately and it comes back at the scheduled time.
A sooner wake replaces one already scheduled. A later one leaves the existing wake in place, so read `wake_at` in the response to see which one is in effect.
A Sailbox that is paused, terminated, or in a failed state takes no scheduled wake.
# Set the automatic-sleep preference
Source: https://docs.sailresearch.com/api-reference/lifecycle/set-the-automatic-sleep-preference
/sailbox-openapi.json post /sailboxes/{sailbox_id}/auto_sleep
Replaces the Sailbox's automatic-sleep preference: whether Sail may sleep it automatically, and how long Sail waits first. The whole preference is replaced in one call, so send the complete shape you want.
An explicit idle window replaces Sail's default and can make automatic sleep happen sooner or later. The window only controls when Sail may consider sleeping the Sailbox; it still sleeps only when fully idle. Your own `sleep`, `pause`, `resume`, and scheduled wakes work the same whatever it says.
# Sleep a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/sleep-a-sailbox
/sailbox-openapi.json post /sailboxes/{sailbox_id}/sleep
Saves the Sailbox's memory and filesystem, stops charging for compute, and lets the Sailbox wake by itself when traffic arrives on a published listener. That first connection waits while the Sailbox comes back.
It comes back with its processes intact. A Sailbox that mounts a volume, or that has an upgrade waiting, comes back cold instead, with the disk intact and nothing running. Any Sailbox can come back cold, so write code that expects it.
# Terminate a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/terminate-a-sailbox
/sailbox-openapi.json post /sailboxes/{sailbox_id}/terminate
Shuts the Sailbox down for good and releases its resources. Billing stops. The Sailbox cannot be restarted, and anything not written to a volume or a checkpoint is gone. Terminating an already terminated Sailbox succeeds.
# Upgrade a Sailbox
Source: https://docs.sailresearch.com/api-reference/lifecycle/upgrade-a-sailbox
/sailbox-openapi.json post /sailboxes/{sailbox_id}/upgrade
Moves the Sailbox onto the current Sail runtime. A running Sailbox restarts to pick it up, which stops running processes and clears memory, so pick the moment yourself. A paused or sleeping Sailbox records the upgrade and applies it on its next wake, and the response has `applied: false`. That wake is a cold start too, so its memory and running processes do not survive it. A Sailbox already on the current runtime answers `applied: true` and is left as it is, running or not. A Sailbox left past its upgrade deadline can be restarted for you.
# Count tokens for an Anthropic message
Source: https://docs.sailresearch.com/api-reference/messages-api/count-tokens-for-an-anthropic-message
/openapi.json post /messages/count_tokens
Counts the input tokens a create-message request would consume, without running the model.
# Create an Anthropic message
Source: https://docs.sailresearch.com/api-reference/messages-api/create-an-anthropic-message
/openapi.json post /messages
Anthropic-compatible Messages endpoint supporting system prompts, tool calling, and Anthropic SSE framing after generation completes.
# Retrieve a message
Source: https://docs.sailresearch.com/api-reference/messages-api/retrieve-a-message
/openapi.json get /messages/{messageID}
Returns the current status of a queued message. Completed responses include the Anthropic message fields. Use this operation with the X-Sail-Message-Id returned after a non-streaming timeout.
# List supported models
Source: https://docs.sailresearch.com/api-reference/models-api/list-supported-models
/openapi.json get /models
# Add a custom domain
Source: https://docs.sailresearch.com/api-reference/networking/add-a-custom-domain
/sailbox-openapi.json post /sailboxes/{sailbox_id}/domains
Serves a published `http` port under a hostname you own.
Point the hostname at the target first, with a CNAME record. A name at the root of a domain cannot hold a CNAME, so route it with your provider's ALIAS or ANAME record and add a TXT record at `_sail-domains.` plus the hostname carrying the same target. The TXT record proves the hostname is yours; it does not route on its own. Read the target from `GET /custom-domains`.
Sail checks DNS while handling this request, so create the record and give it time to propagate before calling. Until it resolves the request answers 400, which is worth sending again once the record is live. Turn off any proxying or acceleration your DNS provider applies to the record: that resolves the hostname to the provider rather than to the target, and no amount of waiting clears it.
The port has to be published as an `http` listener already. The TLS certificate is obtained on the first HTTPS request to the hostname, so that one request can take up to a minute; the rest are served straight away, and renewal is automatic.
Registering a hostname that already serves the same port returns what is there, so the call is safe to repeat. Registering one of your own hostnames against a different Sailbox moves it, with no need to remove it first.
This works on a running, paused, or sleeping Sailbox. A request to the hostname wakes a sleeping one.
# Get custom-domain DNS targets
Source: https://docs.sailresearch.com/api-reference/networking/get-custom-domain-dns-targets
/sailbox-openapi.json get /custom-domains
Returns the two hostnames to use in custom-domain DNS. Point an attached hostname or wildcard CNAME at `cname_target`. Point the `_acme-challenge` CNAME at `acme_challenge_target` to use one wildcard certificate for direct subdomains. Both targets are the same for every domain in your organization.
Registering a hostname needs its DNS to resolve to `cname_target`, so this is the first call to make.
# Get headers that identify a Sailbox
Source: https://docs.sailresearch.com/api-reference/networking/get-headers-that-identify-a-sailbox
/sailbox-openapi.json get /sailboxes/{sailbox_id}/ingress-auth
Returns headers you attach to a request so it is recognized as coming from this Sailbox. Use them to reach a listener whose allowlist names an app when your code runs outside a Sailbox.
The headers are a credential, so they are only issued while the Sailbox can still run. One that has stopped for good, whether you terminated it or it failed, no longer has an identity to hand out. Paused and sleeping Sailboxes still have one.
# List custom domains
Source: https://docs.sailresearch.com/api-reference/networking/list-custom-domains
/sailbox-openapi.json get /sailboxes/{sailbox_id}/domains
Returns every hostname of your own that serves this Sailbox, and the target to point DNS at. The target is the same across your organization, so a caller registering its first hostname can read it here first.
# List published ports
Source: https://docs.sailresearch.com/api-reference/networking/list-published-ports
/sailbox-openapi.json get /sailboxes/{sailbox_id}/listeners
Returns every port the Sailbox publishes, with the address to reach each one. This works on a paused or sleeping Sailbox and does not wake it.
# Publish a port
Source: https://docs.sailresearch.com/api-reference/networking/publish-a-port
/sailbox-openapi.json post /sailboxes/{sailbox_id}/listeners
Publishes a port from inside the Sailbox so it can be reached from outside. This works on a running Sailbox without restarting it, and on a paused or sleeping one.
An `http` listener gets an HTTPS URL. A `tcp` listener gets a host and port to dial, which is what SSH and database clients need.
Without an `allowlist` the port is reachable by anyone who has the address, so set one unless you mean to publish to the internet.
Publishing a port that is already published under the same protocol sets its `allowlist` to what you send, so send the whole list every time. Sending none clears the restriction and reopens the port.
# Remove a custom domain
Source: https://docs.sailresearch.com/api-reference/networking/remove-a-custom-domain
/sailbox-openapi.json delete /sailboxes/{sailbox_id}/domains/{domain}
Stops serving the hostname and releases its certificate. The published port keeps serving its own address, and the body echoes the registration that was removed.
Delete the DNS record afterwards. One left pointing at the target resolves to nothing Sail serves.
# Retrieve a published port
Source: https://docs.sailresearch.com/api-reference/networking/retrieve-a-published-port
/sailbox-openapi.json get /sailboxes/{sailbox_id}/listeners/{guest_port}
Returns one published port and the address to reach it.
# Unpublish a port
Source: https://docs.sailresearch.com/api-reference/networking/unpublish-a-port
/sailbox-openapi.json delete /sailboxes/{sailbox_id}/listeners/{guest_port}
Stops serving traffic on a published port. This works whether the Sailbox is running, paused, or sleeping, and does not wake it.
Any custom domain serving an `http` port goes with it, so re-publishing the port leaves the hostnames to register again.
Sail keeps an unpublished `tcp` address inside your organization and can reuse it for another of your Sailboxes. Publish the port again and read the new address out of the response.
# Create a response
Source: https://docs.sailresearch.com/api-reference/responses-api/create-a-response
/openapi.json post /responses
Creates an OpenAI Responses API task. Returns 202 when background=true, otherwise returns 200 after completion. Foreground stream=true requests return OpenAI Responses Server-Sent Events.
# Retrieve a response
Source: https://docs.sailresearch.com/api-reference/responses-api/retrieve-a-response
/openapi.json get /responses/{response_id}
# Delete a secret
Source: https://docs.sailresearch.com/api-reference/secrets/delete-a-secret
/sailbox-openapi.json delete /secrets/{name}
Deletes a secret from your organization. Delete every HTTP policy that uses it first.
# Get a secret
Source: https://docs.sailresearch.com/api-reference/secrets/get-a-secret
/sailbox-openapi.json get /secrets/{name}
Returns a secret's name and timestamps, but never its value.
# List policies using a secret
Source: https://docs.sailresearch.com/api-reference/secrets/list-policies-using-a-secret
/sailbox-openapi.json get /secrets/{name}/references
Returns the HTTP policies that refer to this secret and must be deleted before the secret can be deleted.
# List secrets
Source: https://docs.sailresearch.com/api-reference/secrets/list-secrets
/sailbox-openapi.json get /secrets
Returns the name and timestamps for every secret in your organization. Sail never returns the stored values.
# Set a secret
Source: https://docs.sailresearch.com/api-reference/secrets/set-a-secret
/sailbox-openapi.json put /secrets/{name}
Creates a secret for your organization, or replaces its value when the name already exists. Sail never returns the stored value. After this call succeeds, the next matching request from any Sailbox using this organization secret gets the new value.
# Get your SSH certificate authority
Source: https://docs.sailresearch.com/api-reference/ssh-access/get-your-ssh-certificate-authority
/sailbox-openapi.json get /ssh/ca
Returns the public key your organization's Sailboxes trust for SSH. It is created the first time you ask for it.
A Sailbox starts trusting it when you turn SSH on in that Sailbox, which needs an SDK or the CLI.
# Issue an SSH certificate
Source: https://docs.sailresearch.com/api-reference/ssh-access/issue-an-ssh-certificate
/sailbox-openapi.json post /ssh/certificate
Signs your SSH public key so you can connect to a Sailbox that has SSH turned on. Save the returned certificate next to your private key as `-cert.pub` and `ssh` presents it automatically. Certificates are short-lived, so ask for a fresh one rather than storing it long term.
Turning SSH on inside a Sailbox needs an SDK or the CLI, and only has to happen once per Sailbox. After that, publish port 22 as a `tcp` listener and dial the host and port it returns. See [Networking](/sailboxes-networking) for the full walkthrough.
# Get resource usage over time
Source: https://docs.sailresearch.com/api-reference/usage/get-resource-usage-over-time
/sailbox-openapi.json get /sailboxes/{sailbox_id}/metrics
Returns CPU, memory, and disk usage for one Sailbox as a time series.
# Get Sailbox spend
Source: https://docs.sailresearch.com/api-reference/usage/get-sailbox-spend
/sailbox-openapi.json get /sailboxes/spend
Returns Sailbox usage and estimated cost for your organization over a time window, with a per-Sailbox breakdown. Defaults to the current UTC calendar month up to now. Costs are reported in billionths of a US dollar, and the active portion is an estimate that settles when the Sailbox stops.
# Create a volume
Source: https://docs.sailresearch.com/api-reference/volumes/create-a-volume
/sailbox-openapi.json post /sailbox-volumes
Creates a volume you can mount into Sailboxes. Volume names are unique within an organization, so creating a name that already exists returns the existing volume rather than failing. That makes this safe to call on every start.
# Delete a volume
Source: https://docs.sailresearch.com/api-reference/volumes/delete-a-volume
/sailbox-openapi.json delete /sailbox-volumes/{volume_id}
Deletes a volume. Nothing can mount it again and its contents are permanently unreachable. Terminate every Sailbox that still mounts it first, because a paused or sleeping Sailbox expects the volume when it wakes. A Sailbox that is already shutting down, or that failed and cannot be restored, no longer holds it.
# List volumes
Source: https://docs.sailresearch.com/api-reference/volumes/list-volumes
/sailbox-openapi.json get /sailbox-volumes
Returns the volumes in your organization, newest first.
Pass `name` to look one up without creating it. The page holds that volume if it exists and is empty if it does not.
# Completion windows
Source: https://docs.sailresearch.com/completion-windows
Trade off latency for lower token prices
Completion windows let you express how long your requests can wait, giving Sail room to increase cost efficiency.
Sail serves low-latency inference by default for core models, at lower cost than traditional inference providers. You can opt in to the `balanced` or `flex` completion windows to cut token costs drastically, for work that is latency tolerant.
Completion windows can be specified on a per-request basis. Long-horizon agents can make use of all three depending on the task at hand.
## Completion windows at a glance
| Window | Scheduling | Typical use case | Price vs. traditional inference providers |
| ---------- | ----------------------- | ---------------------------------------------------------- | ----------------------------------------- |
| `asap` | Low-latency serving | Interactive UIs, human-in-the-loop | \~5-35% less |
| `balanced` | Wider scheduling window | Background agents, subagents, and pipelines; parallel work | \~45-65% less |
| `flex` | Best-effort scheduling | Batch processing, evals, offline | \~60-80% less |
Not every core model supports every completion window yet. Check [Pricing](/pricing) for current per-model availability.
## Completion window details
### `asap`
`asap` is the default, low-latency path for Sail's core models. Use it when a person is
waiting or when one agent turn blocks the next.
For GLM-5.3 FP8 requests using `asap`, Sail targets:
* Average time to first token (TTFT) under 10 seconds
* P90 TTFT under `10 + (input tokens / 1,000)` seconds
* Average generation speed of about 20 to 30 tokens per second (TPS)
These operating targets apply only to GLM-5.3 FP8 on `asap` and are not an SLA.
### `balanced`
`balanced` gives Sail more time to place work on efficient capacity, greatly
lowering token costs for long-horizon background agents and pipelines.
This tier enables you to run many background agents, taking on very large
tasks, at reasonable cost.
### `flex`
`flex` gives Sail the widest scheduling window and offers the lowest available
token prices. It has no TTFT or TPS target. Common use cases include batch jobs, evals, and offline processing.
For individual requests, `flex` requires the [Responses API](/support#responses-api)
with `background=True`. [Batch API](/support#batch-api) requests can also use
`flex`.
flex with Chat Completions and Messages>}>
Chat Completions and Messages technically accept `flex` as a completion window,
but both wait synchronously and may time out before the request finishes.
Therefore, we recommend using the Responses API or Batch API instead.
`flex` requests can spend longer queued than other completion windows. Once Sail
accepts a request, Sail keeps processing it unless it reaches a terminal status.
A client-side polling timeout does not cancel its background work. Keep polling
the response instead of creating a duplicate request. If submission fails
ambiguously, retry with the same `Idempotency-Key` so Sail returns the existing
work instead of creating a duplicate. If the response reaches `failed`, inspect
its error before deciding whether to submit a new request.
## How to set completion windows
Set `metadata.completion_window` to `asap`, `balanced`, or `flex`:
```bash Responses theme={null}
curl https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "Explain the key ideas behind transformers.",
"metadata": {
"completion_window": "balanced"
}
}'
```
```bash Chat theme={null}
curl https://api.sailresearch.com/v1/chat/completions \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"messages": [
{"role": "user", "content": "Explain the key ideas behind transformers."}
],
"metadata": {
"completion_window": "balanced"
}
}'
```
```bash Messages theme={null}
curl https://api.sailresearch.com/v1/messages \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Explain the key ideas behind transformers."}
],
"metadata": {
"completion_window": "balanced"
}
}'
```
```bash Batch theme={null}
curl https://api.sailresearch.com/v1/batches \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/responses",
"label": "transformers",
"requests": [
{
"custom_id": "transformers-1",
"params": {
"model": "zai-org/GLM-5.3",
"input": "Explain the key ideas behind transformers.",
"metadata": {
"completion_window": "balanced"
}
}
}
]
}'
```
For `flex`, set `background=true` when using the [Responses API](/support#responses-api). For reliable `flex` requests, use Responses API background mode or Batch instead of waiting synchronously through Chat Completions or Messages.
```bash Responses highlight={7} theme={null}
curl https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "Explain the key ideas behind transformers.",
"background": true,
"metadata": {
"completion_window": "flex"
}
}'
```
```bash Batch theme={null}
curl https://api.sailresearch.com/v1/batches \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/responses",
"label": "transformers",
"requests": [
{
"custom_id": "transformers-1",
"params": {
"model": "zai-org/GLM-5.3",
"input": "Explain the key ideas behind transformers.",
"metadata": {
"completion_window": "flex"
}
}
}
]
}'
```
### Default behavior
For inference requests for [core models](/models), the default behavior (when no `completion_window` is specified) is low-latency inference (`asap`).
The exceptions to this default behavior are:
* Batch inference requests, which default to `balanced` for core models.
* LoRA requests, which default to `balanced` for core models.
For flex-only models, the default (and only) behavior is `flex` for all inference requests, including Batch and LoRA. Flex-only models require background requests (see examples above).
# Data Processing Agreement
Source: https://docs.sailresearch.com/dpa
How Sail securely handles customer data for **inference**
This Data Processing Agreement (“**DPA**”) forms part of the Self-Service Terms of Service or other written agreement between Sail and Customer for the provision of the Services (“**Agreement**”). Unless otherwise defined in this DPA, capitalized terms used in this DPA will have the meaning given to them in the Agreement.
For more about how Sail secures customer data, see Sail’s [Trust Center](https://trust.sailresearch.com).
***
## 1. Introduction
1. **Roles of Parties.** For the purposes of the Agreement, the Parties agree that (a) Customer is the “controller” and “business” (as such terms are defined under applicable Data Protection Law) and (b) Sail is the “processor” and “service provider” (as such terms are defined under applicable Data Protection Law) with respect to the “Processing” (as such term is defined under applicable Data Protection Law) of Customer Data that constitutes “personal data,” “personal information,” “personally identifiable information,” or any analogous term under applicable Data Protection Law (“**Customer Personal Data**”).
2. **Order of Precedence.** If there is any conflict or inconsistency between the terms of the Agreement or this Data Processing Addendum (“**DPA**”), the terms of this DPA shall control to the extent of such conflict or inconsistency.
## 2. Customer Personal Data
1. **Scope of Processing.** The subject matter, nature and purpose of Sail’s Processing of Customer Personal Data, the types of Customer Personal Data Processed by Sail, and categories of applicable data subjects are set out in Annex I.
2. **Customer Personal Data Processing.** Sail will Process Customer Personal Data to provide the Services and in accordance with Customer’s documented instructions as set forth in this DPA, the Agreement, or otherwise communicated in writing by Customer to Sail provided that such instructions are consistent with this DPA and the Agreement (“**Documented Instructions**”). Unless prohibited by applicable Law, Sail will inform Customer if Sail is subject to a legal obligation that requires Sail to Process Customer Personal Data in contravention of Customer’s Documented Instructions.
3. **Documented Instructions.** Customer will ensure that its Documented Instructions comply with applicable privacy, data protection, and cybersecurity law (“**Data Protection Law**”) and is responsible for determining whether the Services are appropriate for the Processing of Customer Personal Data.
4. **Zero Training Commitment.** Sail will not use Customer Data to train, fine-tune, or improve any artificial intelligence or machine learning models, including any models operated by Sail or accessible through the Services without prior written consent of Customer (“**Zero Training Commitment**”). Sail contractually extends this prohibition to its model provider Subprocessors for Customer Data processed through their APIs. The Zero Training Commitment constitutes a material contractual obligation of Sail under this DPA.
5. **CCPA.** Sail will not (a) “sell” or “share” (as such terms are defined in the California Consumer Privacy Act) Customer Personal Data, (b) retain, use, or disclose Customer Personal Data for any purpose other than in accordance with the Documented Instructions, (c) retain, use, or disclose Customer Personal Data outside of the direct business relationship between Customer and Sail, nor (d) except as otherwise permitted under applicable Data Protection Law, combine Customer Personal Data with personal data that Sail receives from or on behalf of any third party.
## 3. Personnel
1. **Personnel.** Sail will ensure that all personnel authorized to Process Customer Personal Data are subject to an appropriate duty of confidentiality.
## 4. Subprocessors
1. **Authorization.** Customer provides general authorization for Sail to engage the subprocessors as described at [https://trust.sailresearch.com/?tab=subprocessors](https://trust.sailresearch.com/?tab=subprocessors) (“**Subprocessors**”). Sail will (a) enter into an agreement with each Subprocessor that imposes data protection obligations that are substantially as protective as Sail’s obligations under this DPA to the extent applicable to the nature of the services provided by such Subprocessor and (b) remain responsible for the acts and omissions of the Subprocessors’ Processing of Customer Personal Data under this DPA.
2. **Notice of New Subprocessors.** Sail shall make available on its Subprocessors webpage a mechanism to subscribe to notifications of new Subprocessors, and Sail will provide reasonable advance notice prior to appointing any new Subprocessor through such mechanism. Customer may object to the appointment of such new Subprocessor within 15 days of the date of such notice on reasonable privacy or security grounds by providing Sail written notice of its objection. In the event that Customer objects to Sail’s appointment of a new Subprocessor, Customer and Sail will work together in good faith to address any such objection.
## 5. Assistance
1. **Data Subject Rights.** Sail will (a) promptly forward to Customer any request it receives from “data subjects” or “consumers” (as such terms are defined under applicable Data Protection Law) to exercise their rights under applicable Data Protection Law relating to Customer Personal Data, (b) advise such data subjects and consumers to submit such requests directly to Customer, and (c) provide Customer with reasonable assistance as necessary for Customer to fulfil its obligations under applicable Data Protection Laws in responding to such requests.
2. **Cooperation.** Taking into account the nature of the Processing, Sail will provide Customer with reasonable assistance as necessary for Customer to fulfil its obligations under applicable Data Protection Laws, including to conduct data protection impact assessments and consultations with regulatory authorities. Sail may charge Customer a reasonable fee for such assistance under this Section 5.2.
## 6. Security
1. **Security Measures.** Sail will maintain reasonable and appropriate security measures designed to protect Customer Data in its possession and control as described on Sail’s Trust Center at [https://trust.sailresearch.com](https://trust.sailresearch.com) (“**Security Measures**”). Customer acknowledges that the Security Measures provide an appropriate level of security for the risks of the Processing of Customer Personal Data under the Agreement. Sail may update or modify the Security Measures provided that such updates and modifications do not materially decrease the overall security of the Services.
2. **Security Incident.** Sail will notify Customer without undue delay and in any case within 72 hours after becoming aware of any unauthorized access to, or disclosure or use of, Customer Personal Data (“**Security Incident**”). Sail will use reasonable efforts to investigate the Security Incident and mitigate the effects and remediate the causes of the Security Incident. Sail will assist Customer in complying with Customer’s obligations under applicable Data Protection Law by making reasonable efforts to provide Customer with information relating to the Security Incident.
3. **Audits.** Upon Customer’s written request, no more than once every 12 months, Sail will permit Customer to audit Sail’s controls applicable to its Processing of Customer Personal Data and compliance with this DPA (“**Audit**”), provided that such Audit is conducted at Customer’s sole cost, during normal business hours, in a manner that causes minimal disruption, and in accordance with mutually agreed upon scope and terms.
## 7. International Data Transfers
1. **Data Transfers.** Customer authorizes Sail to conduct transfers of Customer Personal Data to countries deemed to have an adequate level of data protection by the European Commission or the applicable competent regulatory authority on the basis of adequate safeguards in accordance with Data Protection Law or pursuant to (a) the contractual clauses annexed to the European Commission’s Implementing Decision 2021/914 of 4 June 2021 on standard contractual clauses for the transfer of Personal Data to third countries pursuant to Regulation (EU) 2016/679 of the European Parliament and of the Council, as amended, superseded, or replaced from time to time (“**EU SCCs**”) or (b) the International Data Transfer Addendum to the EU Commission Standard Contractual Clauses issued by the UK Information Commissioner, Version B1.0, in force 21 March 2022, as amended, superseded or replaced from time to time (“**UK Addendum**”).
2. **EU Data Transfers.** For transfers of Customer Personal Data from the European Union, Sail and Customer conclude Module 2 (controller-to-processor) of the EU SCCs and, if Customer is a processor on behalf of a third-party controller, Module 3 (Processor-to-Subprocessor) of the EU SCCs, which are incorporated herein and completed as follows: (a) the “data exporter” is Customer; (b) the “data importer” is Sail; (c) the optional docking clause in Clause 7 is implemented; (d) option 2 of Clause 9(a) is implemented and the time period therein is specified in Section 3.2; (e) the optional redress clause in Clause 11(a) is struck; (f) option 1 in Clause 17 is implemented; (g) the governing law is the law of Ireland and the courts in Clause 18(b) are the Courts of Dublin, Ireland; and (h) Annex I and Annex II to Module 2 and 3 of the EU SCCs are Schedule I and the Security Measures, respectively. For transfers of Customer Personal Data from Switzerland, any dispute arising from these EU SCCs relating to Swiss Data Protection Laws will be resolved by the courts of Switzerland and data subjects who have their habitual residence in Switzerland may bring claims under the EU SCCs before the courts of Switzerland.
3. **UK Data Transfers.** For transfers of Customer Personal Data from the United Kingdom, Sail and Customer conclude the UK Addendum, which is incorporated herein and completed as follows: (a) in Table 1, the “Exporter” is Customer and the “Importer” is Sail, their details are set forth in this DPA and the Agreement; (b) in Table 2, the first option is selected and the “Approved EU SCCs” are the EU SCCs referred to in Section 7.2; (c) in Table 3, Annexes 1 (A and B) and II to the “Approved EU SCCs” are Schedule I and the Security Measures respectively; and (d) in Table 4, both the “Importer” and the “Exporter” can terminate the UK Addendum.
## 8. Storage, Deletion, and Retention
1. **Persistent Storage.** Customer Data is stored only temporarily in Amazon S3 buckets (unless Customer chooses to use Customer-owned S3 buckets).
2. **Transient Processing.** All other Processing is transient in-memory only for the duration of the job.
3. **Automatic Deletion.** Sail’s production S3 buckets are configured with an automated deletion rule (implemented via S3 bucket lifecycle policy) that deletes Customer Data shortly after Processing. Precise deletion timing can vary in practice due to job retries, failures, or other operational conditions. Sail will not retain Customer Data for longer than 48 hours, except to the extent (a) required by Data Protection Laws or other applicable legal or regulatory requirements, (b) necessary to resolve a dispute between the parties, or (c) such Customer Personal Data is retained in accordance with Sail’s or its Subprocessors’ standard policies and procedures.
***
## Annex I
### List of Parties
**Data exporter:**
* **Name:** Customer
* **Activities relevant to the data transferred under these Clauses:** Customer receives the Services as described in the Agreement and provides Customer Personal Data to Sail in that context.
* **Role (controller/processor):** Controller.
**Data importer:**
* **Name:** Sail.
* **Activities relevant to the data transferred under these Clauses:** Sail provides the Services to Customer as described in the Agreement and DPA and Processes Customer Personal Data on behalf of Customer in that context.
* **Role (controller/processor):** Processor on behalf of Customer.
### Categories of Data Subjects
Customer and Customer’s users.
### Categories of Personal Data Transferred
As determined and controlled by Customer.
### Sensitive Data Transferred (If Applicable)
Sensitive data transferred (if applicable) and applied restrictions or safeguards that fully take into consideration the nature of the data and the risks involved, such as for instance strict purpose limitation, access restrictions (including access only for staff having followed specialized training), keeping a record of access to the data, restrictions for onward transfers or additional security measures: N/A.
### Frequency of the Transfer
The frequency of the International Data Transfer (e.g. whether the Personal Data is transferred on a one-off or continuous basis): On a continuous basis.
### Nature of the Processing
The Customer Personal Data will be processed and transferred as described in the Agreement and DPA.
### Purpose(s) of the International Data Transfer and Further Processing
The Customer Personal Data will be transferred and further processed for the provision of the Services as described in the Agreement and DPA.
### Duration of Processing
The period for which personal data will be retained, or, if that is not possible, the criteria used to determine that period: Customer Personal Data will be retained for as long as necessary taking into account the purpose of the Processing, and in compliance with applicable laws, including laws on the statute of limitations and Data Protection Law.
### Sub-Processor Transfers
For International Data Transfer to (Sub)Processors, also specify subject matter, nature and duration of the Processing: For the subject matter and nature of the Processing, reference is made to the Agreement and DPA. The Processing will take place for the duration of the Agreement.
### Competent Supervisory Authority
The competent authority for the Processing of Customer Personal Data relating to data subjects located in the EEA is the Supervisory Authority of Ireland.
The competent authority for the Processing of Customer Personal Data relating to data subjects located in the UK is the UK Information Commissioner.
The competent authority for the Processing of Customer Personal Data relating to data subjects located in Switzerland is the Swiss Federal Data Protection and Information Commissioner.
### Technical and Organizational Measures
Sail will implement security safeguards designed to protect the security, confidentiality and integrity of Personal Data as described on Sail’s [Trust Center](https://trust.sailresearch.com/).
# Run Harbor tasks on Sailboxes
Source: https://docs.sailresearch.com/harbor
Run every Harbor trial in its own Sailbox with one flag
[Harbor](https://harborframework.com) is a framework for evaluating and
training agents on sandboxed tasks. Each trial runs an agent against one
task inside an isolated environment, then a verifier scores the result.
Harbor ships environments for Docker on your own machine and for several
cloud sandboxes, and loads any other environment by import path. Sail
provides one:
```text theme={null}
sail.harbor:SailboxEnvironment
```
Pass that import path to Harbor and every trial's environment becomes a
Sailbox. Tasks, agents, verifiers, and datasets are unchanged.
## Quickstart
Install `sail` and `harbor` in the same Python environment (Harbor needs
Python 3.12 or newer) and set your Sail API key. Then run Harbor's
`hello-world` dataset with the `oracle` agent, which runs each task's
reference solution and needs no model key:
```bash theme={null}
pip install sail harbor
export SAIL_API_KEY=sk_your_key_here
harbor run -d hello-world@1.0 --agent oracle \
--env sail.harbor:SailboxEnvironment -o ./jobs
```
Harbor creates a Sailbox, runs the task's solution in it, runs the verifier,
writes the trial's results under `./jobs`, and terminates the Sailbox when
the trial ends.
A real evaluation is the same command with a real agent, a model, and more
trials in flight. Each trial gets its own Sailbox, so the machine running
Harbor is no longer the limit on concurrency:
```bash theme={null}
harbor run -d terminal-bench@2.0 --agent --model \
--env sail.harbor:SailboxEnvironment -n 32
```
A job config file (`harbor run -c job.yaml`) selects the environment the
same way:
```yaml theme={null}
environment:
import_path: sail.harbor:SailboxEnvironment
```
## Credentials and app
The process that runs Harbor needs a Sail credential: set `SAIL_API_KEY`, or
run `sail auth login` once on that machine. Sailboxes are created in the
`harbor` [app](/sailbox-sdk-apps) in your organization, which is created on
first use; set `SAIL_APP` to use a different app. Each Sailbox is named
`harbor-` followed by the trial's session id, and `sail box list --app harbor`
(or the app you chose) lists the ones a run created.
## Task images
Harbor decides where an environment's image comes from, and Sail follows the
same rules as Harbor's other cloud providers.
* A task that declares a prebuilt `docker_image` in its `task.toml` runs on
that image, pulled from its registry. Docker-style short references work:
`python:3.11` means `docker.io/library/python:3.11`. The registry must be
one Sail supports (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`),
and the image must be Debian or Ubuntu based; see
[Bring your own base image](/sailboxes-images#bring-your-own-base-image)
for the full requirements.
* A task that ships an `environment/Dockerfile` instead has it built into a
Sailbox image. The build happens once per organization: the first trial of
that task waits for it, and later trials start from the cached image.
Harbor's `--force-build` rebuilds it. See
[Build from a Dockerfile](/sailboxes-images#build-from-a-dockerfile) for
what a Dockerfile can contain.
* A task that ships an `environment/docker-compose.yaml` runs as a Docker
Compose project, with the Sailbox as the Docker host: the services' images
are pulled or built inside it, commands run in the task's `main` service,
and Harbor's per-service operations (exec, download, stop) reach the other
services.
## Sizing
A task's CPU, memory, and storage requests pick the smallest
[Sailbox size](/sailboxes-pricing) that covers them: `s` (1 vCPU), `m`
(4 vCPU), or `l` (8 vCPU). A request above the size's default memory or
disk ceiling raises that ceiling, up to the size's maximum. A task that
declares no resources gets an `s` Sailbox. A task that requests more than
8 CPUs fails up front, since no Sailbox provides more.
The size is a ceiling, not a reservation: a Sailbox is billed for the CPU,
memory, and disk it actually uses, so a task that requests 8 CPUs and uses
one pays for one.
## Cleanup
By default, `harbor run` deletes each environment when its trial ends, which
terminates the Sailbox. A termination that fails is reported in that trial's
result, like any other trial error.
With `--no-delete`, Harbor stops each environment without deleting it. The
Sailbox goes to sleep with its filesystem and processes intact, and billing
stops until it is resumed. Terminate those Sailboxes yourself when you are
done with them, with `sail box terminate ` or from the dashboard.
A trial whose environment fails to start terminates its Sailbox before the
error is reported, even with `--no-delete`. If that termination fails, the
trial's normal cleanup stops the Sailbox the same way it would after a
completed trial.
## Unsupported task features
Harbor checks a task's needs against the environment before starting it, so a
task that needs something unsupported fails up front with a clear error
instead of running without the feature. Sailboxes do not offer:
* GPUs, TPUs, or Windows environments.
* IPv6 allowlist entries. A Sailbox does not reach the internet over IPv6.
* A no-network or allowlist policy for a Compose task, because bringing the
project up pulls images over the network. Both are enforced for a task that
does not use Compose.
A task with the default public networking is unaffected. The
[network policy guide](/sailboxes-network-policy) has the allowlist entry
rules.
Outside Compose mode, host mount specs are accepted and unused, the same as
Harbor's other cloud providers. A Compose task's mounts are bound into its
`main` service.
# Images
Source: https://docs.sailresearch.com/images
Send images to multimodal models
Sail accepts image inputs on multimodal base models. Images can be supplied as base64 data URIs or as URLs.
## Supported models
Multimodal support is per-model, and detailed in the [models](/models) page. Requesting image input on a non-multimodal model returns `400` with `model '' does not support image input`.
## Limits
* Maximum of 20 images per request.
* Maximum of 20 MB per image. This is the size of the image bytes, not the base64-encoded length. There is no pixel-dimension limit.
* Must be a JPEG, PNG, WebP, or GIF.
* URL images can use `http://` or `https://` (`https://` recommended) and must be reachable from the public internet. If Sail can't fetch a URL within 10 seconds, the request fails with `400`.
## Responses API
Pass an `input_image` block inside a message's `content` array. The `image_url` value can be a data URI or a public URL.
```python theme={null}
from openai import OpenAI
client = OpenAI(base_url="https://api.sailresearch.com/v1", api_key="YOUR_KEY")
response = client.responses.create(
model="moonshotai/Kimi-K2.6",
input=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "What's in this image?"},
{
"type": "input_image",
"image_url": "https://example.com/cat.jpg",
},
],
}
],
)
print(response.output_text)
```
Equivalent with a base64 data URI:
```python theme={null}
import base64, pathlib
b64 = base64.b64encode(pathlib.Path("cat.jpg").read_bytes()).decode()
data_uri = f"data:image/jpeg;base64,{b64}"
response = client.responses.create(
model="moonshotai/Kimi-K2.6",
input=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "What's in this image?"},
{"type": "input_image", "image_url": data_uri},
],
}
],
)
```
`detail` (`"auto"`, `"low"`, `"high"`) is supported.
## Chat Completions API
Use OpenAI's standard `image_url` content part.
```python theme={null}
response = client.chat.completions.create(
model="moonshotai/Kimi-K2.6",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/cat.jpg",
"detail": "auto",
},
},
],
}
],
)
```
Data URIs are accepted in the same `url` field:
```python theme={null}
"image_url": {"url": f"data:image/jpeg;base64,{b64}"}
```
## Messages API (Anthropic)
Use the Anthropic `image` content block. Both `base64` and `url` source types are supported.
```python theme={null}
import anthropic
client = anthropic.Anthropic(
base_url="https://api.sailresearch.com",
api_key="YOUR_KEY",
)
# URL source
response = client.messages.create(
model="moonshotai/Kimi-K2.6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "url", "url": "https://example.com/cat.jpg"},
},
{"type": "text", "text": "What's in this image?"},
],
}
],
)
# Base64 source
response = client.messages.create(
model="moonshotai/Kimi-K2.6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": b64, # raw base64 string, no data: prefix
},
},
{"type": "text", "text": "What's in this image?"},
],
}
],
)
```
## Error cases
| Condition | Status | Message |
| -------------------------------------------------- | ------ | ---------------------------------------------------- |
| `model` is not multimodal | 400 | `model '' does not support image input` |
| More than 20 images in one request | 400 | `too many images: maximum 20 images per request` |
| Image larger than 20 MB decoded | 400 | `image too large: exceeds maximum of ... bytes` |
| Unsupported MIME type | 400 | `unsupported image type ...` |
| URL scheme not http/https | 400 | `unsupported URL scheme ...` |
| URL not reachable from the public internet | 400 | `blocked: ...` |
| URL returns non-200 or times out (10 s) | 400 | `failed to download image: ...` |
| Data URI declared MIME does not match actual bytes | 400 | `declared type ... does not match detected type ...` |
## Notes
* If you already have the image bytes, sending them as a base64 data URI is typically faster than a URL.
* Image bytes are not cached across requests.
* LoRAs and image inputs can be combined on a multimodal base model that also supports LoRA (see [LoRAs](/loras)).
# Overview
Source: https://docs.sailresearch.com/index
The most cost-efficient serverless inference and agent sandboxes.
Sail serves trillions of tokens at unbeatable prices, with support for the best open-source models and your own LoRA fine-tunes.
To achieve maximum efficiency for long-horizon agents, we allow you to express latency tolerance with [completion windows](/completion-windows).
```python Python theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://api.sailresearch.com/v1",
api_key="YOUR_SAIL_API_KEY",
)
completion = client.chat.completions.create(
model="zai-org/GLM-5.3",
messages=[{"role": "user", "content": "What are the top 3 things to do in San Francisco?"}],
)
print(completion.choices[0].message.content)
```
```typescript TypeScript theme={null}
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.sailresearch.com/v1",
apiKey: process.env.SAIL_API_KEY,
});
const completion = await client.chat.completions.create({
model: "zai-org/GLM-5.3",
messages: [
{
role: "user",
content: "What are the top 3 things to do in San Francisco?",
},
],
});
console.log(completion.choices[0].message.content);
```
```bash cURL theme={null}
curl https://api.sailresearch.com/v1/chat/completions \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"messages": [
{
"role": "user",
"content": "What are the top 3 things to do in San Francisco?"
}
]
}'
```
```python Python theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://api.sailresearch.com/v1",
api_key="YOUR_SAIL_API_KEY",
)
response = client.responses.create(
model="zai-org/GLM-5.3",
input="What are the top 3 things to do in San Francisco?",
)
print(response.output_text)
```
```typescript TypeScript theme={null}
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.sailresearch.com/v1",
apiKey: process.env.SAIL_API_KEY,
});
const response = await client.responses.create({
model: "zai-org/GLM-5.3",
input: "What are the top 3 things to do in San Francisco?",
});
console.log(response.output_text);
```
```bash cURL theme={null}
curl https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "What are the top 3 things to do in San Francisco?"
}'
```
```python Python theme={null}
from anthropic import Anthropic
client = Anthropic(
base_url="https://api.sailresearch.com",
api_key="YOUR_SAIL_API_KEY",
)
message = client.messages.create(
model="zai-org/GLM-5.3",
max_tokens=1024,
messages=[{"role": "user", "content": "What are the top 3 things to do in San Francisco?"}],
)
for block in message.content:
if block.type == "text":
print(block.text)
```
```typescript TypeScript theme={null}
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://api.sailresearch.com",
apiKey: process.env.SAIL_API_KEY,
});
const message = await client.messages.create({
model: "zai-org/GLM-5.3",
max_tokens: 1024,
messages: [
{
role: "user",
content: "What are the top 3 things to do in San Francisco?",
},
],
});
for (const block of message.content) {
if (block.type === "text") console.log(block.text);
}
```
```bash cURL theme={null}
curl https://api.sailresearch.com/v1/messages \
-H "x-api-key: $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": "What are the top 3 things to do in San Francisco?"
}
]
}'
```
## Intelligence at scale
More agents thinking longer and harder, with space to act and explore, can do incredible things:
Detail
uses Sail inference to deeply scan codebases for their most consequential
yet hard-to-catch bugs
Jack & Jill
runs large-scale deep research with Sail inference, matching job seekers'
resumes with job descriptions from thousands of employers
We
won Browsecomp-Plus
, the AI deep research benchmark, using open models running on Sail
inference
We
built Redis in Rust
with a swarm of 4 long-horizon coding agents running on Sailboxes with Sail
inference over 27 hours
## Security & privacy
Sail has Zero Data Retention (ZDR) by default, is HIPAA and SOC 2-compliant, and does not train on any customer data without consent.
Read more about our security and privacy commitments [here](/security), or visit our Trust Center here.
# LoRAs
Source: https://docs.sailresearch.com/loras
Bring your own PEFT-trained LoRA adapters and run them on supported models
Upload PEFT-trained LoRAs for supported base models. After upload, give the LoRA a name and pass that name or ID in request metadata.
If you train LoRAs with Tinker, you can also sample directly from Tinker checkpoints without uploading. See [Tinker](/tinker).
## Supported base models
LoRA serving is available for:
| Base model | Max rank | Target modules |
| ---------------------- | -------- | -------------- |
| `moonshotai/Kimi-K2.6` | 32 | all |
If a LoRA is incompatible with the base model or exceeds the rank limit, validation or inference fails.
## Adapter requirements
Train with [PEFT](https://huggingface.co/docs/peft) and export the two standard files:
* `adapter_config.json`
* `adapter_model.safetensors`
Use adapter config values compatible with the base model:
* **`base_model_name_or_path`** should identify the base model you register in `supported_models` (e.g. `moonshotai/Kimi-K2.6`).
* **`peft_type`** should be `"LORA"`.
* **`task_type`** should be `"CAUSAL_LM"`.
* **`r`** (rank) must be no more than the base model's max rank.
* **`target_modules`** can include any modules supported by the base model and runtime. Sail does not restrict Kimi K2.6 LoRAs to a fixed target-module allowlist.
Other adapter-config fields (`lora_alpha`, `lora_dropout`, bias settings, etc.) are preserved as-is.
The `adapter_config.json` and `adapter_model.safetensors` files can each be up to 100 GiB.
## Add a LoRA
Create a LoRA in three steps: upload the config file, upload the weights file, then call `POST /v1/loras`. The response includes validation records for each requested model.
### 1. Upload the two adapter files
Use `POST /v1/files` (multipart):
```python theme={null}
from openai import OpenAI
client = OpenAI(base_url="https://api.sailresearch.com/v1", api_key="YOUR_KEY")
with open("my-adapter/adapter_config.json", "rb") as f:
cfg = client.files.create(file=f, purpose="lora")
with open("my-adapter/adapter_model.safetensors", "rb") as f:
wts = client.files.create(file=f, purpose="lora")
```
### 2. Create the LoRA
```python theme={null}
import requests
resp = requests.post(
"https://api.sailresearch.com/v1/loras",
headers={"Authorization": "Bearer YOUR_KEY"},
json={
"name": "funnier-v1",
"supported_models": ["moonshotai/Kimi-K2.6"],
"config_file_id": cfg.id,
"weights_file_id": wts.id,
# optional
"display_name": "Funnier v1",
"description": "Fine-tuned on a standup-comedy corpus to write punchier, joke-forward replies.",
},
timeout=30,
)
resp.raise_for_status()
lora = resp.json()
print(lora["id"], lora["status"]) # e.g. 3fa85f64-... pending_validation
```
Each `supported_models` entry must be a known Sail model ID.
Naming rules for the `name` field:
* 2–64 characters
* lowercase alphanumeric or dashes (`[a-z0-9-]`)
* must start and end with an alphanumeric character
* unique within your organization (duplicate → `409`)
The file IDs you pass must belong to the same organization as your API key.
### Validation flow
`POST /v1/loras` creates one validation record per `supported_models` entry. Each record appears in the response under `validations`:
```json theme={null}
{
"id": "3fa85f64-...",
"name": "funnier-v1",
"status": "pending_validation",
"supported_models": ["moonshotai/Kimi-K2.6"],
"validations": [
{
"model": "moonshotai/Kimi-K2.6",
"status": "pending",
"response_id": "resp_..."
}
]
}
```
Sail validates the LoRA on each model in `supported_models`. Poll `GET /v1/loras/{name}` until validation finishes for the model you want to use.
| Validation status | Meaning |
| ----------------- | ----------------------------------------------------------------------------------------------- |
| `pending` | The validation task has been created. |
| `running` | The validation request is in progress. |
| `succeeded` | The LoRA loaded and completed a validation request for that model. |
| `failed` | The LoRA is not usable for that model. `result_code` and `result_message` describe the failure. |
| `unverified` | Sail could not complete validation automatically. |
Read `validations` for model-specific status. Requests using a LoRA are rejected only when the latest validation for that model is `failed`; `pending`, `running`, and `unverified` records remain usable.
If you later add a model with `PATCH /v1/loras/{name}`, Sail creates validation records for newly added models that have not already succeeded validation.
### 3. Fetch LoRAs
```bash theme={null}
# by name
curl -H "Authorization: Bearer $SAIL_API_KEY" https://api.sailresearch.com/v1/loras/funnier-v1
# by id
curl -H "Authorization: Bearer $SAIL_API_KEY" https://api.sailresearch.com/v1/loras/3fa85f64-...
# list all loras for your org
curl -H "Authorization: Bearer $SAIL_API_KEY" https://api.sailresearch.com/v1/loras
```
## Use a LoRA
Pass the LoRA's name (or its UUID) as `metadata.lora` on any Responses, Chat Completions, or Messages request. `model` must be one of the LoRA's `supported_models`:
```python theme={null}
response = client.responses.create(
model="moonshotai/Kimi-K2.6",
input=[{"role": "user", "content": "Write a one-liner about a centrifuge that's having a bad day."}],
metadata={
"lora": "funnier-v1",
"completion_window": "balanced",
},
background=True,
)
```
Chat Completions:
```python theme={null}
response = client.chat.completions.create(
model="moonshotai/Kimi-K2.6",
messages=[{"role": "user", "content": "Tell me a joke about Go channels."}],
extra_body={"metadata": {"lora": "funnier-v1", "completion_window": "balanced"}},
)
```
### Constraints on LoRA requests
* **Use the `balanced` or `flex` completion window for Kimi K2.6 LoRA requests.** `SailTokenCompleter` uses `balanced` by default. `asap` is not available for LoRA requests. For more on completion windows, see [Completion Windows](/completion-windows).
* **`model` must be in the LoRA's `supported_models` list.** Requesting a different base model returns `400`.
* **A failed model validation blocks that model.** `GET /v1/loras/{name}` includes `validations[].result_message` when validation fails.
* **The LoRA must belong to your organization.** Names are scoped per-org; two orgs can independently own a LoRA called `funnier-v1`.
* **You can use either the LoRA's name or its UUID in `metadata.lora`.**
# Connect the Docs MCP server
Source: https://docs.sailresearch.com/mcp-server
Connect your agents to Sail's documentation over MCP.
## Install
```bash theme={null}
claude mcp add --transport http sail-docs https://docs.sailresearch.com/mcp
```
Or add it to `.mcp.json` in your project:
```json theme={null}
{
"mcpServers": {
"sail-docs": {
"url": "https://docs.sailresearch.com/mcp"
}
}
}
```
```bash theme={null}
codex mcp add sail-docs --url https://docs.sailresearch.com/mcp
```
[Install in Cursor](https://cursor.com/en/install-mcp?name=sail-docs\&config=eyJ1cmwiOiJodHRwczovL2RvY3Muc2FpbHJlc2VhcmNoLmNvbS9tY3AifQ%3D%3D)
(one click), or add it to `.cursor/mcp.json`:
```json theme={null}
{
"mcpServers": {
"sail-docs": {
"url": "https://docs.sailresearch.com/mcp"
}
}
}
```
Add it to `.vscode/mcp.json`:
```json theme={null}
{
"servers": {
"sail-docs": {
"type": "http",
"url": "https://docs.sailresearch.com/mcp"
}
}
}
```
Settings → Connectors → Add custom connector, then paste
`https://docs.sailresearch.com/mcp`.
Where your workspace allows Developer mode and custom connectors, go to
Settings → Apps & Connectors → Developer mode → Add new connector, then paste
`https://docs.sailresearch.com/mcp`.
Any MCP client that supports streamable HTTP works. Point it at
`https://docs.sailresearch.com/mcp`.
## Try it
Once connected, ask your agent things like:
* "Which Sail models support tool calling, and what do they cost?"
* "What completion window should I use for an overnight batch job?"
* "Set up a Sailbox for my agent and exec a command in it."
## Workflow skills
The MCP server answers questions about Sail. To give your agent step-by-step
workflows on top of it, such as migrating an existing app to Sail or building
an observable agent, install the skills from the
[AI Quickstart](/ai-quickstart).
# Migrate to the Sail API
Source: https://docs.sailresearch.com/migrate
Drop-in migration from OpenAI and Anthropic-compatible providers to Sail.
Sail is a drop-in replacement for OpenAI and Anthropic-compatible inference providers, supporting the OpenAI Responses (`/v1/responses`), OpenAI Chat Completions (`/v1/chat/completions`), and Anthropic Messages (`/v1/messages`) APIs. Switching from OpenAI, Anthropic, or any compatible provider is just a configuration change.
Want an agent to do it?
Copy a migration prompt, paste it into your coding agent, and then use the
guide below to review the changes.
Using Claude Code or Codex? Install the [Sail skills](/ai-quickstart) instead
and ask your agent to "Migrate this app to Sail". The `sail-migrate` skill
guides the full migration, including moving sandboxed execution to Sail.
```text Migration prompt theme={null}
Migrate this project's LLM inference to Sail (https://sailresearch.com).
Sail docs to consult as you work:
- MCP server: https://docs.sailresearch.com/mcp (connect to it if you support MCP)
- Full docs as plain text: https://docs.sailresearch.com/llms-full.txt
- Key pages: https://docs.sailresearch.com/models (catalog),
https://docs.sailresearch.com/pricing (per-window rates),
https://docs.sailresearch.com/completion-windows,
https://docs.sailresearch.com/support (API feature matrix)
Sail is drop-in compatible with the OpenAI Responses and Chat Completions
APIs and the Anthropic Messages API, all served from
https://api.sailresearch.com. Keep whichever request shape this code
already uses. Sail's Messages API supports system prompts, tool calling, and
streaming for agentic use; one caveat is that prompt caching (`cache_control`)
is accepted but not yet applied (see /support), so if an Anthropic call site
relies on cache hits, expect full-price reads until that lands. The exact base
URL differs by SDK (see step 4).
If you can ask the user questions, ask whenever a step below is ambiguous
instead of guessing. If you can't, make the best-supported choice and flag
it in your final report.
1. Survey the current setup. Find every place this project calls an LLM:
SDK clients, raw HTTP calls, framework configs, env vars, and docs. For
each call site record the provider, API shape, model, and features used
(streaming, tool calls, structured outputs, images).
2. Choose replacement model(s). For each model currently in use, pick the
closest match from https://docs.sailresearch.com/models, comparing
capability tags, context window, and what the model is known to be good
at. If a current model is not one Sail serves, research it (web search
if available) to understand its strengths before choosing. If multiple
Sail models are plausible, ask the user. Otherwise pick the best fit
and explain the choice in your report. Check the /support page for any
features this code uses that Sail doesn't serve, and flag them.
3. Choose a completion window per call site. Omitting completion_window gives
low-latency inference by default, at lower prices than traditional inference
providers for many models. Workloads that can wait save more with balanced
or flex. The live
https://docs.sailresearch.com/completion-windows and
https://docs.sailresearch.com/pricing pages are the source of truth for
public windows, token prices, and scheduling behavior. If you can't fetch
them, use this summary and decide how long each workload can wait:
- asap: low-latency serving that is cheaper than traditional inference
providers
- balanced: more tokens per dollar for autonomous agents and pipelines;
use it for workloads that can tolerate more latency
- flex: the lowest prices for batch jobs, evals, and offline processing; it
has no latency target and requires background=True on the Responses API
If the workload's latency tolerance isn't obvious from the code, ask
the user. Omit metadata.completion_window when the default low-latency
behavior is acceptable. Set it explicitly for balanced or flex, or pin asap
when the request must fail instead of using a fallback window.
Confirm the chosen window is available for the chosen model on
https://docs.sailresearch.com/pricing. Some models are flex-only.
4. Make the changes wherever the client is configured or called:
- base URL: use https://api.sailresearch.com/v1 for OpenAI-compatible
clients (Responses and Chat Completions). For the Anthropic SDK, use
the bare host https://api.sailresearch.com (e.g. set
ANTHROPIC_BASE_URL=https://api.sailresearch.com). The SDK appends
/v1/messages itself, so a /v1 base URL would resolve to /v1/v1/messages
and 404
- API key: read from the SAIL_API_KEY environment variable. Never hardcode
a key or paste a literal key value into the code
(the Anthropic SDK can pass it as `api_key`; `auth_token` also works)
- model: the Sail model(s) chosen in step 2
- metadata.completion_window: the window(s) chosen in step 3
- add background=True for flex or very long-running requests
Update env var names, .env.example files, config templates, and any
README/docs references. Do not change prompts, tools, or business logic.
5. Estimate the savings. Compare the published per-1M-token list prices
(input, cached input, and output) of the previous model(s) against the
chosen Sail model(s) at the chosen completion window(s) from
https://docs.sailresearch.com/pricing. Research current provider list
prices if you don't know them. State the comparison as a simple table
and an approximate overall multiplier (e.g. "roughly 6x cheaper per
token"). Do not present this as a precise bill forecast.
6. Verify. Run the project's tests. Then make one real smoke request through
the new configuration: if SAIL_API_KEY is already set, use it; if not,
walk the user through creating a key at
https://app.sailresearch.com/api-keys and setting SAIL_API_KEY, then run
the smoke request once they have. Don't ask them to paste the key to you.
Have them export it in their own shell.
Finish with a short migration report: call sites changed; model mapping
with rationale; completion window(s) with rationale; the price comparison
from step 5; and anything that needs human follow-up (unsupported
features, ambiguous choices, untested paths). Close by telling the user
exactly where to set SAIL_API_KEY for their setup (locally and in their
production/deployment environment) so the migrated code can authenticate.
```
## 1. Get your API key
Sign up at the [Sail dashboard](https://app.sailresearch.com/api-keys) and create an API key. Export it where your agent and app can read it:
```bash theme={null}
export SAIL_API_KEY="YOUR_SAIL_API_KEY"
```
## 2. See what changes
Already calling the OpenAI Responses API? The request and response are identical:
```python Sail Responses API theme={null}
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.your-provider.com/v1", # [!code --]
base_url="https://api.sailresearch.com/v1", # [!code ++]
api_key=os.environ["PROVIDER_API_KEY"], # [!code --]
api_key=os.environ["SAIL_API_KEY"], # [!code ++]
)
response = client.responses.create(
model="", # [!code --]
model="", # [!code ++]
input="Explain the key ideas behind transformers.",
)
print(response.output_text)
```
Already calling the Anthropic Messages API? Keep the Anthropic SDK and point it at the bare Sail host. The SDK appends `/v1/messages` itself:
```python Sail Messages API theme={null}
import os
from anthropic import Anthropic
client = Anthropic(
base_url="https://api.sailresearch.com", # [!code ++]
api_key=os.environ["ANTHROPIC_API_KEY"], # [!code --]
api_key=os.environ["SAIL_API_KEY"], # [!code ++]
)
message = client.messages.create(
model="", # [!code --]
model="", # [!code ++]
max_tokens=1024,
messages=[{"role": "user", "content": "Explain the key ideas behind transformers."}],
)
```
## Notes
* **Synchronous by default.** `responses.create` blocks and returns the completed response, exactly like OpenAI. For long-running work, pass `background=True` to get an ID back immediately and poll, avoiding HTTP timeouts. See the [Quickstart](/quickstart).
* **Pick a completion window** for each call site. Omit `completion_window` for the default low-latency behavior, which pairs low latency with Sail's cost efficiency. `balanced` buys more tokens per dollar for autonomous work, and `flex` offers the lowest prices for background batches. See [Completion windows](/completion-windows).
* **Messages API caveat.** System prompts, tool calling, and streaming are supported. Prompt caching (`cache_control`) is accepted but not yet applied, so expect full-price input reads for now. See the [API support matrix](/support).
## Next steps
Set your coding agent up with Sail's docs and skills.
Make your first request against Sail.
Browse supported models and pick a replacement.
How the latency-for-price tradeoff works.
Per-token rates by model and completion window.
Estimate the cost of running your agent on Sail vs traditional inference
providers.
Email us if you hit anything unexpected.
# Models
Source: https://docs.sailresearch.com/models
All models currently served by Sail
## Core models
## Notes
* Each row links the exact Hugging Face checkpoint Sail currently serves. If we offer multiple quantizations, we list them as separate model IDs.
* This table and [`GET /v1/models`](/api-reference/models-api/list-supported-models) return canonical model IDs only. Sail may continue accepting an older ID as a compatibility alias, but new requests should use the canonical slug shown here.
* Use [`GET /v1/models`](/api-reference/models-api/list-supported-models) to confirm runtime availability for your API key.
* For per-model rates by completion window, see [Pricing](/pricing).
# Using OpenCode with Sail
Source: https://docs.sailresearch.com/opencode
Use OpenCode with Sail as your LLM provider.
[OpenCode](https://opencode.ai) is an open-source coding agent. Sail is OpenAI-compatible, so plugging it in is just a config change.
## Install
Follow the install instructions at [opencode.ai](https://opencode.ai). The most common path is:
```bash theme={null}
curl -fsSL https://opencode.ai/install | bash
```
## Get your API key
Sign up for [Sail](https://app.sailresearch.com) and create an API key.
## Configure
Create your OpenCode config with Sail as a provider:
```bash theme={null}
mkdir -p ~/.config/opencode
```
Then put this in `~/.config/opencode/opencode.jsonc`:
```jsonc theme={null}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"Sail": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.sailresearch.com/v1",
"apiKey": "YOUR_SAIL_API_KEY",
},
"models": {
"glm-5.3": {
"id": "zai-org/GLM-5.3",
"name": "GLM-5.3",
"variants": {
"minimal": { "reasoningEffort": "minimal" },
"high": { "reasoningEffort": "high" },
"xhigh": { "reasoningEffort": "xhigh" },
},
},
"glm-5.3-balanced": {
"id": "zai-org/GLM-5.3",
"name": "GLM-5.3 (balanced)",
"options": { "metadata": { "completion_window": "balanced" } },
"variants": {
"minimal": { "reasoningEffort": "minimal" },
"high": { "reasoningEffort": "high" },
"xhigh": { "reasoningEffort": "xhigh" },
},
},
"glm-5.3-flex": {
"id": "zai-org/GLM-5.3",
"name": "GLM-5.3 (flex)",
"options": { "metadata": { "completion_window": "flex" } },
"variants": {
"minimal": { "reasoningEffort": "minimal" },
"high": { "reasoningEffort": "high" },
"xhigh": { "reasoningEffort": "xhigh" },
},
},
},
},
},
}
```
Replace `YOUR_SAIL_API_KEY` with the key you created above.
## Run
```bash theme={null}
opencode
```
Once OpenCode is up, run `/models` and search "Sail" to choose a GLM-5.3
completion window. See [Completion windows](/completion-windows) for latency and
cost tradeoffs.
## Control reasoning
The GLM-5.3 entries above define `minimal`, `high`, and `xhigh` reasoning
variants. Press `Ctrl+T` in the OpenCode terminal UI to switch variants. The
selected variant sets Sail's reasoning effort until you switch again.
Run `/thinking` to show the reasoning blocks returned by the model:
```text theme={null}
/thinking
```
`/thinking` only controls whether OpenCode displays those blocks. It does not
change the model's reasoning effort. See OpenCode's
[reasoning variant](https://opencode.ai/docs/models/#variants) and
[`/thinking`](https://opencode.ai/docs/tui/#thinking) documentation for more
detail.
GLM-5.3 accepts `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`.
`max` selects the same top tier as `xhigh`. See the
[API support matrix](/support) for the request and streaming response fields.
The example includes the minimal, high, and maximum settings. You can add the
other effort levels as variants if you need them.
To make high reasoning the model default, add `reasoningEffort` to the model's
`options`:
```jsonc theme={null}
"options": {
"reasoningEffort": "high",
},
```
OpenCode uses the camelCase `reasoningEffort` config key and sends it to Sail as
`reasoning_effort`.
For a non-interactive request, select the variant and show its reasoning with:
```bash theme={null}
opencode run \
--model Sail/glm-5.3 \
--variant high \
--thinking \
"Explain this code."
```
## Tips
* **What's next:** see OpenCode's [Usage guide](https://opencode.ai/docs/#usage).
* **New models:** add any other model from Sail's [model catalog](/models) under `provider.Sail.models`.
# Pricing
Source: https://docs.sailresearch.com/pricing
Per-token pricing for Sail inference
## Core models
## Notes
* See [Completion Windows](/completion-windows) for how to use `balanced` and `flex` for lower token prices.
* Not all core models support all windows yet. We regularly bring up new models and expand completion window support for existing ones based on demand. If you have a need that's not represented above, get in touch.
* Prompt caching is implicit, based on prefix matching. Optionally, you may use [`prompt_cache_key`](/api-reference/responses-api/create-a-response#body-prompt-cache-key) as a routing hint to help maximize cache hit rates.
* See [Models](/models) for capabilities and other details on supported models.
* To see what these rates add up to on a full agent workload, use the
[agent cost calculator](/cost-calculator).
# Quickstart
Source: https://docs.sailresearch.com/quickstart
Start using Sail with OpenAI or Anthropic clients
Sail supports the OpenAI Chat Completions (`/v1/chat/completions`), OpenAI Responses (`/v1/responses`), and Anthropic Messages (`/v1/messages`) APIs; see the [API support matrix](/support) for the full picture. If you are already using these APIs, switching to Sail is just a base URL and API key change.
## 1. Get your API key
Sign up at the [Sail dashboard](https://app.sailresearch.com) and create an API key.
## 2. Make a request
Point the SDK you already use at Sail. The OpenAI SDK works with the Chat Completions and Responses APIs, and the Anthropic SDK works with the Messages API.
```python Python theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://api.sailresearch.com/v1",
api_key="YOUR_SAIL_API_KEY",
)
completion = client.chat.completions.create(
model="zai-org/GLM-5.3",
messages=[{"role": "user", "content": "Explain the key ideas behind transformers."}],
)
print(completion.choices[0].message.content)
```
```typescript TypeScript theme={null}
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.sailresearch.com/v1",
apiKey: process.env.SAIL_API_KEY,
});
const completion = await client.chat.completions.create({
model: "zai-org/GLM-5.3",
messages: [
{
role: "user",
content: "Explain the key ideas behind transformers.",
},
],
});
console.log(completion.choices[0].message.content);
```
```bash cURL theme={null}
URL=https://api.sailresearch.com/v1/chat/completions
AUTH="Authorization: Bearer YOUR_SAIL_API_KEY"
curl -s $URL \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"messages": [
{
"role": "user",
"content": "Explain the key ideas behind transformers."
}
]
}' | jq
```
```python Python theme={null}
from openai import OpenAI
client = OpenAI(
base_url="https://api.sailresearch.com/v1",
api_key="YOUR_SAIL_API_KEY",
)
response = client.responses.create(
model="zai-org/GLM-5.3",
input="Explain the key ideas behind transformers.",
max_output_tokens=1000,
)
# Incomplete responses still expose their partial output.
print(response.output_text)
```
```typescript TypeScript theme={null}
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.sailresearch.com/v1",
apiKey: process.env.SAIL_API_KEY,
});
const response = await client.responses.create({
model: "zai-org/GLM-5.3",
input: "Explain the key ideas behind transformers.",
max_output_tokens: 1000,
});
// Incomplete responses still expose their partial output.
console.log(response.output_text);
```
```bash cURL theme={null}
URL=https://api.sailresearch.com/v1/responses
AUTH="Authorization: Bearer YOUR_SAIL_API_KEY"
curl -s $URL \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "Explain the key ideas behind transformers.",
"max_output_tokens": 1000
}' | jq
```
```python Python theme={null}
from anthropic import Anthropic
client = Anthropic(
base_url="https://api.sailresearch.com",
api_key="YOUR_SAIL_API_KEY",
)
message = client.messages.create(
model="zai-org/GLM-5.3",
max_tokens=1000,
messages=[{"role": "user", "content": "Explain the key ideas behind transformers."}],
)
# Reasoning models can return a thinking block first, so select text by type.
for block in message.content:
if block.type == "text":
print(block.text)
```
```typescript TypeScript theme={null}
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://api.sailresearch.com",
apiKey: process.env.SAIL_API_KEY,
});
const message = await client.messages.create({
model: "zai-org/GLM-5.3",
max_tokens: 1000,
messages: [
{
role: "user",
content: "Explain the key ideas behind transformers.",
},
],
});
// Reasoning models can return a thinking block first, so select text by type.
for (const block of message.content) {
if (block.type === "text") console.log(block.text);
}
```
```bash cURL theme={null}
URL=https://api.sailresearch.com/v1/messages
AUTH="x-api-key: YOUR_SAIL_API_KEY"
curl -s $URL \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"max_tokens": 1000,
"messages": [
{
"role": "user",
"content": "Explain the key ideas behind transformers."
}
]
}' | jq
```
## Next steps
* Look at our full list of supported [models](/models) and [pricing](/pricing).
* Check the [API support matrix](/support) for what each API supports today, and browse the full [API reference](/api-reference) for endpoints and request fields.
* Using a coding agent? Connect it to the [Sail docs MCP server](/mcp-server)
to give it a live connection to these docs.
* [Email us](mailto:support@sailresearch.com) if you have questions.
# Rate limits
Source: https://docs.sailresearch.com/rate-limits
Rate limits for inference requests
Sail applies input-token rate limits to individual inference requests,
separately for each public model and organization. Batch API requests are not
included. If your organization needs higher limits, [contact
support](mailto:support@sailresearch.com).
## How limits work
Each rate-limited model has a limit for your organization and a shared global
limit. Each holds at most one minute of its configured token allowance and
refills continuously.
For each request subject to these limits, Sail counts input tokens before
inference starts:
* Cached input reduces the count using the larger of a known Supercache read
and Sail's prefix-cache estimate. The two values are not added together.
* A Supercache write counts its full input before any completion-window
discount. It does not receive a cache-read deduction.
* A configured completion-window discount reduces the remaining count, rounded
up to a whole token. Windows share the same limits for the model and your
organization.
* Final inference usage does not revise the rate-limit count.
## When a request is limited
Sail returns:
* `429` when your organization reaches its limit for the model.
* `529` when the model reaches its global limit.
Both responses include `Retry-After` in seconds. If retrying, wait at least that
long. This does not guarantee that the next attempt will succeed.
A rejected request does not start inference. If you sent an idempotency key,
reuse it when retrying. See [Idempotency](/idempotency) for examples.
### Requests larger than the token allowance
A request whose counted input exceeds either limit's entire one-minute
allowance cannot pass that limit, even when it is full. Sail returns
`Retry-After: 60` for this case, but waiting alone does not make the request
eligible. Reduce its counted input or contact support about the limit.
## Temporary model availability
Some models can be temporarily unavailable even when you have not reached a
rate limit. For non-streaming requests and errors detected before a stream
begins, Sail returns `503` with a `Retry-After` header. OpenAI-compatible
endpoints also return error code `model_capacity_unavailable`.
This response has no reset window. Wait at least the stated time, then retry
with backoff. The rejected request does not start inference.
# CLI
Source: https://docs.sailresearch.com/reference/cli
Install the sail command-line tool, plus every command grouped by area
The `sail` CLI manages Sailboxes and apps from the terminal. It is a single
native binary with no runtime dependencies. Run `sail --help` or
`sail --help` for the same information at the prompt, and see the
[Sailboxes guide](/sailboxes) for what you can do with it.
## Install
```bash theme={null}
curl -fsSL https://cli.sailresearch.com/install.sh | sh
```
Installs the latest `sail` into `~/.sail/bin`. If that directory is not
on your `PATH`, the installer adds it to your shell startup files and
prints the line to run in the current shell.
```powershell theme={null}
irm https://cli.sailresearch.com/install.ps1 | iex
```
Installs `sail.exe` into `%LOCALAPPDATA%\sail\bin` and puts that
directory at the front of your user `PATH`. Restart your shell afterward.
```bash theme={null}
pip install sail
```
The Python SDK ships the CLI: `sail` is on your `PATH` in that
environment, and it stays current with `pip install -U sail`.
To pin a version, set `SAIL_CLI_VERSION` for the installer, substituting the
release you want for `X.Y.Z`:
```bash theme={null}
curl -fsSL https://cli.sailresearch.com/install.sh | SAIL_CLI_VERSION=X.Y.Z sh
```
`SAIL_HOME` relocates sail's home directory, and `SAIL_INSTALL_DIR` installs a
copy at an exact path for provisioning scripts.
### Update
```bash theme={null}
sail update
```
Downloads the latest release and replaces the binary you ran. Sail warns when
your CLI version is nearing the end of its support window, and rejects one
past it with an update error before any operation runs. Upgrading a Sailbox
is a different operation: `sail box upgrade `.
## Interactive shell
For interactive use, `sail shell` is usually the most convenient option. It opens
a REPL on your machine that accepts every command below without the leading
`sail` (so `box list`, `box create ...`), and adds touches the one-shot commands
do not: line editing and history, a picker menu when you omit a Sailbox id, and
confirmation prompts.
```bash theme={null}
sail shell
```
The individual `sail ` subcommands take their arguments up front,
which suits scripts and agents (add `--json` for machine-readable output).
Mind the naming: `sail shell` is a shell for *managing* Sailboxes from your
machine. It is not a shell *inside* a box. To open a shell inside a running
Sailbox, use `sail box shell` (below).
## Authentication
```bash theme={null}
sail auth login [--api-key ] # log in via the browser, or store a key with --api-key (or piped to stdin)
sail auth whoami # show the active key
sail auth logout # remove the stored key
```
Every command reads `SAIL_API_KEY`, falling back to the credential stored by
`sail auth login`. Add `--json` to any command for machine-readable output.
See [Configuration](/reference/sdk-configuration).
## Apps
```bash theme={null}
sail app find # find an app by name
sail app create # create an app (returns the existing one if present)
sail app list # list apps in the current org
```
## Sailbox lifecycle
| Command | Description |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `sail box show ` | Show a single Sailbox. |
| `sail box top` | Live top-style view of Sailbox usage. |
| `sail box list` | List Sailboxes in the current org (see flags below). |
| `sail box terminate ` | Permanently terminate a Sailbox. |
| `sail box sleep ` | Checkpoint and release compute (`--wake-at` to schedule a wake). |
| `sail box pause ` | Freeze in place. |
| `sail box resume ` | Resume a paused or sleeping Sailbox. |
| `sail box checkpoint ` | Checkpoint a running Sailbox (`--name`, `--ttl-seconds`). |
| `sail box from-checkpoint --name ` | Create a new Sailbox from a checkpoint, called `name`. |
| `sail box upgrade ` | Upgrade the runtime (now if running, else at next wake). |
| `sail box auto-sleep ` | Change when Sail may sleep it: `never`, `auto`, or 1 through 3600 idle seconds (`0` is the same as `auto`; other numeric values are rejected). |
### `sail box create`
```bash theme={null}
sail box create --app --name [options]
```
| Option | Description |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--app ` | App name (created if missing). Required. |
| `--name ` | Sailbox name within the app. Required. |
| `--arch ` | Base image architecture (default `amd`). |
| `--port ` | HTTP ingress port to expose. Repeatable. |
| `--size ` | Resource size (default `m`): `s` = 1 vCPU, 16 GiB memory, 32 GiB disk; `m` = 4 vCPU, 32 GiB memory, 128 GiB disk; `l` = 8 vCPU, 64 GiB memory, 256 GiB disk. Ongoing billing is by usage; each size also has a one-time creation charge. `s` gives the fastest cold starts and resumes. |
| `--memory-limit-gib ` | Memory ceiling in whole GiB, within the size's range: 2-64 for `s`, 8-128 for `m`, 16-256 for `l`. The size's default when omitted. |
| `--disk-limit-gib ` | Disk ceiling in whole GiB, within the size's range: 8-128 for `s`, 32-512 for `m`, 64-1024 for `l`. The size's default when omitted. |
| `--visibility ` | Who may operate the Sailbox: `org` (the default) lets anyone in your org; `private` restricts all access to you (creator-only) and requires a user-minted API key. An org admin can override some operations on a private Sailbox with a recorded reason. |
| `--enable-ssh` | Expose port 22, trust your org's CA, and start sshd. |
| `--identity-file ` | Local SSH key to authenticate with (implies `--enable-ssh`). |
| `--auto-sleep ` | When Sail may sleep it on its own: `auto` (sleep when fully idle, wake the moment anything needs it; the default), `never` (stays running unless you stop it yourself), or a whole number of idle seconds from 1 through 3600 that replaces the default (`0` is the same as `auto`; other numeric values are rejected). |
| `--no-network` | Create the Sailbox cut off from other hosts and the internet: no outbound connections, no name resolution, and no exposed ingress or SSH. Running commands is unaffected (`sail box exec` and `sail box shell` reach the Sailbox over a Sail-internal path, not its network), and mounted volumes still work. |
| `--allow-host ` | Allow outbound access only to this destination (repeatable, up to 128): a hostname, a `*.` wildcard hostname, an IPv4 address, or an IPv4 range such as `203.0.113.0/24`. Only connections the Sailbox opens are limited, so `--enable-ssh` and `--port` still work. Conflicts with `--no-network`. See [Network policy](/sailboxes-network-policy) for the entry rules and what each entry allows. |
### `sail box list`
| Option | Description |
| ------------------- | ---------------------------- |
| `--app ` | Filter by app name. |
| `--status ` | Filter by status. |
| `--search ` | Filter by id/name substring. |
| `--limit ` | Maximum rows. |
| `--offset ` | Rows to skip. |
### `sail box top`
| Option | Description |
| --------------------- | ------------------------------------------------------------- |
| `--app ` | Filter by app name. |
| `--status ` | Filter by status. |
| `--search ` | Filter by id/name substring. |
| `--refresh ` | Refresh interval in seconds, 1 through 60 (default `2`). |
| `--limit ` | Maximum active rows to display, 1 through 200 (default `50`). |
## Run commands and connect
### `sail box exec`
Run a command in a Sailbox, streaming its output.
```bash theme={null}
sail box exec [options] -- [args...]
```
| Option | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--cwd ` | Working directory inside the guest. |
| `--timeout ` | Kill the command after this long (e.g. `30s`, `5m`). |
| `-e`, `--env ` | Environment variable for the command (repeatable). |
| `--user ` | Run as this user: a name or numeric uid, optionally with a group after a colon (`alice`, `1000`, `alice:staff`). Default: the image's `USER`, else root; pass `0:0` to force root. |
| `-i`, `--stdin` | Pipe local stdin to the guest command. |
| `-t`, `--tty` | Run under a pseudo-terminal driven by your terminal. |
| `--no-forward` | With `--tty`, turn off all local forwarding (browser, ports, paste/clipboard). |
| `--background` | Start the command and leave it running in the Sailbox, returning once the Sailbox has accepted it. Output is not captured. |
### `sail box run`
Create an ephemeral Sailbox, run a command, then terminate it. A shortcut for
`create` + `exec` + `terminate`; for workflows that reuse a Sailbox, use those
directly.
```bash theme={null}
sail box run --app [options] -- [args...]
```
Takes the same `create` flags (`--arch`, sizing, `--port`) plus `--name`
(default `run-`), `--cwd`, `--timeout`, `--env`, `--user`,
`--auto-sleep` (as on create; meaningful with `--keep`), `--no-network` and
`--allow-host` (as on create), and `--keep` (leave the Sailbox running instead
of terminating it). An exposed port is reachable while the command runs and
stays reachable afterwards only with `--keep`.
### `sail box shell`
```bash theme={null}
sail box shell [--shell ] [--user ] [--env ]... [--no-forward]
```
Open an interactive shell inside a running Sailbox. This is the simplest and
preferred way in: it runs a PTY over `exec`, so it opens no port and does not
count against your org's raw-TCP endpoint limit. (Not to be confused with `sail shell`,
the local REPL for managing boxes.)
The session runs as the image's `USER`, or as root when the image sets none:
the same identity `sail box exec` uses. `--user` opens it as someone else (a
name or numeric uid, optionally with a group after a colon, like `alice`,
`1000`, or `alice:staff`); `--user 0:0` forces root. `--env K=V` (or `-e`,
repeatable) adds environment variables to the session, as on `sail box exec`.
While the shell is open, the session forwards to your machine:
* **Browser opens.** When a program in the box opens a browser, the page opens
in your local browser instead. This covers logins like `claude login`,
`codex login`, and `gh auth login`. A login that redirects to a `localhost`
callback completes end to end.
* **Localhost servers.** A server the box starts on `localhost` (say a dev
server on port 3000) becomes reachable at `http://localhost:3000` on your
machine. The same port is used on your machine, so if it is already in use
locally that server is not forwarded.
* **Paste and drag-and-drop.** Files dragged onto the terminal upload to
`/tmp/sail-drops` in the box and paste as their guest paths. Press Ctrl+V to
forward your clipboard. On devbox images an image or text lands on the box's
clipboard, so pasting a screenshot into `claude` or `codex` works as it does
locally, and text copied inside the box is copied back to yours. On other
images a Ctrl+V image uploads as a file and pastes its path, while text uses
your terminal's own paste. Large uploads show a progress line; press Esc to
cancel one.
Pass `--no-forward` to turn all of it off, for example for an untrusted or
automated session. `sail box exec --tty` forwards the same way and takes the
same flag.
### `sail box cp`
```bash theme={null}
sail box cp [--recursive] [--user ]
```
Copy a file or directory to or from a Sailbox; `:` denotes the
remote side. Uploaded files belong to the image's `USER`, or to root when the
image sets none: the same identity `sail box exec` runs commands as. `--user`
names a different owner for the file and any directories the upload creates (a
name or numeric uid, optionally with a group after a colon, like `alice`,
`1000`, or `alice:staff`); `--user 0:0` forces root. `--user` applies to
uploads only; downloaded files are owned by whoever runs the CLI.
Pass `--recursive` (`-r`) to copy a directory: the source directory's
contents land inside the destination directory, which is created if needed.
Copying a directory requires the flag. With `--recursive`, `--user` owns the
copied entries, the destination directory, and any missing parents the copy
creates.
## Networking
```bash theme={null}
sail box expose [--tcp] [--allowlist ]... # expose a guest port at runtime
sail box unexpose # remove a runtime ingress port
sail box listeners # list a Sailbox's ingress listeners
sail box address # print the external address for one port
```
`--tcp` exposes raw TCP instead of HTTP. `--allowlist` restricts sources to an
address or a range (e.g. `203.0.113.0/24`), or, for HTTP listeners, a Sail app
name. Re-exposing a port replaces its whole list; omitting `--allowlist`
reopens the port. See [Access Control](/sailboxes-access-control) for the full model.
## Secrets and HTTP policies
Store a secret for your organization, create an HTTP policy that uses it, then
attach that policy to a Sailbox. See [Credential injection](/sailboxes-credentials)
for a complete example and [HTTP policies](/sailboxes-credentials#policies) for the
policy document format.
```bash theme={null}
sail secret set [--from-env ] # prompt for a value, read an environment variable, or read piped input
sail secret show # show the name and timestamps, never the value
sail secret list # list secret names and timestamps
sail secret delete
sail http-policy create --file # use --file - to read JSON from standard input
sail http-policy show
sail http-policy list [--search ] [--limit ] [--offset ]
sail http-policy rename
sail http-policy delete
sail box http-policy show
sail box http-policy set
sail box http-policy clear
```
A Sailbox holds at most one policy; `set` replaces any policy already
attached.
## Custom domains
`sail box domain` serves a Sailbox HTTP listener on a hostname you own, with
TLS certificates obtained and renewed for you.
```bash theme={null}
sail box domain target # print the custom-domain and wildcard certificate targets
sail box domain attach --port # attach a domain to an exposed HTTP listener
sail box domain list # list the domains attached to a Sailbox
sail box domain detach # stop routing a domain to the Sailbox
```
`attach` requires `--port` naming an exposed HTTP listener, and checks that
your DNS record points at your target first. See
[Custom domains](/sailboxes-custom-domains) for the DNS setup, apex-domain
options, and certificate behavior.
## SSH
`sail box ssh` sets up SSH access so you can reach a box as `ssh .sail`.
For a quick interactive shell, prefer [`sail box shell`](#sail-box-shell): it
needs no open port. Reach for SSH when you need a real SSH endpoint rather than a
PTY over `exec`, such as `scp`/`rsync`, an editor's remote mode, or a devbox you
work in day to day. Enabling it exposes port 22 as a TCP ingress port, which
counts against your org's raw-TCP endpoint limit.
```bash theme={null}
sail box ssh enable [--identity-file ] [--allowlist ]... [--no-wait] [--timeout ]
sail box ssh alias ... [--identity-file ]
sail box ssh disable
```
* **enable**: turn on SSH for a box (expose port 22, install your org's CA, start
sshd), certify your key on this machine, and add the `.sail` shortcut.
`--allowlist ` restricts the sources allowed to reach port 22 to an
address or a range (repeatable). Passing `--allowlist` replaces the port's
current restriction. Omitting it on a first enable leaves port 22 open to
any source; on a re-enable it keeps the existing restriction.
* **alias**: add `ssh .sail` shortcuts for boxes already SSH-enabled
elsewhere (e.g. from the SDK), without waking them.
* **disable**: stop SSH on a box and drop its local shortcut.
Only the public half of your key is certified; the private key is referenced in
your SSH config, never read.
## Configuration
Manage `~/.sail/config.toml`.
```bash theme={null}
sail config get [key] # print one value, or the whole file
sail config set ... # set one or more entries
sail config unset ... # remove one or more keys
sail config reset # reset user-settable settings (run 'sail auth logout' to remove the stored key)
```
# Python SDK
Source: https://docs.sailresearch.com/reference/python-sdk
Python SDK installation and full reference
The Sail Python SDK (`sail` on PyPI) supports Python 3.9+. Sail also provides
[TypeScript](/reference/typescript-sdk) and [Rust](/reference/rust-sdk) SDKs.
## Install
```bash pip theme={null}
pip install sail
```
```bash uv theme={null}
uv add sail
```
Installing the Python SDK also puts the `sail` CLI on your `PATH`. To install
the CLI on its own, see [Install the CLI](/reference/cli).
The Sail API warns when your SDK version is nearing the end of its support
window. The SDK emits it as a `SailDeprecationWarning` through Python's
`warnings` module, once per process. A version past the end of its support
window is rejected with an upgrade error before any operation runs. Upgrade
with `pip install -U sail`.
## Configure
Set `SAIL_API_KEY` in the environment; the SDK also reads the credential
`sail auth login` stores under `~/.sail`. See
[Configuration](/reference/sdk-configuration).
## Quickstart
```python theme={null}
import sail
# Look up (or create) the app your sandboxes belong to.
app = sail.App.find(name="example-app", mint_if_missing=True)
# Boot a sandbox.
sb = sail.Sailbox.create(app=app, name="worker-1")
# Run a command and stream its output.
proc = sb.exec("echo hello && ls /")
for chunk in proc.stdout:
print(chunk, end="")
result = proc.wait()
print("exit code:", result.exit_code)
# Move files.
sb.fs.write("/tmp/note.txt", "hi\n")
contents = sb.fs.read("/tmp/note.txt")
# Clean up (see also pause / sleep / resume / checkpoint).
sb.terminate()
```
## Sync and async
Every method that does I/O has an async twin under `.aio` (the interactive
`shell` is sync-only), so the same code works from scripts and from `asyncio`.
You choose sync or async once, at the call. A handle returned by an `.aio` call
is already async (`await proc.wait()`, `async for chunk in proc.stdout`), with
no further `.aio`:
```python theme={null}
sb = sail.Sailbox.create(app=app, name="box")
sb = await sail.Sailbox.create.aio(app=app, name="box")
```
See [Sailbox → Sync and async](/sailbox-sdk#sync-and-async) for streaming and
end-to-end examples.
## Python-only features
* [`@sail.function`](/sailbox-sdk-images#sail-function): run a local Python
function inside a Sailbox.
* [Voyages](/voyages-sdk) and [Inference](/voyages-sdk-inference): record
agent runs and attribute model calls to them.
## Errors
Product and transport failures derive from `sail.SailError`, and the error
classes that match a Python builtin also inherit it (`sail.NotFoundError` is a
`LookupError`), so both `except sail.SailError` and idiomatic builtin handlers
work. A few argument mistakes raise plain `ValueError`/`TypeError`. See
[Errors](/sailbox-sdk-errors).
## Reference
The docs below are auto-generated.
## Sailbox
A sandbox instance on the Sail platform: the operable handle plus the
monitoring snapshot from the call that produced it.
`sailbox_id` is the stable identifier and the durable external handle.
Equality and hashing follow it: two handles for the same Sailbox compare
equal, regardless of when their snapshots were taken. The remaining fields
are the read-only snapshot as of `get`/`list`
(`status`, resource usage, image, timestamps); a handle born from
`create` carries only what the create response returns. Fetch a fresh
snapshot with `Sailbox.get(sailbox_id)`. Every operation addresses the
Sailbox by this id. Sail wakes a sleeping Sailbox only when an operation
needs it.
**Attributes:**
| Attribute | Type | Description |
| ------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sailbox_id` | `str` | The Sailbox id: the stable, durable external handle. |
| `name` | `str` | The Sailbox name. |
| `status` | `str` | Lifecycle status (for example `"running"`). |
| `app_id` | `Optional[str]` | Identifier of the owning app. |
| `app_name` | `Optional[str]` | Name of the owning app. |
| `image_id` | `Optional[str]` | Identifier of the image the Sailbox was created from. |
| `memory_mib` | `Optional[int]` | Configured memory, in MiB. |
| `vcpu_count` | `Optional[int]` | Configured number of vCPUs. |
| `state_disk_size_gib` | `Optional[int]` | Configured state-disk size, in GiB. |
| `volume_mounts` | `Optional[Tuple[SailboxVolumeMount, ...]]` | Volumes attached to this Sailbox and the paths they are mounted at. `None` for handles that carry no snapshot of them (for example ones born from `create`); fetch a fresh snapshot with `get`. |
| `cpu_requested_vcpu` | `Optional[int]` | Requested CPU, in vCPUs. |
| `cpu_used_vcpu` | `Optional[float]` | Current CPU usage, in vCPUs. |
| `memory_requested_bytes` | `Optional[int]` | Requested memory, in bytes. |
| `memory_used_bytes` | `Optional[int]` | Current memory usage, in bytes. |
| `disk_requested_bytes` | `Optional[int]` | Requested disk, in bytes. |
| `disk_used_bytes` | `Optional[int]` | Current disk usage, in bytes. |
| `architecture` | `Optional[str]` | CPU architecture (for example `"arm64"`). |
| `guest_schema_version` | `Optional[int]` | Version of the managed Sailbox runtime the Sailbox last booted (or was created) with; the platform updates the runtime over time. `None` for handles born from `create`, which carry no monitoring snapshot. |
| `deprecation` | `Optional[SailboxDeprecation]` | Actionable runtime deprecation notice, when an upgrade is needed. |
| `error_message` | `Optional[str]` | Human-readable error detail when the Sailbox is in an error state. |
| `checkpoint_generation` | `Optional[int]` | Monotonic checkpoint generation counter. |
| `started_at` | `Optional[datetime]` | When the Sailbox first started running, if it ever has. A resume does not rewrite it. |
| `last_checkpointed_at` | `Optional[datetime]` | When the most recent checkpoint was taken, if any. |
| `created_at` | `Optional[datetime]` | When the Sailbox was created. |
| `updated_at` | `Optional[datetime]` | When the Sailbox was last updated. |
| `created_by_user_id` | `Optional[str]` | The user whose credential created this Sailbox (for a restore, the user who ran it). `None` for service-key creates. |
| `visibility` | `Optional[str]` | `"private"` when access is restricted to the creator; `None`/`"org"` is the default org-wide access. |
| `auto_sleep` | `Optional[AutoSleep]` | When Sail may sleep this Sailbox on its own: from `get` or `list`, or your own last `set_auto_sleep` through this object; `None` otherwise. A Sailbox created by `from_checkpoint` inherits its source's preference, so read it back with `get` to learn the inherited value. |
| `network_policy` | `Optional[NetworkPolicyInfo]` | The Sailbox's network policy, frozen at creation: from `get` or `list`. `None` means public (unrestricted outbound access). A Sailbox created by `from_checkpoint` keeps the policy of the one it came from, so read it back to verify the enforced value. |
### create
Create a new Sailbox.
```python theme={null}
@classmethod
def create(
*,
app: Union[App, str],
image: Optional[ImageDefinition] = None,
name: str,
image_build_timeout: int = 1800,
timeout: int = 600,
size: Optional[SailboxSize] = None,
memory_limit_gib: Optional[int] = None,
disk_limit_gib: Optional[int] = None,
ingress_ports: Optional[Sequence[Union[int, IngressPort]]] = (),
volumes: Optional[Mapping[str, Any]] = None,
visibility: Literal["org", "private"] = "org",
auto_sleep: Optional[AutoSleep] = None,
network_policy: Union[
NetworkPolicy, NetworkAllowlist, Literal["public", "no_network"]
] = NetworkPolicy.PUBLIC,
) -> Sailbox
```
Custom image definitions are built first; the call then returns once
the new Sailbox is running or creation has failed.
`timeout` (seconds) bounds each attempt of the call, since creating
a Sailbox can block for many minutes while it queues for capacity and
boots the VM. A call that times out raises, and the Sailbox may still
come up in the background; it then shows up in `list`. Pass
`0` to wait without a bound.
`ingress_ports` exposes guest ports for ingress. Each
entry is either a bare `int` (shorthand for an HTTP port) or an
`IngressPort` carrying an explicit protocol, e.g.
`ingress_ports=[80, 443, IngressPort(22, "tcp")]`. Call
`listener` / `listeners` on the returned Sailbox for the
public address of each exposed port: an HTTP listener's `endpoint` is
an `HttpEndpoint` with a routable `url` and a TCP listener's is a
`TcpEndpoint` with a `host`/`port` any TCP client can dial (for
example `psql -h -p `).
SSH is enabled after create with `enable_ssh`, which trusts
your org's SSH certificate authority, starts `sshd`, and exposes
guest port 22 as `tcp`.
`visibility` chooses who may operate the Sailbox, fixed for its
life. `"org"` (the default) lets any credential in your org exec,
copy files, SSH, or run lifecycle operations on it. `"private"`
restricts all of that to you. An org admin can override that with a
recorded reason for exec, files, setting a wake time, and the pause,
sleep, resume, terminate, and upgrade operations. SSH, exposing or
removing listeners, checkpoint, and restore stay creator-only.
`"private"` requires an API key minted by your user (not a service
key).
`volumes` mounts shared persistent NFS storage into the guest. Pass a
mapping from absolute guest mount path to a `sail.Volume` returned
by `sail.Volume.find()`, e.g. `volumes={"/mnt/shared": volume}`.
Volumes are currently in Alpha. To pilot them, reach out in the Sail
Slack:
[https://join.slack.com/t/sailresearchcrew/shared\_invite/zt-41pdcym9j-UU0Ey\~A\~r6n2H0DQVQsQHQ](https://join.slack.com/t/sailresearchcrew/shared_invite/zt-41pdcym9j-UU0Ey~A~r6n2H0DQVQsQHQ).
`size` selects the resource size: `"s"`, `"m"` (the default),
or `"l"`.
Each size sets the vCPU count plus default memory and disk.
Ongoing billing is based on observed usage, so a bigger size does not
reserve CPU, memory, or disk. Each size has a separate one-time
creation charge. Choose `"s"` when you want the fastest cold starts
and resumes. Its lower ceilings also cap what a runaway workload
can consume, so you don't accidentally use more than you need.
`memory_limit_gib` and `disk_limit_gib` tune that size's default memory and
disk ceilings in whole GiB, within its range. Omit them to keep the
size's own ceilings.
`image` is the image to boot; omit it for the prebuilt Debian base
(no image build). A custom image is built at create if it is not
already cached; `image_build_timeout` (seconds) bounds that build.
Sail may sleep a fully idle Sailbox; it wakes transparently on traffic
or the next operation. `auto_sleep` turns that off or replaces the
default idle window; see `AutoSleep`.
`network_policy` sets how the Sailbox may reach the network, chosen
here and fixed for its life: a `NetworkPolicy` (`public` or
`no_network`), or a `NetworkAllowlist` to permit only a list
of destinations. The default leaves network access open. A no-network
Sailbox cannot serve inbound connections, so
`NetworkPolicy.NO_NETWORK` cannot be combined with `ingress_ports`;
an allowlist limits only connections the Sailbox opens, so ingress
ports and SSH still work. A Sailbox created by `from_checkpoint`
inherits its source's policy, so read it back with `get` to
verify the inherited value.
`await Sailbox.create.aio(...)` is the async form, building the image
and provisioning the VM without blocking the event loop.
### list
List the Sailboxes for the current org that match the filters,
fetching pages until every match (or `limit` of them) is collected.
Use `list_page` to page through results manually instead.
```python theme={null}
@classmethod
def list(
*,
app_id: Optional[Union[App, str]] = None,
status: Optional[SailboxStatus] = None,
search: Optional[str] = None,
order: Optional[SailboxListOrder] = None,
limit: Optional[int] = None,
) -> List[Sailbox]
```
Filters are server-side. `app_id` filters by the owning app: a
`sail.App` value or an app id string (resolve an app name through
`App.find` first if needed).
`order` sorts the results: `"newest_active"` returns the most
recently active first (the default the server applies),
`"newest_created"` the newest-created first. `limit` caps the
total returned, bounding the fetch for large orgs; `None` returns
every match.
### list\_page
List one page of Sailboxes alongside the pagination envelope
(`limit`/`offset`/`total`/`has_more`).
```python theme={null}
@classmethod
def list_page(
*,
app_id: Optional[Union[App, str]] = None,
status: Optional[SailboxStatus] = None,
search: Optional[str] = None,
limit: int = DEFAULT_LIST_LIMIT,
offset: int = 0,
order: Optional[SailboxListOrder] = None,
) -> SailboxPage
```
Takes the same filters as `list`, plus `limit` and `offset` to
select the page. `order` sorts the results: `"newest_active"`
returns the most recently active first (the default the server
applies), `"newest_created"` the newest-created first.
### get
Fetch a Sailbox by id: the operable handle plus a fresh snapshot.
```python theme={null}
@classmethod
def get(sailbox_id: str) -> Sailbox
```
Validates access first: wrong-org ids and unknown ids both surface as
`LookupError` (the server returns 404 for both to avoid leaking
ownership across orgs). Nothing wakes here; operations resume a paused
or sleeping Sailbox on demand. Call `get` again for a fresh snapshot.
### from\_id
Bind a handle to an existing Sailbox id without a network call.
```python theme={null}
@classmethod
def from_id(sailbox_id: str) -> Sailbox
```
The returned handle carries no snapshot fields (its `name` and
`status` are empty), just the operable surface. The id is not
verified to exist: operations on an unknown or inaccessible id fail
with `NotFoundError`. Use `get` to validate the id and fetch
a fresh snapshot instead.
### from\_checkpoint
Create a new running Sailbox from a durable checkpoint handle.
```python theme={null}
@classmethod
def from_checkpoint(
checkpoint_id: str,
*,
name: str,
timeout: Optional[int] = None,
) -> Sailbox
```
The new Sailbox uses the checkpoint's writable disk and cleaned memory
state, so background processes continue and the new Sailbox runs
independently of the source. Commands started with `exec` stop,
though their writes up to the checkpoint remain. Host-specific identity
and network routes are removed before the checkpoint handle becomes
ready. A Sailbox with volume mounts cannot create a reusable checkpoint.
If Sail cannot resume the saved memory, it starts the child cold with
its writable disk intact and without the saved processes. The new
Sailbox keeps the original's network policy; read it back with
`get`.
`name` names the new Sailbox. `timeout` (seconds)
bounds the call, since a restore can block for many minutes while the
new Sailbox queues for capacity. A call that times out raises, and
the restore may still finish in the background; the new Sailbox then
shows up in `list`. `timeout` must be positive when given;
omit it to wait without a bound.
### terminate
Permanently terminate this Sailbox.
```python theme={null}
def terminate() -> None
```
Idempotent: terminating a Sailbox that is already terminated succeeds, so
cleanup paths can call it unconditionally.
### pause
Checkpoint and pause this Sailbox until it is explicitly resumed.
```python theme={null}
def pause() -> None
```
### sleep
Checkpoint and sleep this Sailbox until traffic or a wake restores it.
```python theme={null}
def sleep(wake_at: Optional[datetime] = None) -> Optional[datetime]
```
`wake_at`, when given, schedules a wall-clock wake before the sleep
starts and returns the effective wake time: the sooner of this
request and any wake already scheduled. If the Sailbox is sleeping
when that moment arrives, Sail restores it. The wake can fire a
little after the time you set, so treat it as approximate. A naive
`wake_at` is interpreted as local time. Calling `sleep` on an
already-sleeping Sailbox succeeds and just updates the scheduled
wake.
### set\_auto\_sleep
Replace when Sail may sleep this Sailbox on its own.
```python theme={null}
def set_auto_sleep(auto_sleep: AutoSleep) -> None
```
Each call replaces the whole setting: switching to `AutoSleep.never`
clears any minimum wait set earlier, and switching back does not
restore it.
### checkpoint
Create a durable checkpoint handle for this Sailbox.
```python theme={null}
def checkpoint(
*,
name: Optional[str] = None,
ttl_seconds: Optional[int] = None,
) -> SailboxCheckpoint
```
Running Sailboxes are snapshotted first. Paused and sleeping Sailboxes
reuse their existing checkpoint. The call returns after Sail has
prepared the clean start state that new Sailboxes use. Sailboxes with
volume mounts are not supported. Upgrade a Sailbox that uses an older
guest payload before you create a checkpoint handle.
`name` sets a display name for the handle. `ttl_seconds`, when set,
must be positive and overrides the server's default retention window;
use it to keep a checkpoint you intend to reuse as a template alive
longer than the default. The returned handle's `expires_at` reports
when the checkpoint expires; starting a Sailbox from it after that
fails.
### upgrade
Upgrade this Sailbox's runtime to the latest version.
```python theme={null}
def upgrade() -> UpgradeResult
```
Upgrading picks up new Sailbox features, fixes, and performance
improvements without recreating the Sailbox. A running Sailbox reboots
in place on its current disk: all filesystem state is preserved, but
processes restart as they would after a machine reboot (data not yet
written to disk is lost). Upgrading a paused or sleeping Sailbox does
not wake it: the upgrade is recorded and applies automatically at the
next wake.
Returns an `UpgradeResult`: `applied` is `True` when nothing
is left to apply, either because the Sailbox took the upgrade just now
or because it was already current, and `False` when the upgrade is
recorded for the next wake.
### resume
Resume this paused or sleeping Sailbox, returning it to running.
```python theme={null}
def resume() -> Sailbox
```
### listener
Fetch one listener by guest port without waking the Sailbox.
```python theme={null}
def listener(guest_port: int) -> Listener
```
### listeners
List this Sailbox's listeners without waking it.
```python theme={null}
def listeners() -> list[Listener]
```
### wait\_for\_listener
Block until the listener on `guest_port` is reachable end to end.
```python theme={null}
def wait_for_listener(guest_port: int, *, timeout: float = 60.0) -> Listener
```
For an HTTP listener this probes the `url`, so a successful return
means your guest HTTP server answered. For a TCP listener it opens a
connection through the ingress edge and treats it as ready once the
guest sends bytes (e.g. an SSH banner) or holds the connection open.
This is a connectivity check, not an application-level health check.
Raises `TimeoutError` if the listener does not become reachable
within `timeout` seconds; `float("inf")` waits indefinitely.
### expose
Expose an additional ingress port on this Sailbox at runtime.
```python theme={null}
def expose(
guest_port: int,
protocol: IngressProtocol = "http",
allowlist: Optional[List[str]] = None,
) -> Listener
```
`protocol` is `"http"` (a routable URL, the default) or `"tcp"`
(a public `host`/`port` for raw TCP: ssh, Postgres, etc.).
`allowlist` restricts which sources may connect: an entry that reads
as an address or a range (e.g. `["203.0.113.0/24"]`) matches source
IPs, and every other entry is a Sail app name (app names on `"http"`
listeners only; `"tcp"` allowlists must be addresses or ranges). An
address must not carry an IPv6 zone, such as `fe80::1%eth0`, which
names an interface on one machine rather than a source.
Re-exposing a port under the same protocol sets its `allowlist` to
what you pass, so pass the whole list every time; passing none clears
the restriction and reopens the port. A raw-TCP port reclaims its
previous address while your org still holds it idle; if another of
your org's Sailboxes took the address over, a new one is allocated, so
read the endpoint from the returned `Listener`.
Changing an exposed port's protocol is rejected: `unexpose` an HTTP
port and re-expose it, or use a different guest port for a raw-TCP one.
Returns the `Listener` (its `route_status` is `"unknown"`:
the expose response does not report reachability).
This works on a paused or sleeping Sailbox without waking it; a later
resume serves the new listener.
Probing reachability with `wait_for_listener` needs a running,
connected Sailbox, so wait only once the Sailbox is running.
### unexpose
Stop serving an exposed ingress port on this Sailbox.
```python theme={null}
def unexpose(guest_port: int) -> None
```
A `"tcp"` port stops counting against your org's raw-TCP quota once
removed, but its public `host`/`port` stays owned by your org:
another of your org's Sailboxes may reuse the idle address, and it is
never given to a different org. Re-`expose`-ing the same guest port
reclaims the exact address while your org still holds it idle; after a
reuse you get a new one. An `"http"` port carries no such reservation
and is removed outright. Removing a port that is not exposed raises a
`LookupError`.
### set\_http\_policy
Attach an HTTP policy to this Sailbox.
```python theme={null}
def set_http_policy(policy: Union[HttpPolicy, HttpPolicySummary, str]) -> None
```
This replaces any policy already attached to this Sailbox. Accepts a
`sail.HttpPolicy`, a listing summary, or a policy id string.
The policy applies to HTTPS connections this Sailbox opens after the
call; connections already open keep the previous policy until they
close.
### http\_policy
The HTTP policy attached to this Sailbox, or `None` when no
policy is attached.
```python theme={null}
def http_policy() -> Optional[HttpPolicy]
```
### clear\_http\_policy
Clear this Sailbox's attached HTTP policy.
```python theme={null}
def clear_http_policy() -> None
```
The change applies to HTTPS connections this Sailbox opens after the
call; connections already open keep the previous policy until they
close. The call also succeeds when no policy is attached.
### ingress\_auth\_headers
Fetch the ingress-identity headers for *this* Sailbox via the API.
```python theme={null}
def ingress_auth_headers() -> Dict[str, str]
```
Attach the returned headers to HTTP requests so they authenticate as
this Sailbox against another listener whose `allowlist` contains
this Sailbox's app name, useful for host-side orchestrators and tests
that drive Sailboxes from outside. Requires an organization-scoped API
key and a live (non-terminated) Sailbox.
Inside a Sailbox guest, prefer the module-level
`sail.ingress_auth_headers`, which reads the same values from
the guest environment without an API call.
### enable\_ssh
Make this Sailbox reachable over SSH, returning its endpoint.
```python theme={null}
def enable_ssh(
*,
allowlist: Optional[List[str]] = None,
wait: bool = True,
timeout: float = 60.0,
) -> Optional[TcpEndpoint]
```
Installs your org's SSH certificate authority as trusted, (re)starts
`sshd`, and exposes guest port 22 as `tcp` ingress once the CA-only
daemon verifiably owns it (a failed enable never leaves port 22 newly
exposed). Works on any running Sailbox and is the only way to enable
SSH: create the Sailbox, then call this. Safe to re-run: the Sailbox's host key
is generated once and never rotated, so a caller's `known_hosts`
stays valid; re-run it to bring `sshd` back up if the (unsupervised)
daemon stops. `sshd` survives sleep and checkpoint→resume.
`allowlist` restricts which source addresses or ranges may connect
to port 22, replacing any existing restriction. Left empty, a first
enable opens the port to any source, and a re-enable leaves an existing
restriction unchanged. Disabling SSH (`sail box ssh disable`)
unexposes port 22 together with its restriction, so a later enable is a
first enable.
Anyone in the org connects with a short-lived certificate signed for
their key, rather than installed keys (a private Sailbox is the exception,
accepting only its creator's certificates). The `sail box ssh` CLI
fetches that certificate and writes the local SSH config; this method only
prepares the Sailbox. By default it blocks until the port-22 listener is
reachable and returns its `TcpEndpoint`; pass `wait=False` to
skip the readiness probe and return `None`.
### fs
Filesystem operations on this Sailbox's guest: read and write files
(buffered or streaming), and directory helpers.
```python theme={null}
fs: SailboxFs
```
### run
Run a command to completion and return its buffered result.
```python theme={null}
def run(
command: Union[str, Sequence[str]],
*,
timeout: Optional[int] = None,
cwd: Optional[str] = None,
env: Optional[Mapping[str, str]] = None,
user: Optional[Union[str, int]] = None,
check: bool = False,
idempotency_key: Optional[str] = None,
output_buffer_bytes: int = DEFAULT_OUTPUT_BUFFER_BYTES,
) -> ExecResult
```
A one-shot convenience over `exec` followed by `wait()`. A
`str` runs via `/bin/sh -lc`; a sequence is exec'd directly. `env`
adds environment variables for the command; `cwd` sets the working
directory (string commands only, like `exec`); `user` picks the
guest user the command runs as (see `exec`). The result's stdout
and stderr hold only the most recent `output_buffer_bytes` of each
stream (1 MiB by default, up to 64 MiB), with `stdout_truncated` and
`stderr_truncated` set when older output was dropped; the command
never pauses for unread output. To get every byte, use `exec`
and read the stream
(see `ExecProcess`).
`check=True` raises `sail.CommandFailedError` (carrying the
completed result as `result`) when the command exits nonzero or
times out.
When `timeout` (seconds) elapses, the command is killed and `run`
returns an `ExecResult` with `timed_out=True`, raising only when
`check=True`.
`idempotency_key` deduplicates retries: calling `run` again with
the same key returns the original command's result instead of
launching it a second time. While the first call is still running, a
second call with the same key takes over its output stream, and the
earlier call's result may come back truncated. The UTF-8 value can be
up to 256 KiB.
### exec
Run a shell command or decorated Python function in the Sailbox.
```python theme={null}
def exec(
command: Union[str, Sequence[str], SailFunction],
*function_args: Any,
timeout: Optional[int] = None,
background: bool = False,
cwd: Optional[str] = None,
idempotency_key: Optional[str] = None,
open_stdin: bool = False,
pty: Union[bool, PtyConfig] = False,
env: Optional[Mapping[str, str]] = None,
user: Optional[Union[str, int]] = None,
output_mode: Union[OutputMode, Literal["auto", "pipe", "tail"]] = "auto",
output_buffer_bytes: int = DEFAULT_OUTPUT_BUFFER_BYTES,
kwargs: Optional[Mapping[str, Any]] = None,
) -> Union[ExecProcess, Any]
```
For shell commands, returns a `ExecProcess` immediately
after Sail accepts the command. By default a stream you are reading
pauses the command when you fall behind, so nothing is lost until a
cancel or the exec `timeout` ends the pauses, and a
stream you are not reading keeps only its most recent 1 MiB.
Accessing `proc.stdout` or `proc.stderr` is what starts reading,
so access it right after this call returns when you need every byte
(see `ExecProcess`). `output_mode` changes that: `"pipe"` holds
both streams until you read them, so a late reader still gets every
byte, and `"tail"` never pauses the command for you (see
`OutputMode`). `output_buffer_bytes` sets each stream's
buffer size, from 64 KiB to 64 MiB; it is what `wait()` returns per
stream and how far a reader can fall behind before the command pauses.
`open_stdin=True` opens the command's stdin for `proc.stdin` writes;
by default stdin is `/dev/null` so stdin-reading commands see immediate
EOF instead of blocking.
`pty=True` runs the command under a pseudo-terminal: `isatty()` is
true, control bytes written to `proc.stdin` become signals (Ctrl-C is
`b"\x03"`), and `proc.resize(cols, rows)` adjusts the window.
stdout and stderr merge onto `proc.output` (`proc.stderr` stays
empty). `pty` implies `open_stdin`. For a full interactive shell that
drives the local terminal, use `shell` instead.
`env` adds environment variables for the command. Entries override
the guest defaults (including `LANG` and the `IS_SANDBOX=1` sandbox
marker) and the image environment. A few
reserved variables that identify the Sailbox (such as `SAILBOX_ID`)
cannot be overridden. For pty execs the local terminal environment
(`COLORTERM`, `LANG`, `LC_*`, `TERM_PROGRAM`) is forwarded
automatically for keys not set here.
`cwd` runs the command in the directory you name (string commands
only). Without it, commands start in the image's working directory, or
`/` when the image does not set one.
`user` runs the command as that guest user: a user name, a numeric
uid, or either with a group appended after a colon (`"alice"`,
`1000`, `"alice:staff"`, `"1000:100"`, the Docker `USER`
syntax). A named user must exist in the Sailbox's `/etc/passwd`; a
numeric uid need not. `HOME` (and `USER`/`LOGNAME` when a name
resolves) default to the resolved account, with `env` entries still
winning. When `user` is not given, commands run as the image's
`USER` if the image sets one, root otherwise; pass `user="0:0"`
to force root (`"root"` is a user name like any other, resolved
through the Sailbox's `/etc/passwd`). Sailboxes created before user
support shipped must call `upgrade` once first; until then such
execs fail rather than run as root. The exact spelling `user="0:0"`
needs no upgrade.
`idempotency_key` deduplicates the launch, so a retry with the
same key attaches to the same command instead of starting a new one.
An exec has one live handle at a time: a second handle started with
the same key takes over the stream, and the first stops receiving
live output and resolves from a bounded recorded result. A first
handle reconnecting after a dropped connection can race a handle
that attached meanwhile, and either handle's result may come back
incomplete; avoid overlapping same-key handles. The UTF-8 value can be
up to 256 KiB.
`background=True` launches the command through a detached shell that
returns immediately. Its output is discarded, so `proc.stdout` /
`proc.stderr` stay empty and `proc.wait()` only confirms the
launcher started it.
`await sb.exec.aio(...)` is the async form: a shell command resolves
to an `AsyncExecProcess`, a function to its return value. For a
Python function, `output_mode` must stay `"auto"`, and the function's
complete encoded response (its serialized return value, captured
stdout and stderr, and any error details, as encoded on the wire) must
fit `output_buffer_bytes`; a larger response raises
`sail.SailboxFunctionSerializationError`. A second call with the same
`idempotency_key` while the function runs takes over its output,
and the earlier call may then fail to decode its result.
### shell
Open an interactive pty session on the Sailbox, driving the local terminal.
```python theme={null}
def shell(
command: Optional[str] = None,
*,
shell: Optional[str] = None,
term: Optional[str] = None,
cwd: Optional[str] = None,
user: Optional[Union[str, int]] = None,
timeout: Optional[int] = None,
env: Optional[Mapping[str, str]] = None,
no_forward: bool = False,
) -> int
```
With no `command`, runs an interactive login shell. Pass `command`
to run that under a pty instead (e.g. a REPL or `vim`). Either way the
session is bridged to the local terminal: raw-mode keystrokes (including
Ctrl-C, Ctrl-Z, and Ctrl-D) reach the remote process, its output renders
locally, and terminal resizes propagate. Blocks until the remote process
exits and returns its exit code. Requires an interactive local terminal
(stdin and stdout must be TTYs) on a Unix machine.
This is the equivalent of `ssh`-ing into the Sailbox, without a separate
SSH server. `shell` overrides the login shell (default `$SHELL` or
`/bin/bash`); it is ignored when `command` is given.
The session runs as the image's `USER` when the image sets one, root
otherwise: the same identity `exec` uses. `user` runs it as
someone else instead (a user name or numeric uid, optionally with a
group after a colon, like `"alice"`, `1000`, `"alice:staff"`);
`user="0:0"` is always root. A `user` other than `"0:0"`
requires a Sailbox whose guest honors requested users; on older
Sailboxes the session fails until `upgrade` is called.
`env` adds environment variables to the session, with the same
precedence and reserved names as for `exec`.
While attached, several local conveniences are forwarded: the Sailbox's
browser opens and localhost servers reach your machine, files dragged
onto the terminal upload into the Sailbox and paste as guest paths, and
Ctrl+V forwards your clipboard. On devbox images the clipboard is
two-way: pasted images and text land on the Sailbox's clipboard, and text
copied inside the Sailbox comes back to yours. Other images upload a pasted
image as a file and paste its path instead. Pass `no_forward=True` to
turn all of it off, for example for an untrusted or automated session.
## App
A Sail application.
**Attributes:**
| Attribute | Type | Description |
| ------------ | ---------- | ----------------------------------------------------- |
| `id` | `str` | Stable server-assigned app identifier. |
| `name` | `str` | Human-readable app name unique within the owning org. |
| `created_at` | `datetime` | App creation time. |
### find
Find an app by name, optionally creating it if it doesn't exist.
```python theme={null}
@classmethod
def find(name: str, *, mint_if_missing: bool = False) -> App
```
### list
Return every app the current org owns, newest first.
```python theme={null}
@classmethod
def list() -> list[App]
```
Apps with no Sailboxes yet are included. The response is not paginated;
the per-org app count is small.
## ImageDefinition
### apt\_install
Add an `apt-get install` step for `packages`.
```python theme={null}
def apt_install(*packages: str) -> ImageDefinition
```
### pip\_install
Add a `pip install` step for `packages`.
```python theme={null}
def pip_install(*packages: str) -> ImageDefinition
```
### run\_commands
Add shell commands to the build, each as its own step.
```python theme={null}
def run_commands(*cmd: str) -> ImageDefinition
```
### add\_local\_file
Bake the contents of one local file into the image at remote\_path.
```python theme={null}
def add_local_file(
local_path: Union[str, Path],
remote_path: str,
*,
mode: Optional[int] = None,
) -> ImageDefinition
```
The local file is hashed (sha256) and uploaded to Sail's
content-addressed asset store; only the hash, target path, and mode
flow into the image spec. A one-byte change to the local file
therefore changes the resulting image\_id and forces a rebuild.
`remote_path` must be an absolute POSIX path. If it ends with a
slash, the basename of `local_path` is appended.
`mode` is the POSIX permission bits (low 9 bits, max 0o777). When
omitted (`None`) or 0 the default 0o644 applies; an explicit
`mode=0` is treated the same as omitting the argument.
### add\_local\_dir
Bake a local directory into the image at remote\_path.
```python theme={null}
def add_local_dir(
local_path: Union[str, Path],
remote_path: str,
*,
ignore: Optional[Union[Sequence[str], Path, str]] = None,
) -> ImageDefinition
```
Each regular file under `local_path` is hashed and uploaded;
per-file modes come from the local stat(). Symlinks are skipped.
`ignore` accepts a sequence of gitignore patterns or a Path to a
file containing them (e.g. `.dockerignore`); pass a list to use
patterns directly. `remote_path` must be an absolute POSIX path.
### env
Bake environment variables into the image.
```python theme={null}
def env(env: Dict[str, str]) -> ImageDefinition
```
### build
Build the image now and return the built definition.
```python theme={null}
def build(*, timeout: int = 1800, force_build: bool = False) -> ImageDefinition
```
Submits the spec to Sail and polls until the build is ready or fails,
raising `TimeoutError` if the build does not finish within
`timeout` seconds. Any automatic retries are included in that
timeout.
Creating Sailboxes from the returned definition needs no further
build. For an image imported with `Image.from_registry` through
a tag, it is also pinned to the exact version the build resolved
the tag to, even if the tag later moves upstream.
By default, Sail may reuse an existing ready build for this
definition. Pass `force_build=True` to build it again: new
Sailboxes use the fresh image once it is ready, Sailboxes that
already exist keep the filesystem they were created with, and a
forced build that fails changes nothing. For an image imported
through a registry tag, a forced build also asks the registry what
the tag points at now and builds that version. The tag then means
that version for your whole organization, while definitions built
earlier keep their pinned version. A forced build of an image
built with `Image.from_dockerfile` looks up the tags its
`FROM` and `COPY --from` instructions name and moves those
pins for your whole organization, while definitions built
earlier keep the versions their build used. If
forced builds overlap, the last-requested one
that succeeds decides which image new Sailboxes use and, for a
tag, what the tag means.
## ImageNamespace
Base images a Sailbox can build on.
Access these through the module-level `sail.Image` singleton, for example
`sail.Image.debian_arm64` or `sail.Image.devbox_arm64`. See the
[Images guide](https://docs.sailresearch.com/sailboxes-images) for how to
choose between the Debian and devbox bases and the CPU architectures.
### debian
Debian base for the given CPU architecture (default amd64).
```python theme={null}
def debian(
architecture: Literal["amd64", "arm64"] = "amd64",
*,
install_python: bool = True,
) -> ImageDefinition
```
By default the image gets a `python3` matching your local Python
version, which is what lets `@sail.function` run local Python
functions inside a Sailbox. Pass `install_python=False` to keep
the base's stock `python3` instead; a base with no Python
install and no other build steps is prebuilt, so creating a Sailbox
from it needs no build.
`Image.debian_amd64` and `Image.debian_arm64` are shorthand for
`debian("amd64")` and `debian("arm64")`.
### debian\_amd64
Debian base for x86-64; shorthand for `debian("amd64")`.
```python theme={null}
debian_amd64: ImageDefinition
```
### debian\_arm64
Debian base for arm64; shorthand for `debian("arm64")`.
```python theme={null}
debian_arm64: ImageDefinition
```
### devbox
Devbox base for the given CPU architecture (default amd64): Debian
plus a baked development toolchain.
```python theme={null}
def devbox(architecture: Literal["amd64", "arm64"] = "amd64") -> ImageDefinition
```
Docker is included, and its daemon starts automatically when the
Sailbox boots and keeps running across sleeps. The daemon can take
a few seconds to accept commands right after boot. If it stops, it
is not restarted automatically.
The devbox base is prebuilt only: build steps and `env` are not
supported on it, so start from `debian` to customize.
`Image.devbox_amd64` and `Image.devbox_arm64` are shorthand for
`devbox("amd64")` and `devbox("arm64")`.
### devbox\_amd64
Devbox base for x86-64; shorthand for `devbox("amd64")`.
```python theme={null}
devbox_amd64: ImageDefinition
```
### devbox\_arm64
Devbox base for arm64; shorthand for `devbox("arm64")`.
```python theme={null}
devbox_arm64: ImageDefinition
```
### from\_registry
Your own image as the Sailbox root filesystem.
```python theme={null}
def from_registry(
ref: str,
*,
architecture: Optional[Literal["amd64", "arm64"]] = None,
) -> ImageDefinition
```
Sail pulls the image and layers everything a Sailbox needs on top, so
the result behaves like any other image: build steps, env, and
`Sailbox.create` all work the same. The image keeps its own
`python3`, which `pip_install` and `@sail.function` use. Unlike
`debian`, an imported image never gets a Python matching your local
interpreter: installing one would shadow the Python the image was
built around. An image without Python still gets one from the
packages Sail installs. If you use `@sail.function`, an optional
feature of the Python SDK that runs local Python functions inside a
Sailbox, the image's Python must match your local Python's
major.minor version.
Reference an image on a supported public registry (`docker.io`,
`ghcr.io`, `public.ecr.aws`, or `quay.io`), written as you
would for `docker pull`: `python:3.13` means
`docker.io/library/python:3.13` and `acme/tool` means
`docker.io/acme/tool`; name the registry for the others, as in
`ghcr.io/acme/tool`. You can pass a tag, a `@sha256:...` digest,
or just the name, which means the `latest` tag. The image must be
Debian- or Ubuntu-based.
Your Sailbox runs on the CPU architecture the image was built for. An
image published for both amd64 and arm64 runs on amd64. Pass
`architecture` to require one instead, and building fails if the
image was not built for it.
A tag is pinned for your organization once an image has been built
from it: later builds keep using that image even after the tag
moves upstream. Call `build(force_build=True)` to look the tag up
again and build the version it points at now for your whole
organization; see `build` for how the switch propagates. A digest
names exactly one image, so it never moves.
The image's environment variables, working directory, and `USER`
become the defaults for commands you run with `Sailbox.exec` or
`Sailbox.run`; per-call `env`, `cwd`, and `user` override them
(pass `user="0:0"` to run as root on an image that sets `USER`).
The image's `ENTRYPOINT` and `CMD` are not run: a Sailbox manages
its own processes, and your commands say what to execute. Build steps
you chain onto the image (such as `apt_install`) and SSH sessions
still run as root.
```python theme={null}
image = sail.Image.from_registry("python:3.13").apt_install("git")
```
### from\_dockerfile
Build a Dockerfile into a Sailbox image.
```python theme={null}
def from_dockerfile(
dockerfile: Union[str, Path, None] = None,
*,
contents: Optional[str] = None,
context_dir: Optional[Union[str, Path]] = None,
build_args: Optional[Dict[str, str]] = None,
ignore: Optional[Sequence[str]] = None,
architecture: Optional[Literal["amd64", "arm64"]] = None,
) -> ImageDefinition
```
Pass the path to a Dockerfile as the positional argument, or its
literal text with `contents=`.
`context_dir` is the build context that `COPY` and `ADD` read
from. A `.dockerignore` file in the context is honored, and
`ignore` patterns are applied on top of it. A file named after
your Dockerfile, like `Dockerfile.dockerignore`, sitting next to
it is used instead of the context's `.dockerignore`, as it is
with Docker. The files the ignore rules keep are hashed and
uploaded when `from_dockerfile` is called, and their modes,
empty directories, and symbolic links are carried into the build.
Edits made after the call do not reach the build; call
`from_dockerfile` again to pick them up. Without
`context_dir` the build runs with an empty context.
Every image a `FROM` or `COPY --from` instruction names must
live on a supported public registry (`docker.io`, `ghcr.io`,
`public.ecr.aws`, or `quay.io`). A short name like
`python:3.12` means `docker.io/library/python:3.12`. The
Dockerfile must produce a Debian- or Ubuntu-based filesystem.
Sail layers everything a Sailbox needs on top, so build steps,
`env`, and `Sailbox.create` all work the same as any other
image.
A `# syntax=` line can declare `docker/dockerfile:1` or a
release from 1.4 through 1.22.0. A file that declares anything
else is rejected. The declared release does not change how the
file is built.
The image builds for amd64 unless `architecture` says otherwise.
`build_args` provides values for the Dockerfile's `ARG`
instructions. Names may not start with the reserved `BUILDKIT_`
prefix, and Docker's proxy names (`HTTP_PROXY`, `HTTPS_PROXY`,
`FTP_PROXY`, `NO_PROXY`, `ALL_PROXY`, in any letter case) are
rejected; a step that needs a proxy can set one inside its `RUN`
command.
Multi-stage Dockerfiles work. A `RUN --mount` of type `cache`,
`secret`, or `ssh` is rejected; `tmpfs` mounts work, and
`bind` mounts work when they read from the build context or
another build stage. Mount options must be literal text, and
`ONBUILD` is not supported, in the Dockerfile or in an image a
`FROM` names.
Each image a `FROM` or `COPY --from` names is pinned to the
version its tag pointed at the first time your organization used
it, so rebuilding the same Dockerfile keeps using those versions
even after a tag moves. To look every tag up again and build with
the versions they point at now, pass `force_build=True` to
`build`.
The image's environment variables, working directory, and `USER`
become the defaults for commands you run with `Sailbox.exec` or
`Sailbox.run`; per-call `env`, `cwd`, and `user` override
them (pass `user="0:0"` to run as root on an image that sets
`USER`), and values set with `.env()` win over the image's. The
image's `ENTRYPOINT` and `CMD` are not run: a Sailbox manages
its own processes, and your commands say what to execute. Build
steps you chain onto the image (such as `apt_install`) and SSH
sessions still run as root. The image keeps its own `python3`; if
you use `@sail.function`, that Python must match your local
Python's major.minor version.
```python theme={null}
from pathlib import Path
env_dir = Path("./envs/task1")
image = sail.Image.from_dockerfile(env_dir / "Dockerfile", context_dir=env_dir)
```
## ExecProcess
A command running in a Sailbox.
Returned by `Sailbox.exec`. Each stream has a buffer, 1 MiB by default
(`output_buffer_bytes`), and `output_mode` says what happens when
it fills. With the default `"auto"`: if you are not reading a stream,
the command never pauses and the stream keeps only its most recent bytes;
if you are reading a stream and fall behind, the command pauses when the
buffer fills and resumes as you read, like a pipe. Reading a stream is
how you get every byte, and it slows the command when you cannot keep
up. `"pipe"` holds both streams from the start, so a reader that starts
late still gets every byte; `"tail"` never pauses the command for you.
See `sail.OutputMode`.
With `"auto"`, start reading right after `exec()` returns to get
every byte. You can read stdout without holding stderr, or the reverse;
the stream you are not holding keeps its most recent bytes and never
pauses the command when it fills. If you hold both, read them at the
same time, each from its own thread. The exit code is available from
`exit_code` once the streams end and from `wait()`.
Accessing `proc.stdout` or `proc.stderr` (`stdout_bytes` /
`stderr_bytes` for raw bytes) claims the stream and returns a
generator. The stream is released when the generator ends, when you call
`close()` on it, or when nothing references it any more (a `for`
loop drops it when the loop ends, including by `break`); to stop early
on purpose, keep the generator in a variable and call `close()`. Each
stream can be claimed once; a second access raises
`sail.InvalidArgumentError`.
`wait()` returns each stream's buffer, its most recent output, with
`stdout_truncated` / `stderr_truncated` set when older output was
dropped. `close()`, or your process exiting, releases both streams; the
command keeps running, and `wait()` raises
`sail.InvalidArgumentError` after `close()` unless it already
resolved a result. Sail may reattach
after an interruption, but reattachment does not guarantee exact output
replay. A pty command never pauses; `resync()` requests a fresh screen.
### exec\_request\_id
The durable identifier of this exec: the launch's idempotency
key as Sail recorded it (yours, or the one Sail generated when you
did not supply one; read it here to learn the generated value).
Reading it marks a generated identity as shareable: a second handle
started with it takes over the stream, so from then on this handle
no longer reclaims the stream after any interruption, a clean end or
a dropped connection alike, and resolves from the recorded result
instead. Reading back a key you supplied changes nothing.
```python theme={null}
exec_request_id: str
```
### stdout
Live stdout as a generator of `str` chunks (incrementally decoded
UTF-8). Accessing this property claims the stream, so access it right
after `exec()` returns when you need every byte; the generator
releases the stream when it ends, when you call `close()` on it, or
when nothing references it any more (see `ExecProcess`). Use
`stdout_bytes` instead for raw bytes; a second access of either
raises `sail.InvalidArgumentError`.
```python theme={null}
stdout: Generator[str, None, None]
```
### stderr
Live stderr generator; same as `stdout`, including that accessing
it claims the stream. Empty for a pty exec, which merges stderr onto
stdout.
```python theme={null}
stderr: Generator[str, None, None]
```
### output
Live merged terminal output for a pty exec (alias of `stdout`).
```python theme={null}
output: Generator[str, None, None]
```
`output` and `stdout` are the same one-consumer stream.
### stdout\_bytes
Live stdout as a generator of raw `bytes` chunks, exactly as the
command wrote them (escape sequences and binary payloads included). The
same one-consumer stream as `stdout` and `output`: accessing it
claims the stream the same way, and `close()` on the generator
releases the stream early.
```python theme={null}
stdout_bytes: Generator[bytes, None, None]
```
### stderr\_bytes
Raw `bytes` twin of `stderr`. Choose either accessor; this stream
can be claimed once, and `close()` on the generator releases it.
```python theme={null}
stderr_bytes: Generator[bytes, None, None]
```
### output\_bytes
Raw `bytes` twin of `output` (alias of `stdout_bytes`).
```python theme={null}
output_bytes: Generator[bytes, None, None]
```
### stdin
Stdin writer for an `open_stdin=True` exec; raises
`sail.InvalidArgumentError` otherwise.
```python theme={null}
stdin: StdinWriter
```
### exit\_code
Exit code once the streams end, else None.
```python theme={null}
exit_code: Optional[int]
```
Never blocks and never drops output. If the connection was lost
for good mid-command, the streams end early with the outcome still
unknown: this stays None and `wait()` fetches the result Sail
recorded. A host-lost exec (the machine running the Sailbox was
lost mid-command) has no real exit code: `wait()` always raises
`SailboxHostLostError` for one, and this raises it when that
loss is what ended the streams.
### poll
Alias of `exit_code`; never blocks.
```python theme={null}
def poll() -> Optional[int]
```
### cancel
Signal the guest command: SIGINT by default, SIGKILL if force=True.
```python theme={null}
def cancel(*, force: bool = False) -> None
```
Idempotent on the server. Transient failures are retried briefly,
covering the window right after the command starts when the guest
cannot accept signals for it yet.
### resize
Set the pty window (cols x rows) for a `pty=True` exec.
```python theme={null}
def resize(cols: int, rows: int) -> None
```
Advisory and best-effort: an unknown, finished, or not-yet-placed exec is
a server no-op, and transient transport errors are swallowed since the
next resize resends. A no-op for a non-pty exec.
### resync
Ask a `pty=True` exec to repaint its current screen.
```python theme={null}
def resync() -> None
```
A command runs at full speed and never waits for a slow reader, so if
you fall far behind the oldest output is dropped. Call this after that
happens to receive the current screen instead of a broken, partial one.
Advisory and best-effort; a no-op for a non-pty exec.
### close
Abandon the handle without killing the command.
```python theme={null}
def close() -> None
```
It releases both streams. The command keeps running and never pauses,
and Sail keeps only the most recent output of each stream. Call
`cancel()` instead if the command should stop. `wait()` raises
`sail.InvalidArgumentError` after `close()` unless it
already resolved a result.
### wait
Wait for the exec to complete and return its result.
```python theme={null}
def wait(*, stop: Optional[threading.Event] = None) -> Optional[ExecResult]
```
`result.stdout` and `result.stderr` hold each stream's buffer, its
most recent output (1 MiB by default), with `stdout_truncated` and
`stderr_truncated` set when older output was dropped; to get every
byte, read the stream (see `ExecProcess`). `wait()` itself
never pauses the command and may run while a generator is still open.
With `output_mode="pipe"`, a stream nobody reads pauses the command when
its buffer fills, and `wait()` then waits for as long as the command
stays paused. After `close()` it raises
`sail.InvalidArgumentError` unless a result was already
resolved; a repeat `wait()` returns the cached result.
Ctrl-C sends SIGINT and resumes waiting (the guest's natural
128+SIGINT=130 exit code flows back); a second Ctrl-C escalates to
SIGKILL and re-raises so a wedged guest can't trap the caller.
If `stop` is given and fires before the stream ends, `wait()` returns
`None` and leaves the exec running, so a caller that no longer needs the
result can return promptly. Without `stop` the result is non-None.
If this exec opened a Voyages auto-span, `wait()` closes it so the span
records this run's outcome.
## AsyncExecProcess
A command running in a Sailbox, with an async interface.
Returned by `await Sailbox.exec.aio(...)`. A chunk can be a partial
line. Each stream has a buffer, 1 MiB by default
(`output_buffer_bytes`), and `output_mode` says what happens when
it fills. With the default `"auto"`: if you are not reading a stream,
the command never pauses and the stream keeps only its most recent bytes;
if you are reading a stream and fall behind, the command pauses when the
buffer fills and resumes as you read, like a pipe. Reading a stream is
how you get every byte, and it slows the command when you cannot keep
up. `"pipe"` holds both streams from the start, so a reader that starts
late still gets every byte; `"tail"` never pauses the command for you.
See `sail.OutputMode`.
With `"auto"`, start reading right after `exec()` returns, before
awaiting anything else, to get every byte. You can read stdout without
holding stderr, or the reverse; the stream you are not holding keeps its
most recent bytes and never pauses the command when it fills. If you hold
both, read them at the same time, each from its own task
(`asyncio.gather`). The exit code is available from `exit_code` once
the streams end and from `wait()`.
Accessing `proc.stdout` or `proc.stderr` (`stdout_bytes` /
`stderr_bytes` for raw bytes) claims the stream and returns an async
generator. The stream is released when the generator ends, when you
`await` its `aclose()`, or when nothing references it any more (an
`async for` loop drops it when the loop ends, including by `break`);
to stop early on purpose, keep the generator in a variable and `await`
its `aclose()`. Each stream can be claimed once; a second access raises
`sail.InvalidArgumentError`.
`wait()` returns each stream's buffer, its most recent output, with
`stdout_truncated` / `stderr_truncated` set when older output was
dropped. `close()`, or your process exiting, releases both streams; the
command keeps running, and `wait()` raises
`sail.InvalidArgumentError` after `close()` unless it already
resolved a result. Sail may reattach
after an interruption, but reattachment does not guarantee exact output
replay. A pty command never pauses; `resync()` requests a fresh screen.
Cancelling the task awaiting `wait()` stops waiting but leaves the command
running; call `await proc.cancel()` to signal the command itself.
### exec\_request\_id
The durable identifier of this exec: the launch's idempotency
key as Sail recorded it (yours, or the one Sail generated when you
did not supply one; read it here to learn the generated value).
Reading it marks a generated identity as shareable: a second handle
started with it takes over the stream, so from then on this handle
no longer reclaims the stream after any interruption, a clean end or
a dropped connection alike, and resolves from the recorded result
instead. Reading back a key you supplied changes nothing.
```python theme={null}
exec_request_id: str
```
### stdout
Live stdout as an async generator of `str` chunks (incrementally
decoded UTF-8). Accessing this property claims the stream, so access
it right after `exec()` returns when you need every byte; the
generator releases the stream when it ends, when you `await` its
`aclose()`, or when nothing references it any more (see
`AsyncExecProcess`). Use `stdout_bytes` instead for raw
bytes; a second access of either raises
`sail.InvalidArgumentError`.
```python theme={null}
stdout: AsyncGenerator[str, None]
```
### stderr
Live stderr async generator; same as `stdout`, including that
accessing it claims the stream. Empty for a pty exec, which merges
stderr onto stdout.
```python theme={null}
stderr: AsyncGenerator[str, None]
```
### output
Live merged terminal output for a pty exec (alias of `stdout`).
```python theme={null}
output: AsyncGenerator[str, None]
```
`output` and `stdout` are the same one-consumer stream.
### stdout\_bytes
Live stdout as an async generator of raw `bytes` chunks, exactly
as the command wrote them. The same one-consumer stream as `stdout`
and `output`: accessing it claims the stream the same way; `await`
the generator's `aclose()` to release the stream early.
```python theme={null}
stdout_bytes: AsyncGenerator[bytes, None]
```
### stderr\_bytes
Raw `bytes` twin of `stderr`. Choose either accessor; this stream
can be claimed once, and `aclose()` on the generator releases it.
```python theme={null}
stderr_bytes: AsyncGenerator[bytes, None]
```
### output\_bytes
Raw `bytes` twin of `output` (alias of `stdout_bytes`).
```python theme={null}
output_bytes: AsyncGenerator[bytes, None]
```
### stdin
Stdin writer for an `open_stdin=True` exec; raises
`sail.InvalidArgumentError` otherwise.
```python theme={null}
stdin: AsyncStdinWriter
```
### exit\_code
Exit code once the streams end, else None (see the sync handle's
note).
```python theme={null}
exit_code: Optional[int]
```
### poll
Alias of `exit_code`; never blocks.
```python theme={null}
def poll() -> Optional[int]
```
### cancel
Signal the guest command: SIGINT by default, SIGKILL if force=True.
```python theme={null}
async def cancel(*, force: bool = False) -> None
```
### resize
Set the pty window for a `pty=True` exec; a no-op otherwise.
```python theme={null}
async def resize(cols: int, rows: int) -> None
```
### resync
Ask a `pty=True` exec to repaint its current screen; a no-op
otherwise. See the sync `ExecProcess.resync`.
```python theme={null}
async def resync() -> None
```
### close
Abandon the handle without killing the command.
```python theme={null}
def close() -> None
```
It releases both streams. The command keeps running and never pauses,
and Sail keeps only the most recent output of each stream. Call
`cancel()` instead if the command should stop. `wait()` raises
`sail.InvalidArgumentError` after `close()` unless it
already resolved a result.
### wait
Wait for the exec to complete and return its result.
```python theme={null}
async def wait() -> ExecResult
```
`result.stdout` and `result.stderr` hold each stream's buffer, its
most recent output (1 MiB by default), with `stdout_truncated` and
`stderr_truncated` set when older output was dropped; to get every
byte, read the stream (see `AsyncExecProcess`). `wait()`
itself never pauses the command and may run while an async generator
is still open. With `output_mode="pipe"`, a stream nobody reads pauses the
command when its buffer fills, and `wait()` then waits for as long as
the command stays paused. After `close()` it raises
`sail.InvalidArgumentError` unless a result was already
resolved; a repeat `wait()` returns the cached result. If this exec opened a Voyages auto-span, `wait()`
closes it with the run's outcome.
## StdinWriter
File-like write side of an exec's stdin, reached via `proc.stdin`.
Writes block (with backoff) while the guest buffer is full, like a real pipe;
a completed or stdin-closed exec surfaces as `BrokenPipeError`.
### close
Send EOF; the guest closes the pipe once the backlog drains.
```python theme={null}
def close() -> None
```
## AsyncStdinWriter
Async write side of an exec's stdin, reached via `proc.stdin`.
`await stdin.write(data)` applies backpressure (it resolves once the guest
accepts the bytes); `await stdin.close()` sends EOF.
### close
Send EOF; the guest closes the pipe once the backlog drains.
```python theme={null}
async def close() -> None
```
## function
Decorate a Python function so it can run through `Sailbox.exec`.
```python theme={null}
def function(func: Optional[F] = None)
```
## SailFunction
A Python function that can be executed inside a Sailbox.
## SailboxFs
Filesystem operations on a Sailbox's guest, reached via `Sailbox.fs`.
File I/O streams bytes to/from the guest; the directory helpers create,
remove, test, and transfer paths. Paths are remote POSIX paths in the
guest, accepted as `str` or `PurePosixPath`.
Writes give what they create to the image's `USER` by default (root
when the image sets none), the same identity commands run as, so an
uploaded file is usable by the code in the Sailbox. Reads and the
directory helpers act as root by default, so they work on any path.
Every operation except the reads and the directory download takes an
optional `user` in Docker's `USER` syntax (`name`, `uid`,
`name:group`, or `uid:gid`; `"0:0"` is always root). The
directory helpers other than the transfers run their command as that
user, with its permissions enforced. Writes and the directory upload
keep running as root but give that user what they create, like
`COPY --chown`. Reads and the download take no `user`: a `user`
only decides which paths an operation may touch and who owns what it
creates. A read creates nothing in the Sailbox, and a download reads
any path as root, the way the reads do. A `user` other than
`"0:0"` requires a Sailbox whose guest honors requested users; on
older Sailboxes these calls fail until `Sailbox.upgrade` is
called.
### read
Read a regular file from the Sailbox as bytes.
```python theme={null}
def read(path: GuestPath) -> bytes
```
Loads the entire file into memory. For files larger than a few
hundred MiB (model checkpoints, datasets) prefer
`read_stream`, which yields chunks without buffering.
### read\_stream
Stream a regular file's contents from the Sailbox as chunks.
```python theme={null}
def read_stream(path: GuestPath) -> FileStream
```
The result is iterable both ways, so the same call serves sync and
async code:
```python theme={null}
for chunk in sb.fs.read_stream(path): ...
async for chunk in sb.fs.read_stream(path): ...
```
Chunk sizes depend on network delivery and are bounded by the transfer
path. Iterate to completion so the underlying stream is released.
### write\_stream
Open a streaming write to a regular file in the Sailbox.
```python theme={null}
def write_stream(
path: GuestPath,
*,
create_parents: bool = True,
mode: int = 0o644,
user: Optional[Union[str, int]] = None,
) -> FileWriter
```
Returns a `FileWriter`: push chunks with `write` and
confirm with `finish` (only `finish` commits the write). Best used
as a context manager, which finishes on a clean exit and aborts on an
exception:
```python theme={null}
with sb.fs.write_stream("/logs/run.log") as writer:
for chunk in produce_chunks():
writer.write(chunk)
```
The async form returns an `AsyncFileWriter` whose `write` /
`finish` / `abort` are awaited:
```python theme={null}
async with await sb.fs.write_stream.aio("/logs/run.log") as writer:
await writer.write(chunk)
```
The file gets mode `0o644` unless `mode` says otherwise. A
`user` names the owner for the written file and any parent
directories the write creates, defaulting to the image's `USER`,
else root; the write itself always runs as root.
### write
Write data to a regular file in the Sailbox.
```python theme={null}
def write(
path: GuestPath,
data: FileContents,
*,
create_parents: bool = True,
mode: int = 0o644,
user: Optional[Union[str, int]] = None,
) -> None
```
Missing parent directories are created by default, and the file
gets mode `0o644` unless `mode` says otherwise. A `user` names
the owner for the written file and any parent directories the write
creates, defaulting to the image's `USER`, else root; the write
itself always runs as root. A file-like `data` is streamed from
the source, so it can be larger than memory. See
`write_files` to write several files in one call.
### write\_files
Write several complete files in one call.
```python theme={null}
def write_files(
files: Mapping[GuestPathT, FileContents],
*,
create_parents: bool = True,
mode: int = 0o644,
user: Optional[Union[str, int]] = None,
) -> None
```
`files` maps each absolute guest path to its contents: a string
(UTF-8), bytes, or a file-like object that is read into memory first.
Each file is its own request, up to eight at a time, and every file
gets the same `create_parents`, `mode`, and `user` as
`write`. A batch is not atomic across paths: the first failure
stops the batch, files that already completed stay written, writes
already in flight finish, and the error names the file that failed.
A path may appear only once. Use `write_stream` to stream a
large source.
### mkdir
Create a directory and any missing parents (like `mkdir -p`); a
no-op if it already exists. A `user` runs the mkdir as that user, so
created directories are owned by it.
```python theme={null}
def mkdir(path: GuestPath, *, user: Optional[Union[str, int]] = None) -> None
```
### remove
Remove a file or directory tree (like `rm -rf`); a no-op if it is
already absent. A `user` runs the removal as that user, limiting it
to what that user may delete.
```python theme={null}
def remove(path: GuestPath, *, user: Optional[Union[str, int]] = None) -> None
```
### exists
Whether `path` exists in the guest. Follows symlinks (like
`test -e`), so a dangling symlink reports `False` even though
`ls` lists it. A `user` reports existence as observable by
that user: a path the user lacks permission to reach also reports
`False`.
```python theme={null}
def exists(path: GuestPath, *, user: Optional[Union[str, int]] = None) -> bool
```
### ls
List a directory's immediate entries as `DirEntry` records (no
recursion). Runs GNU `find` in the guest, which the default Debian
image ships. A missing path raises, as does a path that is not a
directory and a listing too large for the exec output cap. An entry
whose name is not valid UTF-8 fails the listing, since the path API
cannot address it. A `user` runs the listing as that user, so a
directory it may not read raises a permission error.
```python theme={null}
def ls(path: GuestPath, *, user: Optional[Union[str, int]] = None) -> List[DirEntry]
```
### upload\_dir
Upload a local directory's contents into a directory on the Sailbox.
```python theme={null}
def upload_dir(
local_dir: Union[str, "os.PathLike[str]"],
guest_dir: GuestPath,
*,
user: Optional[Union[str, int]] = None,
) -> None
```
`local_dir`'s entries land inside `guest_dir`, which is created
if needed. Entries the upload does not name are left in place; a
same-named file is replaced. Uploaded files belong to the image's
`USER`, the same identity commands run as, so the code in the
Sailbox can use them. When the image sets no `USER`, or that user
cannot be resolved in the Sailbox, they belong to root.
`guest_dir` and any missing parents the upload creates get the
same owner. Files keep their permission bits, except that the
setuid, setgid, and sticky bits are cleared. A `user` (the same
syntax the other operations take) gives the entries to that user
instead, like `COPY --chown`; it must exist in the Sailbox, and
like the other operations' `user` it requires a Sailbox whose
guest honors requested users. The Sailbox's image must provide
`tar` and `gzip`, which the transfer uses to ship the directory
as one compressed archive; the default images do.
### download\_dir
Download a directory's contents from the Sailbox into a local
directory.
```python theme={null}
def download_dir(
guest_dir: GuestPath,
local_dir: Union[str, "os.PathLike[str]"],
) -> None
```
`guest_dir`'s entries land inside `local_dir`, which is created
if needed. Entries the download does not name are left in place; a
same-named file is replaced. The transfer reads every file in the
tree, so download directories of ordinary files: system trees like
`/proc` or `/sys` hold files that cannot be read as plain data,
and downloading them fails. A file that is being written while the
download runs is captured as it is at that moment, the way copying
a live file would; download after writers finish for a consistent
copy. On Windows, a directory that contains symbolic links cannot
be downloaded, since Windows restricts creating them. The
Sailbox's image must provide `tar` and `gzip`, which the
transfer uses to ship the directory as one compressed archive; the
default images do.
## FileWriter
A streaming write to a guest file.
Push chunks with `write` and confirm the write with `finish`;
only `finish` commits it. An unfinished writer aborts on `__exit__`
(or explicit `abort`), so a stream that ends without `finish` is
never committed as a completed write; the guest file state after an abort
is unspecified. Usable as a context manager: a clean exit finishes, an
exception aborts and propagates. `AsyncFileWriter`, returned by
`write_stream.aio`, is the async form.
### write
Write bytes (or UTF-8 text); writes are chunked at the transport size.
```python theme={null}
def write(data: Union[str, bytes, bytearray, memoryview]) -> None
```
### finish
Confirm the write, creating an empty file when nothing was written.
```python theme={null}
def finish() -> None
```
### abort
Cancel the write so it is never committed. Idempotent; a no-op
after `finish`.
```python theme={null}
def abort() -> None
```
## AsyncFileWriter
A streaming write to a guest file, with an async interface.
Returned by `write_stream.aio`; same commit semantics as
`FileWriter` with `await`-able `write`, `finish`, and
`abort`. Usable as an async context manager: a clean exit finishes,
an exception aborts and propagates.
### write
Write bytes (or UTF-8 text); writes are chunked at the transport size.
```python theme={null}
async def write(data: Union[str, bytes, bytearray, memoryview]) -> None
```
### finish
Confirm the write, creating an empty file when nothing was written.
```python theme={null}
async def finish() -> None
```
### abort
Cancel the write so it is never committed. Idempotent; a no-op
after `finish`.
```python theme={null}
async def abort() -> None
```
## FileStream
An iterable stream of file chunks that opens on first use.
Opening the transfer can wake the Sailbox. Deferring that to first
iteration keeps `read_stream` cheap to call, and the async path runs it
off the event loop so other tasks keep running. Iterate to completion, or
call `close` (or use it as a context manager) to release the stream
early.
### close
Stop the stream and release its resources; safe to call twice.
```python theme={null}
def close() -> None
```
### aclose
Async twin of `close`, run off the event loop.
```python theme={null}
async def aclose() -> None
```
## Volume
A managed NFS volume that can be mounted into one or more Sailboxes.
Volumes are currently in Alpha. To pilot them, reach out in the Sail Slack:
[https://join.slack.com/t/sailresearchcrew/shared\_invite/zt-41pdcym9j-UU0Ey\~A\~r6n2H0DQVQsQHQ](https://join.slack.com/t/sailresearchcrew/shared_invite/zt-41pdcym9j-UU0Ey~A~r6n2H0DQVQsQHQ).
**Attributes:**
| Attribute | Type | Description |
| ------------ | -------------------- | ------------------------------------------- |
| `volume_id` | `str` | The volume id. |
| `name` | `str` | The volume name. |
| `backend` | `str` | Storage backend serving the volume. |
| `status` | `str` | Lifecycle status. |
| `mount_path` | `Optional[Path]` | Mount path inside the Sailbox, if reported. |
| `created_at` | `Optional[datetime]` | Creation time, if reported. |
| `updated_at` | `Optional[datetime]` | Last-update time, if reported. |
### find
Get an org-scoped NFS volume by name, optionally creating it.
```python theme={null}
@staticmethod
def find(name: str, *, mint_if_missing: bool = False) -> Volume
```
### list
List active NFS volumes for the current organization, newest first.
```python theme={null}
@staticmethod
def list(*, max_objects: Optional[int] = None) -> List["Volume"]
```
### delete
Delete this NFS volume, returning the deleted handle.
```python theme={null}
def delete(*, allow_missing: bool = False) -> Optional["Volume"]
```
With `allow_missing=True` an already-deleted volume returns `None`
instead of raising. To delete by name without a handle, use
`delete_by_name`.
### delete\_by\_name
Delete the NFS volume with the given name, returning the deleted
handle.
```python theme={null}
@staticmethod
def delete_by_name(name: str, *, allow_missing: bool = False) -> Optional["Volume"]
```
With `allow_missing=True` a name that does not resolve to a volume
returns `None` instead of raising.
### from\_mount
Load the volume handle for a path mounted into a Sailbox.
```python theme={null}
@staticmethod
def from_mount(path: str | os.PathLike[str]) -> Volume
```
## HTTP policies
See [Credential injection](/sailboxes-credentials) for setup, examples, and cleanup. The entries below list the available Python calls.
### Secret
A value an HTTP policy can insert into matching HTTPS requests.
Secrets belong to your organization. Sail never returns a stored value.
Get and list calls return only the secret's name and timestamps.
**Attributes:**
| Attribute | Type | Description |
| ------------ | ---------- | --------------------------------------------------- |
| `name` | `str` | The secret's name, unique within your organization. |
| `created_at` | `datetime` | When the secret was first set. |
| `updated_at` | `datetime` | When the secret's value last changed. |
#### set
Set (create or update) the named secret's value. An HTTP policy
inserts it with `${secrets.NAME}`.
```python theme={null}
@staticmethod
def set(name: str, value: str) -> Secret
```
After this call succeeds, the next matching request from any Sailbox
whose attached HTTP policy uses this secret gets the new value.
Names start with a letter or number and use letters, numbers,
underscores, and dashes (up to 128 characters). Values cannot be
empty. They can be up to 64 KiB and cannot contain ASCII control
characters such as tabs or line breaks.
#### get
Fetch one secret's name and timestamps. The value is never returned.
```python theme={null}
@staticmethod
def get(name: str) -> Secret
```
Raises `sail.NotFoundError` when no secret has that name.
#### list
List your organization's secret names and timestamps, sorted by name.
```python theme={null}
@staticmethod
def list() -> List["Secret"]
```
#### delete
Delete this secret.
```python theme={null}
def delete() -> None
```
A secret cannot be deleted while an HTTP policy refers to it; the
call raises `sail.SecretInUseError` until every referencing
policy is deleted. Policy summaries from `sail.HttpPolicy.list`
include the secret names they use.
To delete by name without a handle, use `delete_by_name`.
#### delete\_by\_name
Delete the named secret. Same contract as `delete`.
```python theme={null}
@staticmethod
def delete_by_name(name: str) -> None
```
### HttpPolicy
Rules that shape the HTTPS requests your Sailboxes send.
A policy is a named document owned by your organization. Obtain one from
`create` or `get`; do not construct it directly. The document
cannot change after creation, but `rename` can change its name.
**Attributes:**
| Attribute | Type | Description |
| ------------ | ------------------- | ------------------------------------------------------------------------------------ |
| `id` | `str` | The policy's stable identifier. |
| `name` | `str` | The policy's name (the only mutable field). |
| `document` | `Mapping[str, Any]` | The saved policy document: Sail's normalized form of the document given at creation. |
| `created_at` | `datetime` | When the policy was created. |
| `updated_at` | `datetime` | When the policy's name last changed. |
#### create
Create a policy from `document`.
```python theme={null}
@staticmethod
def create(name: str, document: Mapping[str, Any]) -> HttpPolicy
```
Every `${secrets.NAME}` in the document must name a secret that
already exists. An invalid document raises
`sail.InvalidArgumentError` identifying the field to fix. Sail
saves a normalized form of the document (for example, host names are
lowercased and defaults are filled in), so reading the policy back can
return a different shape with the same behavior.
Policy names must contain visible text, use at most 128 characters,
and cannot contain tabs, line breaks, or other control characters.
Sail does not retry this call. If the connection ends before the
result arrives, list policies before trying again; a second call can
create a second policy.
#### get
Fetch one policy by id, document included.
```python theme={null}
@staticmethod
def get(policy_id: str) -> HttpPolicy
```
Raises `sail.NotFoundError` when no policy has that id.
#### list
List your organization's policies as summaries, without documents.
```python theme={null}
@staticmethod
def list(
*,
search: Optional[str] = None,
limit: Optional[int] = None,
) -> List["HttpPolicySummary"]
```
`search` filters by id or name, case-insensitively, and `limit`
caps the number returned. Fetch a policy's document with `get`.
#### rename
Rename the policy and return the updated policy object.
```python theme={null}
def rename(name: str) -> HttpPolicy
```
Names follow the same rules as `create`.
The document cannot change; create a new policy to change behavior.
#### delete
Delete the policy.
```python theme={null}
def delete() -> None
```
A policy still attached to a Sailbox cannot be deleted; the call
raises `sail.HttpPolicyInUseError` until every Sailbox clears
or replaces it.
## ingress\_auth\_headers
Headers that authenticate this Sailbox as an ingress allowlist source.
```python theme={null}
def ingress_auth_headers() -> Dict[str, str]
```
Use these when making HTTP requests from one Sailbox to another listener
whose `allowlist` contains the caller's app name. The helper is only
available inside a Sailbox.
## Voyages
A Voyage records what an agent run did. The module-level calls below act on the Voyage attached to the current process, so most code never holds a `Voyage` object itself.
### create
Create a new Voyage and make it the current Voyage.
```python theme={null}
def create(
name: str,
*,
version: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
sailbox_id: Optional[str] = None,
) -> Union[Voyage, NoopVoyage]
```
The current Voyage is tracked per execution context with a process-wide
fallback: concurrent tasks or threads that each create their own Voyage
keep their own attribution, and a context that never created one (a raw
worker thread, code after `asyncio.run` returns) resolves the process's
most recently created Voyage.
Always creates, even when `SAIL_VOYAGE_ID` is set in the environment;
a child process joining its parent's Voyage uses `attach` instead.
Without a Sail API key (`SAIL_API_KEY` or a `sail auth login`
credential) this degrades to a `NoopVoyage` so
instrumented code keeps running with telemetry disabled. Arguments are
validated before that gate: a malformed call raises even when telemetry
is off.
### attach
Attach to an existing Voyage and make it the current Voyage
(context-scoped, like `create`).
```python theme={null}
def attach(voyage_id: Optional[str] = None) -> Union[Voyage, NoopVoyage]
```
`voyage_id` defaults to `SAIL_VOYAGE_ID`, the handoff a parent
process sets so its children join the parent's Voyage. Raises
`ValueError` when neither is provided and telemetry is enabled.
Without a Sail API key (`SAIL_API_KEY` or a `sail auth login`
credential) this degrades to a `NoopVoyage` so
instrumented code keeps running with telemetry disabled. This includes a
keyless child whose parent exported no handoff env. An explicitly malformed
argument always raises; an absent ambient config in a telemetry-off
environment is the no-op state.
### run
Run one Voyage around a block. This is the recommended entry point.
```python theme={null}
def run(
name: str,
*,
version: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
sailbox_id: Optional[str] = None,
) -> _VoyageRunContext
```
`with sail.voyage.run("code-review") as voyage:` creates the Voyage on
enter (same arguments and semantics as `create`: always creates,
never reads `SAIL_VOYAGE_ID`), emits `voyage.completed` on a clean exit,
and on an exception emits `voyage.failed` with the exception's type and
message, then re-raises. Use `async with sail.voyage.run(...)` for the
async form, which drives the same lifecycle without blocking the event loop.
Terminal delivery is a bounded best-effort flush; call `voyage.flush()`
inside the block for strict delivery confirmation. Without a Sail API key
the block runs with telemetry disabled, exactly like `create`.
Controllers that create and complete the Voyage in different places keep
using `create` / `attach` directly.
### disable
Disable Voyage telemetry (context-scoped, like `create`).
```python theme={null}
def disable() -> NoopVoyage
```
Like `create`, this also resets the process-wide fallback, so contexts
that never started their own Voyage (a raw worker thread, code after
`asyncio.run`) resolve the disabled state too.
Installs and returns a `NoopVoyage` as the current Voyage. This is
the public form of the telemetry-off state `create()`/`attach()` enter
when no Sail API key is available. For controllers that catch a startup
telemetry failure and choose to continue unobserved:
`except VoyageError: voyage = sail.voyage.disable()`.
### child\_env
Env vars a child process needs to `attach()` to the current Voyage.
```python theme={null}
def child_env(*, agent: bool = True) -> Dict[str, str]
```
Returns `{}` when there is no current Voyage or telemetry is disabled,
so the handoff pattern is safe to use without an API key. See
`Voyage.child_env`.
### voyage\_id
The current Voyage's id, or `None` when no Voyage is current.
```python theme={null}
def voyage_id() -> Optional[str]
```
### headers
Headers attributing a Sail API request to the current Voyage and to
the span/agent context active at call time. Compute per request, never
once at client construction.
```python theme={null}
def headers(existing: Optional[Mapping[str, str]] = None) -> Dict[str, str]
```
### wrap\_openai
Attribute an OpenAI-style client's Sail calls to the live Voyage context.
```python theme={null}
def wrap_openai(
client: Any,
*,
voyage: Optional[Union[Voyage, NoopVoyage]] = None,
) -> Any
```
Wraps the client's request methods in place (`responses.create`,
`responses.retrieve`, and `chat.completions.create`, whichever
exist) so every call computes the attribution headers AT CALL TIME
(voyage id plus the span/agent active at that moment) and injects them
via `extra_headers`. Snapshotting stale headers at client construction
(`default_headers=sail.voyage.headers()`) becomes impossible: there is
nothing to snapshot. Like the `sail.inference` wrappers, un-spanned
`create` calls get a synthesized auto-span so the model call
lands scoped; `retrieve` polls carry headers but never synthesize.
`voyage=` pins attribution to one Voyage handle; the default follows
the current Voyage per call. Async clients
(`AsyncOpenAI`) are supported: coroutine-function methods get an async
wrapper whose auto-span covers the awaited request, not just coroutine
creation. Wrapping mutates the client in place (every holder of the
object sees attribution), is idempotent, returns the client, and raises
`TypeError` for an object exposing none of the known request methods.
### event
Record one timestamped event on the current Voyage.
```python theme={null}
def event(
kind: str,
level: str = "info",
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
*,
span_id: Optional[str] = None,
parent_span_id: Optional[str] = None,
error_type: Optional[str] = None,
occurred_at: Optional[str] = None,
sequence_id: Optional[int] = None,
) -> None
```
Everything after `payload` is keyword-only so a stale positional caller
fails loudly rather than being silently reinterpreted.
Agent attribution comes from the enclosing `agent()` context (or the
`SAIL_AGENT_*` env defaults); there is no per-event override.
### span
Open a named span on the current Voyage; context manager or decorator.
```python theme={null}
def span(
span_name: Optional[str] = None,
*,
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
span_id: Optional[str] = None,
parent_span_id: Optional[str] = None,
) -> _DeferredVoyageContext
```
The current Voyage is resolved when the context is entered (or the
decorated function is called), not when `span()` is evaluated. A
module-level `@sail.span(...)` declared before `create()` attributes
correctly. `span_name` may be omitted only in the decorator form,
where it defaults to the function's `__qualname__`.
### agent
Declare the named agent on the current Voyage; context manager or
decorator.
```python theme={null}
def agent(
name: str,
*,
role: Optional[str] = None,
slug: Optional[str] = None,
) -> _DeferredVoyageContext
```
The current Voyage is resolved at enter/call time, not at construction,
so a module-level `@sail.agent(...)` declared before `create()`
attributes correctly. Arguments are validated eagerly: a bad name or
slug raises at the declaration site regardless of voyage state.
### complete
Mark the current Voyage completed. A no-op when no Voyage is active.
```python theme={null}
def complete(
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
) -> None
```
### fail
Mark the current Voyage failed. A no-op when no Voyage is active.
```python theme={null}
def fail(
error_type: str = "harness_error",
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
) -> None
```
### cancel
Mark the current Voyage canceled. A no-op when no Voyage is active.
```python theme={null}
def cancel() -> None
```
### flush
Flush the current Voyage's buffered events. A no-op when none is active.
```python theme={null}
def flush(timeout: Optional[float] = None) -> None
```
### Voyage
A Sail Voyage attached to the current process.
#### headers
Headers attributing a Sail API request to this Voyage and to the
span/agent context active at call time.
```python theme={null}
def headers(existing: Optional[Mapping[str, str]] = None) -> Dict[str, str]
```
Compute per request, never once at client construction, so each
call carries the span and agent actually active when it is made.
Stale Voyage context headers in `existing` are replaced. Agent ids
are slug-derived and therefore header-safe by construction; a
non-header-safe caller-supplied span id is omitted rather than sent.
#### child\_env
Env vars a child process needs to `attach()` to this Voyage.
```python theme={null}
def child_env(*, agent: bool = True) -> Dict[str, str]
```
Merge into the child's environment:
`subprocess.run(cmd, env={**os.environ, **voyage.child_env()})`.
With `agent=True` (default) the active `agent()` context rides
along as the child's `SAIL_AGENT_*` defaults, so the child's
events attribute to the same agent without re-declaring it.
`NoopVoyage.child_env()` returns `{}`. The handoff is
safe to call without an API key.
#### event
Record one timestamped event.
```python theme={null}
def event(
kind: str,
level: str = "info",
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
*,
span_id: Optional[str] = None,
parent_span_id: Optional[str] = None,
error_type: Optional[str] = None,
occurred_at: Optional[str] = None,
sequence_id: Optional[int] = None,
) -> None
```
Everything after `payload` is keyword-only so a stale positional
caller fails loudly rather than having an argument silently
reinterpreted as `span_id`.
Agent attribution carries no per-event override: it comes
from the enclosing `agent()` context, or from the `SAIL_AGENT_*`
env defaults when no context is active. A one-shot attributed event
is `with voyage.agent(...): voyage.event(...)`.
#### span
Open a named span of work; nests under the active span automatically.
```python theme={null}
def span(
span_name: Optional[str] = None,
*,
message: Optional[str] = None,
payload: Optional[Dict[str, Any]] = None,
span_id: Optional[str] = None,
parent_span_id: Optional[str] = None,
) -> _SpanContextManager
```
Usable as a context manager or as a decorator. `span_name` may be
omitted only in the decorator form (`@voyage.span()`), where it
defaults to the decorated function's `__qualname__`; the `with`
form requires a name.
A span carries no agent identity of its own. Events emitted inside it
(including the span's own lifecycle events) are attributed to the
enclosing `agent()` context.
#### agent
Declare the named agent as the active participant.
```python theme={null}
def agent(
name: str,
*,
role: Optional[str] = None,
slug: Optional[str] = None,
) -> _AgentContextManager
```
`name` is the display identity shown in the dashboard; the stable
attribution key (`agent_id`) is derived from it: lowercased,
ASCII-folded, hyphenated. Pass `slug=` to pin the attribution key
across display renames or multi-process attach; `role=` is an
optional freeform taxonomy used for dashboard filtering.
#### cancel
Mark this Voyage cancelled without emitting a customer event.
```python theme={null}
def cancel() -> None
```
Cancel stops recording for the Voyage; it does not terminate external
agent code. `NoopVoyage.cancel()` and unattached Voyage instances are
no-ops when no API key is configured.
Unlike `complete()`/`fail()` (which never raise: there is a
buffered terminal flush behind them), `cancel()` is a single
synchronous request to Sail and **raises** `VoyageHTTPError` on
a failed response. Wrap it if you call it from a `finally`/cleanup
path where an exception would mask the original error.
### NoopVoyage
No-op Voyage used when no Sail API key is available.
Attribute-compatible with `Voyage` so fail-open code that reads voyage
fields does not crash when telemetry is disabled.
## SailTokenCompleter
Tinker TokenCompleter backed by Sail's raw-token Responses path.
Extends `TokenCompleter`.
## TinkerSandbox
Run a tinker-cookbook sandbox on a Sailbox.
Implements the cookbook's sandbox interface, so recipes that take a
sandbox (or a sandbox factory, via `tinker_sandbox_factory`) can
execute their rollout commands in an isolated Sailbox instead of on the
training machine. Each instance owns one Sailbox for its whole life,
and `cleanup` terminates it; give each sandbox its own Sailbox, since
two sandboxes sharing one would share its filesystem and processes and
the first cleanup would terminate it for both. The factory creates a
fresh Sailbox per sandbox; constructing directly is for supplying your
own, such as one restored from a warmed checkpoint.
`timeout_seconds` is the sandbox's lifetime budget: once it has
elapsed, the next operation terminates the Sailbox and raises the
cookbook's `SandboxTerminatedError`. `None` means no budget.
### sandbox\_id
The backing Sailbox's id.
```python theme={null}
sandbox_id: str
```
### run\_command
Run a shell command in the Sailbox and return its `SandboxResult`.
```python theme={null}
async def run_command(
command: str,
workdir: Optional[str] = None,
timeout: Optional[float] = 60,
max_output_bytes: Optional[int] = None,
) -> Any
```
`max_output_bytes` keeps only the first bytes of each output
stream; without it the full output is returned, up to a large
safety ceiling that keeps a runaway stream from exhausting the
training process's memory. A
command that outlives `timeout`
(seconds) is killed and reported with `metrics["timed_out"]` set.
Errors from the Sailbox surface as a result with exit code `-1`,
except a terminated or lost Sailbox, which raises the cookbook's
`SandboxTerminatedError`.
### read\_file
Read a file from the Sailbox into a `SandboxResult`'s stdout.
```python theme={null}
async def read_file(
path: str,
max_bytes: Optional[int] = None,
timeout: float = 60,
) -> Any
```
`max_bytes` keeps the file's first bytes (without it the whole
file, up to a large safety ceiling), and `timeout` (seconds)
bounds the whole read. A missing or unreadable file is reported as a
result with exit code `1` rather than raised, matching the
cookbook's contract, and so is a read that runs out of time.
### write\_file
Write a file into the Sailbox, marked executable when asked.
```python theme={null}
async def write_file(
path: str,
content: Union[str, bytes],
executable: bool = False,
timeout: float = 60,
) -> Any
```
`timeout` (seconds) bounds the write; one that runs out of time is
reported as a result with exit code `1`.
### send\_heartbeat
Check the sandbox's lifetime budget.
```python theme={null}
async def send_heartbeat(timeout: float = 30) -> None
```
A Sailbox stays alive without keep-alives, so the heartbeat sends
nothing; it only enforces `timeout_seconds`, terminating the
Sailbox and raising the cookbook's `SandboxTerminatedError` once
the budget has elapsed. `timeout` is part of the cookbook's
heartbeat signature and is unused here, since there is no request
for it to bound.
### cleanup
Terminate the backing Sailbox. Safe to call more than once
(termination is idempotent for an already-gone Sailbox); a
cancellation arriving mid-cleanup still lets the termination finish,
within a bounded grace, before propagating, so the Sailbox does not
stay running and billable.
```python theme={null}
async def cleanup() -> None
```
## tinker\_sandbox\_factory
Create a `TinkerSandbox` for a tinker-cookbook environment.
```python theme={null}
async def tinker_sandbox_factory(
env_dir: Union[str, Path],
timeout_seconds: Optional[float] = None,
*,
app: Optional[str] = None,
size: SailboxSize = "s",
image_ref: Optional[str] = None,
name_prefix: str = "tinker",
) -> TinkerSandbox
```
Pass this function (or a `functools.partial` of it, to preset the
keyword arguments) wherever the cookbook accepts a sandbox factory; being
a module-level function, it pickles by reference, so it survives the
cookbook's process boundaries.
`image_ref` takes precedence when given. Otherwise, the image comes
from `[environment].docker_image` in the `task.toml` next to
`env_dir`, or from `env_dir / "Dockerfile"` with `env_dir` as its
build context. Docker-style short references are accepted
(`python:3.11`). The registry image or Dockerfile must produce a
Debian- or Ubuntu-based filesystem. The Sailbox is created in the
`app` app (default `$SAIL_APP` or `"tinker"`, created on first
use) and `timeout_seconds` becomes the sandbox's lifetime budget.
## SailboxEnvironment
A Harbor environment whose commands run in a Sailbox.
Extends `ComposeServiceOpsMixin`, `BaseEnvironment`.
`start` creates the Sailbox from the task's image: a declared
`docker_image` is pulled from its registry (Docker-style short
references are accepted), and a task that ships an
`environment/Dockerfile` instead has it built into a Sailbox image.
Task and persistent environment variables apply to every command, and
a prebuilt-image task's `environment/` directory is uploaded into its
working directory, the same way Harbor's other cloud providers do.
A task that ships an `environment/docker-compose.yaml` runs as a
Docker Compose project inside the Sailbox: the services' images are
pulled or built there, commands run in the task's `main` service, and
per-service operations (exec, download, stop) reach the other services.
`stop` puts the Sailbox to sleep, so a kept environment stops billing
and resumes with its filesystem intact on the next `start`; deleting
the environment terminates the Sailbox. In Compose mode the whole
project sleeps, wakes, and terminates with the Sailbox.
GPUs, TPUs, Windows, IPv6 allowlist entries, and a no-network or
allowlist policy for a Compose task (whose bring-up must pull images
over the network) are declared unsupported, so Harbor rejects a task
that needs them up front rather than running it degraded. A plain
task's no-network or allowlist policy is honored, and a task with
default public networking is unaffected. Host mount specs are accepted
and unused outside Compose mode, as Harbor permits for cloud providers
that do not bind-mount; in Compose mode they are bound into the `main`
service from the Sailbox's own filesystem.
### start
Create the environment's Sailbox, building its image if needed.
```python theme={null}
async def start(force_build: bool) -> None
```
Starting an environment that was stopped without deletion resumes
its sleeping Sailbox, filesystem intact, instead of creating a new
one.
### exec
Run a shell command in the environment and return its result.
```python theme={null}
async def exec(
command: str,
cwd: Optional[str] = None,
env: Optional[Mapping[str, str]] = None,
timeout_sec: Optional[float] = None,
user: Optional[Union[str, int]] = None,
) -> Any
```
In Compose mode the command runs inside the `main` service;
otherwise it runs in the Sailbox itself.
### upload\_file
Copy a local file in, keeping its permission bits.
```python theme={null}
async def upload_file(source_path: Union[str, Path], target_path: str) -> None
```
### download\_file
Copy a file out to the local filesystem.
```python theme={null}
async def download_file(source_path: str, target_path: Union[str, Path]) -> None
```
### upload\_dir
Copy a local directory's contents in.
```python theme={null}
async def upload_dir(source_dir: Union[str, Path], target_dir: str) -> None
```
### download\_dir
Copy a directory's contents out to a local directory.
```python theme={null}
async def download_dir(source_dir: str, target_dir: Union[str, Path]) -> None
```
### stop
Stop the environment: put its Sailbox to sleep, or terminate it.
```python theme={null}
async def stop(delete: bool = False) -> None
```
A sleeping Sailbox stops billing and keeps its state, so starting
the same environment again resumes it; deletion is permanent. In
Compose mode the project's containers sleep and wake with the
Sailbox, and termination takes the whole project with it.
## Config
SDK configuration resolved from the environment and `~/.sail`.
Sail resolves the API key and service endpoints, with environment
variables taking precedence over the stored `~/.sail` credentials. Set
`SAIL_API_KEY` (or run `sail auth login`) to authenticate.
`SAIL_API_URL`, `SAILBOX_API_URL`, and `SAILBOX_INGRESS_URL`
override individual endpoints, for custom or
self-hosted stacks. Configuration is resolved once per process; in a
long-lived process, call `sail.reset_transports()` after changing these
variables. `ingress_base` and `ingress_scheme` describe how a
listener's public URL is built from the Sailbox id and port when the
server does not return one: `"path"` addresses
`/_sailbox/{id}/{port}`, `"subdomain"` addresses
`-.`.
### from\_env
Load SDK config, raising `ValueError` when no API key is configured.
```python theme={null}
@classmethod
def from_env() -> Config
```
### from\_env\_optional\_api\_key
Load SDK config without requiring an API key.
```python theme={null}
@classmethod
def from_env_optional_api_key() -> Config
```
Like `from_env()` but does not raise when no key is configured, so
endpoints still resolve for paths that do not need to authenticate (such
as building a listener's public URL).
## Types
Plain data types accepted by and returned from the calls above.
### IngressPort
A guest port to expose for ingress.
`protocol` selects how the port is published:
* `"http"` (the default) exposes the port as an HTTP service with a stable
HTTPS URL.
* `"tcp"` exposes the port as a byte-transparent raw-TCP service reachable
at a stable host and port by any TCP client (for example a database client
such as `psql -h -p `).
`Sailbox.create(ingress_ports=...)` also accepts a bare `int` as
shorthand for `IngressPort(port)` (i.e. an HTTP port).
`allowlist` restricts which sources may connect to *this* port. An entry
that reads as an address or a range (e.g. `["203.0.113.0/24"]`) matches
source IPs; every other entry is a Sail app name whose Sailboxes may
connect. An app name cannot read as an address or a range, and cannot
contain a `/`. An address must not carry an IPv6 zone, such as
`fe80::1%eth0`, which names an interface on one machine rather than a
source.
App-name entries are supported on `"http"` listeners only. Raw-TCP
connections carry no source app identity, so `"tcp"` allowlists must
be addresses or ranges. Each port carries its own allowlist. An
empty/omitted list means any source may connect.
Exposing a well-known unauthenticated service port (e.g. a database) as raw
TCP without an explicit `allowlist` is rejected. Use source restrictions,
or pass `["0.0.0.0/0", "::/0"]` to explicitly allow every source.
**Attributes:**
| Attribute | Type | Description |
| ------------ | --------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `guest_port` | `int` | The in-guest port to expose (1-65535). |
| `protocol` | `IngressProtocol` | `"http"` (the default) or `"tcp"`. |
| `allowlist` | `Optional[List[str]]` | Sources allowed to reach the port: an address or a range, or a Sail app name on an `"http"` listener. Empty allows all. |
### HttpEndpoint
The routable HTTPS address of an `"http"` listener.
**Attributes:**
| Attribute | Type | Description |
| --------- | ----- | ----------------------- |
| `url` | `str` | The routable HTTPS URL. |
### TcpEndpoint
The host and port to connect to for a `"tcp"` listener.
**Attributes:**
| Attribute | Type | Description |
| --------- | ----- | ----------------- |
| `host` | `str` | Hostname to dial. |
| `port` | `int` | Port to dial. |
### Listener
An exposed guest port.
`guest_port` is the guest port you exposed. `endpoint` is how you reach it: an
`HttpEndpoint` for `"http"` listeners or a `TcpEndpoint` for `"tcp"`
listeners. It is `None` until the listener is routable.
**Attributes:**
| Attribute | Type | Description |
| -------------- | -------------------------------------------- | ------------------------------------------------------- |
| `guest_port` | `int` | The in-guest port traffic is forwarded to. |
| `protocol` | `str` | Wire protocol exposed (`"http"` or `"tcp"`). |
| `route_status` | `str` | Status of the listener's ingress route. |
| `endpoint` | `Optional[Union[HttpEndpoint, TcpEndpoint]]` | How to reach the listener; `None` until it is routable. |
### SailboxPage
One page of `Sailbox.list_page` results plus the server's pagination envelope.
**Attributes:**
| Attribute | Type | Description |
| ---------- | --------------- | ------------------------------------------ |
| `items` | `List[Sailbox]` | The Sailboxes on this page. |
| `limit` | `int` | The page size that was applied. |
| `offset` | `int` | The offset that was applied. |
| `total` | `int` | Total matching Sailboxes across all pages. |
| `has_more` | `bool` | Whether more results exist past this page. |
### SailboxCheckpoint
A durable checkpoint handle that can be used to start new Sailboxes.
**Attributes:**
| Attribute | Type | Description |
| ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `checkpoint_id` | `str` | The checkpoint id. |
| `sailbox_id` | `str` | The Sailbox the checkpoint was taken from. |
| `checkpoint_generation` | `int` | Checkpoint generation captured by this checkpoint. |
| `expires_at` | `Optional[datetime]` | When the checkpoint expires: seven days out unless `ttl_seconds` asked for a different window. Starting a Sailbox from it after that fails. |
| `status` | `str` | The source Sailbox's status after checkpointing. |
### UpgradeResult
The outcome of a Sailbox runtime upgrade.
**Attributes:**
| Attribute | Type | Description |
| --------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `applied` | `bool` | True when no upgrade is left to apply, either because the Sailbox took one just now or because it was already current. False when the upgrade is recorded and takes effect the next time the Sailbox wakes. |
| `status` | `str` | Lifecycle status of the Sailbox after the upgrade call. |
### SailboxDeprecation
Actionable notice that a Sailbox's runtime should be upgraded.
**Attributes:**
| Attribute | Type | Description |
| ---------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `deadline` | `str` | Date after which the Sailbox may be upgraded automatically on its next wake, stopping running processes and clearing in-memory state. |
| `message` | `str` | Upgrade guidance from the server. |
### SailboxVolumeMount
One volume attached to a Sailbox and where it is mounted.
**Attributes:**
| Attribute | Type | Description |
| ------------ | ----- | ------------------------------------------------------------- |
| `volume_id` | `str` | Identifier of the mounted volume. |
| `mount_path` | `str` | Absolute path inside the Sailbox where the volume is mounted. |
### AutoSleep
When Sail may put a Sailbox to sleep on its own.
Sail sleeps Sailboxes that are doing nothing, freeing their memory and
waking them the moment anything needs them again. Waking takes a
couple of seconds: free for a batch job, unwelcome if someone is waiting
at a terminal.
Build one with `default`, `not_before`, or `never`.
An explicit idle window replaces Sail's default and can make automatic
sleep happen sooner or later. The window only controls when Sail may
consider sleeping the Sailbox. Sail still sleeps it only when it sits fully
idle: no busy process, no imminent timer, nothing a sleep would interrupt.
Calling `Sailbox.sleep` yourself is unaffected, and so are
`Sailbox.pause`, `Sailbox.resume`, and scheduled wakes.
**Attributes:**
| Attribute | Type | Description |
| -------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `automatic` | `bool` | False stops Sail sleeping the Sailbox on its own. |
| `min_seconds_before_sleep` | `Optional[int]` | Use this idle window instead of Sail's default. Once it passes, Sail may sleep the Sailbox only when it is fully idle. `None` uses Sail's default. |
#### default
Let Sail decide, on its own timing. The default.
```python theme={null}
@classmethod
def default() -> AutoSleep
```
#### not\_before
Let Sail decide, but not before this much idle time.
```python theme={null}
@classmethod
def not_before(seconds: int) -> AutoSleep
```
This idle window replaces Sail's default. Whole-second values from 1
through 3600 are accepted. A value of 0 is the same as
`default`, and is stored and read back that way. Other numeric
values are rejected; use `never` instead.
#### never
Stop Sail sleeping a Sailbox on its own.
```python theme={null}
@classmethod
def never() -> AutoSleep
```
### NetworkPolicy
How a Sailbox may reach the network, chosen at creation and fixed for
its life.
Extends `str`, `Enum`.
`PUBLIC` leaves network access open and is the default. `NO_NETWORK`
cuts the Sailbox off from other hosts and the internet: it cannot make
outbound connections or expose inbound services, and name resolution
does not work. Running commands is unaffected (`exec` and the shell
reach the Sailbox over a Sail-internal path, not its network), and
mounted volumes and other platform features it was created with keep
working. To allow only some destinations, pass a
`NetworkAllowlist` as the policy instead.
### NetworkAllowlist
Restrict the destinations a Sailbox can reach, chosen when it is created
and fixed for its whole life.
Each entry is a hostname, a `*.` wildcard hostname (one extra name part),
an IPv4 address, or an IPv4 range in CIDR form such as `203.0.113.0/24`.
Give at least one entry and at most 128; a list that breaks the entry rules
is rejected before the Sailbox is created. Only connections the Sailbox
opens are limited, so `ingress_ports` and SSH still work. The
[network policy guide](https://docs.sailresearch.com/sailboxes-network-policy)
has the entry rules and what each entry allows.
Pass the hosts as a list:
```python theme={null}
sail.NetworkAllowlist(["api.example.com", "*.internal.example.com", "203.0.113.0/24"])
```
**Attributes:**
| Attribute | Type | Description |
| --------------- | --------------- | ----------------------------------------------------- |
| `allowed_hosts` | `Sequence[str]` | The destinations the Sailbox may reach; at least one. |
### NetworkPolicyInfo
A Sailbox's network policy as reported by `Sailbox.get` and
`Sailbox.list`.
`mode` is the policy mode as a string (`"no_network"` or
`"allowlist"`) rather than a `NetworkPolicy`, so a mode this
version of the SDK does not know is still reported instead of reading
back as public. Because `NetworkPolicy` is a string enum,
`mode == NetworkPolicy.NO_NETWORK` still holds for the modes this
version knows. `allowed_hosts` carries the destinations in allowlist
mode.
**Attributes:**
| Attribute | Type | Description |
| --------------- | ----------------- | ---------------------------------------------------------------- |
| `mode` | `str` | The policy mode, for example `"no_network"`. |
| `allowed_hosts` | `Tuple[str, ...]` | Allowlist destinations when the mode uses them; empty otherwise. |
### OutputMode
What happens when a stream's output buffer fills. Each stream has its
own buffer, 1 MiB by default (`output_buffer_bytes` on
`Sailbox.exec`). Accepted as the enum or its string value.
Extends `str`, `Enum`.
Sending `cancel()`, and the exec `timeout`, end every pause: from then
on each stream keeps only its most recent bytes, so a reader more than a
buffer behind skips. A command that ignores the cancel signal keeps
running that way; `cancel(force=True)` stops it. The command keeps its
original timeout. If this handle attaches to a command launched earlier
under the same `idempotency_key`, this handle's pause deadline starts
when the attachment succeeds, so it can release the pauses one full
timeout after that; `cancel()` and `close()` release them at once.
**Attributes:**
| Attribute | Type | Description |
| --------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTO` | | A stream you are reading pauses the command when its buffer fills and resumes as you read, like a pipe. A stream you are not reading never pauses the command and keeps only its most recent bytes. The default. |
| `PIPE` | | Both streams pause the command when their buffer fills, until you read them, so nothing is lost while you are late to start reading. Read both streams, or the command stays paused on the one you ignore. Once a reader is released, its stream goes back to keeping only its most recent bytes. Not available with `pty=True`. |
| `TAIL` | | The command never pauses for you. Each stream keeps only its most recent bytes, even while you are reading it, so a reader that falls behind skips output without notice; `stdout_truncated` and `stderr_truncated` say only that the result holds less than the command wrote. A pty command always behaves this way. |
### DirEntry
One entry in a directory listing from `SailboxFs.ls`.
**Attributes:**
| Attribute | Type | Description |
| --------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
| `name` | `str` | The entry's base name, with no directory prefix. |
| `type` | `Literal["file", "directory", "symlink", "other"]` | The entry's own kind. A symlink is `"symlink"` regardless of what it points at. |
| `size` | `int` | Size in bytes as reported by the guest. |
| `modified_time` | `float` | Last-modified time as a Unix timestamp in seconds (with a fractional part). |
| `mode` | `int` | Unix permission bits, e.g. `0o644`. The file-type bits are not included. |
### ExecResult
The completed result of a Sailbox command. It holds the most recent
output of each stream (up to the exec's `output_buffer_bytes`, 1 MiB by
default), the exit code, timeout status, and truncation flags.
**Attributes:**
| Attribute | Type | Description |
| ------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stdout` | `str` | The most recent standard output, up to the exec's buffer size (1 MiB by default). Older bytes are dropped when the command wrote more; see `stdout_truncated`. |
| `stderr` | `str` | The most recent standard error, up to the exec's buffer size (1 MiB by default; see `stderr_truncated`). |
| `exit_code` | `int` | The command's exit code. |
| `timed_out` | `bool` | Whether the command was killed for exceeding its timeout. |
| `stdout_truncated` | `bool` | The command wrote more stdout than `stdout` holds, so older bytes are missing. |
| `stderr_truncated` | `bool` | Like `stdout_truncated`, for stderr. |
### PtyConfig
The pseudo-terminal a `pty` exec runs under. Every field has a
default, so `PtyConfig()` (or `pty=True`) is a usable terminal.
**Attributes:**
| Attribute | Type | Description |
| --------- | --------------- | ---------------------------------------------------------------- |
| `term` | `Optional[str]` | `$TERM` for the pty; `None` takes the default, `xterm-256color`. |
| `cols` | `int` | Initial width in columns; `0` takes the default, 80. |
| `rows` | `int` | Initial height in rows; `0` takes the default, 24. |
### HttpPolicySummary
A policy as returned by `HttpPolicy.list`, with usage counts but
without the document. Fetch the full policy with `HttpPolicy.get`.
**Attributes:**
| Attribute | Type | Description |
| ------------------------- | ----------------- | ------------------------------------------------------- |
| `id` | `str` | The policy's stable identifier. |
| `name` | `str` | The policy's name. |
| `host_count` | `int` | How many hosts the document covers. |
| `rule_count` | `int` | How many rules the document carries across every host. |
| `referenced_secret_names` | `Tuple[str, ...]` | The secret names the document refers to. |
| `attachment_count` | `int` | How many Sailboxes the policy is currently attached to. |
| `created_at` | `datetime` | When the policy was created. |
| `updated_at` | `datetime` | When the policy's name last changed. |
### GuestPath
A path inside the guest: a str or a `PurePosixPath`. Guest paths are
remote POSIX paths, independent of the local platform.
### FileContents
The contents of one file passed to `write` or `write_files`: a str
(written as UTF-8), a bytes-like object, or a readable file-like object
that is read to its end first.
### DEFAULT\_LIST\_LIMIT
Default page size for `Sailbox.list` and `Sailbox.list_page`.
```python theme={null}
DEFAULT_LIST_LIMIT: int
```
## Errors
Exceptions raised by this SDK surface. Every one of them extends `SailError`, so `except sail.SailError` catches them all. `SailDeprecationWarning` is the one entry below that is not an error: it is a warning the SDK emits through Python's `warnings` module.
### SailError
Base class for Sail SDK errors.
Every operation failure the SDK raises derives from this class, and the
classes that match a Python builtin also inherit it (for example
`NotFoundError` is a `LookupError`), so `except sail.SailError` and
builtin-based handlers both work. A few argument-type mistakes raise the
plain builtin `TypeError`.
**Attributes:**
| Attribute | Type | Description |
| ------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `retryable` | `bool` | Whether retrying the same call may succeed. Advisory and conservative: `True` for transport failures and transient API statuses, `False` where the failure is deterministic. |
| `status_code` | `Optional[int]` | HTTP status code, on API and creation failures; `None` elsewhere, so a catch-all handler can inspect it without narrowing first. |
| `rpc_status` | `str` | Transport status on exec failures; empty elsewhere. The attribute name stays `rpc_status` for compatibility. |
| `body` | `Optional[Any]` | Parsed response body, on API and creation failures; `None` elsewhere. |
### NotFoundError
Raised when a requested Sailbox, app, volume, checkpoint, secret, or
HTTP policy is not found.
Extends `SailError`, `LookupError`.
### PermissionDeniedError
Raised for a missing or invalid API key, or insufficient scope.
Extends `SailError`, `PermissionError`.
### InvalidArgumentError
Raised when an argument or the SDK configuration is rejected as invalid.
Extends `SailError`, `ValueError`.
### InternalError
Raised for an unexpected internal SDK failure.
Extends `SailError`, `RuntimeError`.
### FileNotFoundError
Raised when a guest file operation references a path that does not exist.
Extends `SailError`, `builtins.FileNotFoundError`.
### BrokenPipeError
Raised when a stdin write hits a command that already finished.
Extends `SailError`, `builtins.BrokenPipeError`.
### TimeoutError
Raised when a transport attempt exceeds its deadline.
Extends `SailError`, `builtins.TimeoutError`.
### TransportError
Raised when the transport cannot establish or maintain a connection.
Extends `SailError`, `ConnectionError`.
### ApiError
Raised for any other non-2xx API response.
Extends `SailError`, `RuntimeError`.
`RuntimeError` inheritance keeps generic retry-on-RuntimeError loops
working; prefer branching on `retryable`.
### SecretInUseError
Raised when deleting a secret that HTTP policies still refer to.
Extends `ApiError`.
Delete those policies first, then delete the secret.
### HttpPolicyInUseError
Raised when deleting an HTTP policy that is still attached to a Sailbox.
Extends `ApiError`.
Clear or replace the policy on every Sailbox first, then delete it.
### SailDeprecationWarning
Warning that a Sail client or Sailbox runtime should be upgraded.
Extends `UserWarning`.
### SailboxError
Base class for Sailbox-specific SDK errors.
Extends `SailError`.
### SailboxCreationError
Raised when Sailbox creation fails.
Extends `SailboxError`.
### ImageBuildError
Raised when a custom image build fails.
Extends `SailboxError`.
### SailboxExecutionError
Base class for Sailbox exec-related SDK errors.
Extends `SailboxError`.
### SailboxTerminatedError
Raised when the Sailbox no longer exists.
Extends `SailboxExecutionError`.
### SailboxExecRequestNotFoundError
Raised when a wait references an unknown exec request.
Extends `SailboxExecutionError`.
### SailboxHostLostError
Raised when the machine hosting your Sailbox failed before the command finished.
Extends `SailboxExecutionError`.
The command may have run only partially, and its output is gone. The run
cannot be resumed. Calling `Sailbox.exec` again starts it over from the
beginning, so any side effects the partial run applied will happen again.
The Sailbox itself recovers automatically, so you do not need to resume it.
### SailboxFunctionError
Raised when a Python function fails while running in a Sailbox.
Extends `SailboxExecutionError`.
### SailboxFunctionSerializationError
Raised when a Python function payload or result cannot be serialized.
Extends `SailboxExecutionError`.
### CommandFailedError
Raised by `run(check=True)` when the command exits nonzero or times out.
Extends `SailboxExecutionError`.
Carries the completed `sail.ExecResult` as `result`.
### VoyageError
Base class for Voyage SDK errors.
Extends `SailError`.
### VoyageHTTPError
Raised when the Voyage API returns an HTTP error.
Extends `VoyageError`.
### VoyageNotFoundError
Raised when a Voyage cannot be found for the current API key.
Extends `VoyageHTTPError`.
### InferenceError
Base class for Sail inference wrapper errors.
Extends `SailError`.
### InferenceHTTPError
Raised when a Sail inference endpoint returns an HTTP error.
Extends `InferenceError`.
# Rust SDK
Source: https://docs.sailresearch.com/reference/rust-sdk
Rust SDK installation and API reference on docs.rs
The Sail Rust SDK (`sail-rs` on crates.io) creates and drives Sailboxes from
Rust: lifecycle, streaming exec, file transfer, ingress, and credential
injection. Behavior matches the [Python](/reference/python-sdk) and
[TypeScript](/reference/typescript-sdk) SDKs.
## Install
```toml theme={null}
[dependencies]
sail-rs = "0.11.2"
# tokio must be a direct dependency to write #[tokio::main]:
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
The crate is published as `sail-rs` and imported as `sail`:
```rust theme={null}
use sail::Client;
```
Adding the crate does not install the `sail` CLI. Install the CLI separately
with `curl -fsSL https://cli.sailresearch.com/install.sh | sh`, or see
[Install the CLI](/reference/cli).
## Configure
Set `SAIL_API_KEY` in the environment; the SDK also reads the credential
`sail auth login` stores under `~/.sail`. Construct with `Client::from_env()`,
or use `Client::builder(api_key)` for explicit configuration. See
[Configuration](/reference/sdk-configuration).
## Quickstart
```rust theme={null}
use sail::{Client, CreateSailboxRequest, RunOptions, SailError};
#[tokio::main]
async fn main() -> Result<(), SailError> {
let client = Client::from_env()?;
// Look up (or create) the app your Sailboxes belong to.
let app = client
.find_app("rust-quickstart", /* mint_if_missing */ true)
.await?;
// Create a Sailbox; the default request uses the prebuilt Debian base image.
let sb = client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id,
name: "quickstart".into(),
..Default::default()
},
/* timeout */ None,
)
.await?;
// Run a command, then terminate the Sailbox whether or not the run failed.
let run = sb
.run_shell("echo hello from the guest", RunOptions::default())
.await;
sb.terminate().await?;
print!("{}", run?.stdout);
Ok(())
}
```
## Runtime
Every method is `async`, driven on a Tokio runtime. Async hosts await the
methods directly. Synchronous code can drive any method with `sail::block_on`,
which runs it to completion on a shared internal runtime. `Client` is cheap to
clone (it shares its connection pools and config behind an `Arc`), so clone it
to share across tasks.
## Surface
Voyages (agent tracing) and inference calls are Python-only; the Rust SDK
covers the full Sailbox surface. See the [Voyages reference](/voyages-sdk).
`create_sailbox` and `create_from_checkpoint` return a `Sailbox`, and
`client.sailbox(id)` binds an existing id without a network call. Every
per-Sailbox operation is a method on it: lifecycle (`info` / `terminate` /
`pause` / `sleep` / `set_auto_sleep` / `resume` / `upgrade`), `checkpoint`,
one-shot `run` / `run_shell`, streaming `exec` / `exec_shell`, filesystem
helpers under `fs()` (one-shot `read` / `write`, streaming `read_stream` /
`write_stream`, directory transfer `upload_dir` / `download_dir`, and
`mkdir` / `remove` / `exists` / `ls`), an interactive `shell`, listeners
(`expose` / `unexpose` / `listeners` / `listener` / `wait_for_listener` /
`ingress_auth_headers`), SSH (`enable_ssh`), and HTTP policy attachment
(`set_http_policy` / `http_policy` / `clear_http_policy`).
`run` and `run_shell` return only the most recent `output_buffer_bytes` of each
stream (1 MiB by default), with the `*_truncated` flags set when older output
was dropped. To get every byte of a stream, use `exec` or `exec_shell` and take
its reader (`reader` or `reader_async`) right after the call returns; Sail then
pauses the command if you fall behind (until a cancel or the timeout ends the
pauses; see `OutputMode`), and dropping the reader releases the stream. A
stream without a reader never pauses the command, and Sail keeps only
its most recent bytes. `ExecOptions::output_mode` changes that: `OutputMode::Pipe`
holds both streams until you take their readers, and `OutputMode::Tail` never
pauses the command. You can read one stream without holding the other. If you
hold both readers, read them at the same time from two tasks. The
[Exec process](/sailbox-sdk#sailboxexecprocess) section has the details.
Org-scoped operations live on the `Client`: `list_sailboxes`,
`create_from_checkpoint`, volumes, apps, the image build pipeline, and secrets
and HTTP policies for [credential injection](/sailboxes-credentials).
Volumes are currently in Alpha. To pilot them, reach out in the [Sail
Slack](https://join.slack.com/t/sailresearchcrew/shared_invite/zt-41pdcym9j-UU0Ey~A~r6n2H0DQVQsQHQ).
## API reference
The full API reference is generated by rustdoc and published on docs.rs. It
covers every module, type, and method in the crate, with source links and
cross-references.
Open the complete `sail-rs` reference.
# Configuration
Source: https://docs.sailresearch.com/reference/sdk-configuration
How the SDK resolves endpoints and retries: environment variables and sail.Config
Most applications configure the SDKs entirely through environment variables.
Set `SAIL_API_KEY` (or run `sail auth login` once) and you're done; the
endpoint variables below matter only for custom or self-hosted deployments.
Every SDK reads the same variables and the same credential store.
Use `export SAIL_API_KEY=sk_...` for SDK scripts, CI jobs, and background
agents. If `SAIL_API_KEY` is unset, the SDKs fall back to the credential
`sail auth login` stores under `~/.sail`. The environment variable always wins.
## Endpoint overrides
Each of these overrides one endpoint, for custom or self-hosted stacks:
* `SAIL_API_URL`: the main Sail API. Default
`https://api.sailresearch.com`.
* `SAILBOX_API_URL`: the Sailbox API. Default
`https://sailbox-api.sailresearch.com`.
* `SAILBOX_INGRESS_URL`: the base URL used to build an exposed listener's
public address when the service does not return one.
Configuration is read when a client is created. To pick up changed variables
in a long-lived process: in Python, call `sail.reset_transports()` (the SDK
resolves once per process); in TypeScript, construct a new client with
`Client.fromEnv()` and repoint the object model with `setDefaultClient`; in
Rust, construct a new client with `Client::from_env()`.
## Worker threads
The Python and TypeScript SDKs run their network calls on a small pool of
background threads shared by the whole process, and the Rust SDK uses the same
pool for its blocking calls. The pool is sized to the machine: one thread per
CPU, with at least 2 and at most 8. That is plenty for most applications,
including ones that drive many Sailboxes concurrently, because the threads
spend nearly all of their time waiting on the network.
Rust code that awaits the async API does not use this pool. Those calls run on
your application's own async runtime, so the setting below does not affect
them.
Set `SAIL_RUNTIME_THREADS` to override the pool size, for example to give a
large fan-out workload more headroom or to keep a constrained process at
exactly one thread. Values from 1 to 256 are accepted; anything else is
ignored and the default applies. The variable is read once per process, when
the SDK first performs work, so set it before your program starts using the
SDK.
## Inspecting the configuration
Each SDK exposes the configuration it resolved from the environment:
```python Python theme={null}
import sail
config = sail.Config.from_env()
print(config.api_url) # https://api.sailresearch.com
```
```typescript TypeScript theme={null}
import { resolveConfig } from "@sailresearch/sdk";
const config = resolveConfig();
console.log(config.apiUrl); // https://api.sailresearch.com
```
```rust Rust theme={null}
use sail::Client;
let client = Client::from_env()?;
println!("{}", client.config().api_url); // https://api.sailresearch.com
```
### Python constructors
```python theme={null}
@classmethod
def from_env() -> Config
@classmethod
def from_env_optional_api_key() -> Config
```
* `Config.from_env()` resolves the configuration and **requires** an API key
(from `SAIL_API_KEY` or the stored `sail auth login` credential), raising
`ValueError` if none is found.
* `Config.from_env_optional_api_key()` is the same, but does not require an API
key. Use it when you want a `Config` without a key, for example so
`sail.voyage` can run in no-op mode.
For the Voyage and agent attribution environment variables, see the [Voyages
environment table](/voyages-sdk#environment-variables).
## Retries
The SDKs retry transient failures automatically. By default they make up to 3
attempts with exponential backoff and full jitter, honoring a server
`Retry-After` when one is present:
* **502 / 503 / 504** are retried.
* **429** is retried only when the response includes a valid `Retry-After`.
* **500** and other statuses are surfaced immediately, without a retry.
Mutations that are not safe to repeat skip these retries and make a single
attempt. `exec` retries transient failures against a waking or migrating
Sailbox for up to ten minutes, reusing its idempotency key so a retried
launch still runs the command once.
# TypeScript SDK
Source: https://docs.sailresearch.com/reference/typescript-sdk
TypeScript SDK installation and full reference
The Sail TypeScript SDK (`@sailresearch/sdk` on npm) runs on Node 22+ and Bun.
Sail also provides [Python](/reference/python-sdk) and
[Rust](/reference/rust-sdk) SDKs.
## Install
```bash npm theme={null}
npm install @sailresearch/sdk
```
```bash pnpm theme={null}
pnpm add @sailresearch/sdk
```
```bash bun theme={null}
bun add @sailresearch/sdk
```
The SDK supports Linux x64/arm64 (glibc and musl), macOS x64/arm64, and
Windows x64.
The Sail API warns when your SDK version is nearing the end of its support
window. The SDK prints that warning to stderr once per process. A version past
the end of its support window is rejected with an upgrade error before any
operation runs. Upgrade with `npm install @sailresearch/sdk@latest`.
## Configure
Set `SAIL_API_KEY` in the environment; the SDK also reads the credential
`sail auth login` stores under `~/.sail`. The statics on `Sailbox`, `App`, and
`Volume` use this configuration by default, or construct a `Client` explicitly
with `Client.fromConfig({ apiKey })`. See
[Configuration](/reference/sdk-configuration).
## Quickstart
```ts theme={null}
import { App, Sailbox } from "@sailresearch/sdk";
// Look up (or create) the app your sandboxes belong to.
const app = await App.find("example-app", { mintIfMissing: true });
// Boot a sandbox.
const sb = await Sailbox.create({ app, name: "worker-1" });
// Run a command and stream its output.
const proc = await sb.exec("echo hello && ls /");
for await (const chunk of proc.stdout) process.stdout.write(chunk);
const result = await proc.wait();
console.log("exit code:", result.exitCode);
// Move files.
await sb.fs.write("/tmp/note.txt", "hi\n");
const contents = await sb.fs.read("/tmp/note.txt");
// Expose a port and wait until it is reachable.
await sb.expose(8080, { protocol: "http" });
const listener = await sb.waitForListener(8080);
if (listener.endpoint?.kind === "http") {
console.log("reachable at:", listener.endpoint.url);
}
// Clean up (see also pause / sleep / resume / checkpoint).
await sb.terminate();
```
## Errors
Every failure the SDK recognizes extends `SailError`, so one
`catch (e) { if (e instanceof SailError) }` handles them; a truly unexpected
error is rethrown unchanged. Subclasses like `NotFoundError` and
`SailboxExecutionError` match specific failures, every error carries an
advisory `retryable` flag, and `isSailError()` is the realm-safe check.
See [Errors](/sailbox-sdk-errors).
## Reference
The docs below are auto-generated.
## Sailbox
A sandbox (Sailbox): the primary object agent harnesses work with. Create one
with [Sailbox.create](#create-1), run commands with [exec](#exec-1), move files with
[fs](#fs), expose ports with [expose](#expose), and
manage its lifecycle. The statics use a default env-configured client unless
you pass one.
### Example
```ts theme={null}
import { App, Sailbox } from "@sailresearch/sdk";
const app = await App.find("example-app", { mintIfMissing: true });
const box = await Sailbox.create({ app, name: "worker-1" });
const proc = await box.exec(["bash", "-lc", "echo hello"]);
console.log(await proc.stdout.text());
await box.terminate();
```
### Accessors
#### appId
##### Get Signature
> **get** **appId**(): `string` | `undefined`
Identifier of the owning app.
##### Returns
`string` | `undefined`
#### appName
##### Get Signature
> **get** **appName**(): `string` | `undefined`
Name of the owning app.
##### Returns
`string` | `undefined`
#### architecture
##### Get Signature
> **get** **architecture**(): `string` | `undefined`
CPU architecture (for example `arm64`).
##### Returns
`string` | `undefined`
#### autoSleep
##### Get Signature
> **get** **autoSleep**(): [`AutoSleep`](#autosleep-4) | `undefined`
When Sail may sleep this Sailbox on its own: from the latest
[Sailbox.get](#get-1) snapshot, or your own last
[Sailbox.setAutoSleep](#setautosleep) through this object. `undefined` before
either; a Sailbox created by [Sailbox.fromCheckpoint](#fromcheckpoint) inherits its
source's preference, so call
[Sailbox.get](#get-1) to learn an inherited value.
##### Returns
[`AutoSleep`](#autosleep-4) | `undefined`
#### checkpointGeneration
##### Get Signature
> **get** **checkpointGeneration**(): `number` | `undefined`
Checkpoint generation counter as of the snapshot.
##### Returns
`number` | `undefined`
#### client
##### Get Signature
> **get** **client**(): [`Client`](#client)
The underlying [Client](#client).
##### Returns
[`Client`](#client)
#### cpuRequestedVcpu
##### Get Signature
> **get** **cpuRequestedVcpu**(): `number` | `undefined`
Requested CPU, in vCPUs.
##### Returns
`number` | `undefined`
#### cpuUsedVcpu
##### Get Signature
> **get** **cpuUsedVcpu**(): `number` | `undefined`
Current CPU usage, in vCPUs, as of the snapshot.
##### Returns
`number` | `undefined`
#### createdAt
##### Get Signature
> **get** **createdAt**(): `Date` | `undefined`
When the Sailbox was created.
##### Returns
`Date` | `undefined`
#### createdByUserId
##### Get Signature
> **get** **createdByUserId**(): `string` | `undefined`
The user whose credential created this Sailbox (for a restore, the user
who ran it). `undefined` for service-key creates.
##### Returns
`string` | `undefined`
#### deprecation
##### Get Signature
> **get** **deprecation**(): `SailboxDeprecation` | `undefined`
Actionable runtime deprecation notice, when an upgrade is needed.
##### Returns
`SailboxDeprecation` | `undefined`
#### diskRequestedBytes
##### Get Signature
> **get** **diskRequestedBytes**(): `number` | `undefined`
Requested disk, in bytes.
##### Returns
`number` | `undefined`
#### diskUsedBytes
##### Get Signature
> **get** **diskUsedBytes**(): `number` | `undefined`
Current disk usage, in bytes, as of the snapshot.
##### Returns
`number` | `undefined`
#### errorMessage
##### Get Signature
> **get** **errorMessage**(): `string` | `undefined`
Failure detail when the status is `failed`.
##### Returns
`string` | `undefined`
#### fs
##### Get Signature
> **get** **fs**(): [`SailboxFs`](#sailboxfs-1)
Filesystem operations on this Sailbox's guest: read and write files
(buffered or streaming), and directory helpers.
##### Returns
[`SailboxFs`](#sailboxfs-1)
#### guestSchemaVersion
##### Get Signature
> **get** **guestSchemaVersion**(): `number` | `undefined`
The Sailbox runtime schema version the Sailbox last booted with.
##### Returns
`number` | `undefined`
#### imageId
##### Get Signature
> **get** **imageId**(): `string` | `undefined`
Identifier of the image the Sailbox was created from.
##### Returns
`string` | `undefined`
#### lastCheckpointedAt
##### Get Signature
> **get** **lastCheckpointedAt**(): `Date` | `undefined`
When the most recent checkpoint was taken.
##### Returns
`Date` | `undefined`
#### memoryMib
##### Get Signature
> **get** **memoryMib**(): `number` | `undefined`
Configured memory, in MiB.
##### Returns
`number` | `undefined`
#### memoryRequestedBytes
##### Get Signature
> **get** **memoryRequestedBytes**(): `number` | `undefined`
Requested memory, in bytes.
##### Returns
`number` | `undefined`
#### memoryUsedBytes
##### Get Signature
> **get** **memoryUsedBytes**(): `number` | `undefined`
Current memory usage, in bytes, as of the snapshot.
##### Returns
`number` | `undefined`
#### name
##### Get Signature
> **get** **name**(): `string`
The Sailbox name.
##### Returns
`string`
#### networkPolicy
##### Get Signature
> **get** **networkPolicy**(): [`NetworkPolicyInfo`](#networkpolicyinfo) | `undefined`
The Sailbox's network policy, frozen at creation, from the latest
[Sailbox.get](#get-1) or [Sailbox.list](#list-2) snapshot. `undefined` means
public (unrestricted outbound access) or that no snapshot has been taken
yet; a present value carries the restrictive mode so you can verify what
is enforced, including on a Sailbox created from a checkpoint.
##### Returns
[`NetworkPolicyInfo`](#networkpolicyinfo) | `undefined`
#### sailboxId
##### Get Signature
> **get** **sailboxId**(): `string`
The Sailbox's stable identifier.
##### Returns
`string`
#### startedAt
##### Get Signature
> **get** **startedAt**(): `Date` | `undefined`
When the Sailbox first started running. A resume does not rewrite it.
##### Returns
`Date` | `undefined`
#### stateDiskSizeGib
##### Get Signature
> **get** **stateDiskSizeGib**(): `number` | `undefined`
Configured state-disk size, in GiB.
##### Returns
`number` | `undefined`
#### status
##### Get Signature
> **get** **status**(): [`SailboxStatus`](#sailboxstatus-1)
The lifecycle status as of the call that produced this handle (updated
by lifecycle calls on this instance). Use [Sailbox.get](#get-1) for a fresh
snapshot.
##### Returns
[`SailboxStatus`](#sailboxstatus-1)
#### updatedAt
##### Get Signature
> **get** **updatedAt**(): `Date` | `undefined`
When the Sailbox last changed.
##### Returns
`Date` | `undefined`
#### vcpuCount
##### Get Signature
> **get** **vcpuCount**(): `number` | `undefined`
Configured number of vCPUs.
##### Returns
`number` | `undefined`
#### visibility
##### Get Signature
> **get** **visibility**(): `string` | `undefined`
`"private"` when access is restricted to the creator; `undefined`/`"org"`
is the default org-wide access.
##### Returns
`string` | `undefined`
#### volumeMounts
##### Get Signature
> **get** **volumeMounts**(): `SailboxVolumeMount`\[] | `undefined`
Volumes attached to this Sailbox and the paths they are mounted at.
##### Returns
`SailboxVolumeMount`\[] | `undefined`
### Methods
#### checkpoint()
> **checkpoint**(`options?`): `Promise`\<[`SailboxCheckpoint`](#sailboxcheckpoint-1)>
Take a checkpoint of this Sailbox and prepare its clean start state. The
returned handle carries `expiresAt`, after which starting a Sailbox from it
fails. Sailboxes with volume mounts are not supported. Upgrade a Sailbox
that uses an older guest payload before creating a checkpoint handle.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------------- |
| `options` | [`CheckpointOptions`](#checkpointoptions) |
##### Returns
`Promise`\<[`SailboxCheckpoint`](#sailboxcheckpoint-1)>
#### clearHttpPolicy()
> **clearHttpPolicy**(): `Promise`\<`void`>
Clear this Sailbox's attached HTTP policy. The change applies to HTTPS
connections this Sailbox opens after the call; connections already open
keep the previous policy until they close. This also resolves when no
policy is attached.
##### Returns
`Promise`\<`void`>
#### enableSsh()
> **enableSsh**(`options?`): `Promise`\<[`SshEndpoint`](#sshendpoint) | `null`>
Enable SSH on this Sailbox: trust the org SSH CA, start `sshd`, and
expose guest port 22 as TCP once the CA-only daemon owns it. Org members
connect with a short-lived certificate (fetched by the `sail box ssh`
CLI); a private Sailbox accepts only its creator's certificates. Safe to
re-run. With `wait` (the default), polls until the endpoint is reachable
and returns it, throwing [TimeoutError](#timeouterror) if it is not within
`timeoutSeconds`; with `wait: false`, skips the probe and resolves
`null`.
##### Parameters
| Parameter | Type |
| --------- | --------------------------------------- |
| `options` | [`EnableSshOptions`](#enablesshoptions) |
##### Returns
`Promise`\<[`SshEndpoint`](#sshendpoint) | `null`>
#### exec()
> **exec**(`command`, `options?`): `Promise`\<[`ExecProcess`](#execprocess)>
Run a command and return a handle to the live process. A `string` command
is run via `/bin/sh -lc`; a `string[]` is exec'd directly. By default a
stream you are consuming pauses the command when you fall behind, so
nothing is lost until a cancel or the exec timeout ends the pauses, and a
stream you are not consuming keeps only its most
recent 1 MiB; start consuming right after this call returns to get every
byte. `outputMode` and `outputBufferBytes` in `options` change that (see
[ExecProcess](#execprocess)). `options` can also set a working directory or
detach the command (see [ExecOptions](#execoptions)). Stopping the command is the
caller's job via [ExecProcess.cancel](#cancel).
##### Parameters
| Parameter | Type |
| ---------- | -------------------------------- |
| `command` | `string` \| readonly `string`\[] |
| `options?` | [`ExecOptions`](#execoptions) |
##### Returns
`Promise`\<[`ExecProcess`](#execprocess)>
#### expose()
> **expose**(`guestPort`, `options?`): `Promise`\<[`Listener`](#listener-1)>
Expose a guest port at runtime. Re-exposing a port under the same
protocol sets its `allowlist` to what you pass, so pass the whole list
every time; passing none clears the restriction and reopens the port. The
returned listener carries the resolved endpoint but an `"unknown"` route
status: the response confirms configuration, not reachability;
[waitForListener](#waitforlistener-1) confirms the route is live.
##### Parameters
| Parameter | Type |
| ----------- | --------------------------------- |
| `guestPort` | `number` |
| `options` | [`ExposeOptions`](#exposeoptions) |
##### Returns
`Promise`\<[`Listener`](#listener-1)>
#### httpPolicy()
> **httpPolicy**(): `Promise`\<[`HttpPolicy`](#httppolicy) | `null`>
The HTTP policy attached to this Sailbox, or `null` when no policy is
attached.
##### Returns
`Promise`\<[`HttpPolicy`](#httppolicy) | `null`>
#### ingressAuthHeaders()
> **ingressAuthHeaders**(): `Promise`\<`Record`\<`string`, `string`>>
Ingress-identity headers for this Sailbox, as a name→value map.
##### Returns
`Promise`\<`Record`\<`string`, `string`>>
#### listener()
> **listener**(`guestPort`): `Promise`\<[`Listener`](#listener-1)>
Fetch one listener by guest port without waking the Sailbox.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `guestPort` | `number` |
##### Returns
`Promise`\<[`Listener`](#listener-1)>
#### listeners()
> **listeners**(): `Promise`\<[`Listener`](#listener-1)\[]>
List this Sailbox's listeners without waking it.
##### Returns
`Promise`\<[`Listener`](#listener-1)\[]>
#### pause()
> **pause**(): `Promise`\<`void`>
Pause this Sailbox in memory.
##### Returns
`Promise`\<`void`>
#### resume()
> **resume**(): `Promise`\<`void`>
Resume this Sailbox (updates [status](#status-2)).
##### Returns
`Promise`\<`void`>
#### run()
> **run**(`command`, `options?`): `Promise`\<[`ExecResult`](#execresult)>
Run a command to completion and return its buffered result: a one-shot
convenience over [exec](#exec-1) followed by [ExecProcess.wait](#wait). A
`string` command runs via `/bin/sh -lc`; a `string[]` is exec'd directly.
Set `env` in `options` to add environment variables; `cwd` sets the
working directory (string commands only, like [exec](#exec-1)); `signal`
force-cancels the command on abort (see [RunOptions](#runoptions)). The result's
stdout and stderr hold only the most recent `outputBufferBytes` of each
stream (1 MiB by default, up to 64 MiB), with `stdoutTruncated` and
`stderrTruncated` set when older output was dropped; the command never
pauses for unread output. While the first call is still running, a
second call with the same `idempotencyKey` takes over its output stream,
and the earlier call's result may come back truncated. To get every
byte, use [exec](#exec-1) and consume the stream (see
[ExecProcess](#execprocess)). `openStdin`, `pty`, `background`, and
`outputMode: "pipe"` are excluded from [RunOptions](#runoptions) and rejected at
runtime: run() waits for the command to finish and buffers its output,
so an interactive command would hang, a backgrounded one would return
the launcher's result, not the command's, and a pipe would pause forever
with nobody consuming it; use [exec](#exec-1) for those.
##### Parameters
| Parameter | Type |
| ---------- | -------------------------------- |
| `command` | `string` \| readonly `string`\[] |
| `options?` | [`RunOptions`](#runoptions) |
##### Returns
`Promise`\<[`ExecResult`](#execresult)>
#### setAutoSleep()
> **setAutoSleep**(`autoSleep`): `Promise`\<`void`>
Replace when Sail may sleep this Sailbox on its own.
Each call replaces the whole setting: switching to `{ automatic: false }`
clears any minimum wait set earlier, and switching back does not restore
it. Calling [Sailbox.sleep](#sleep) yourself is unaffected, and so are
`pause`, `resume`, and scheduled wakes.
##### Parameters
| Parameter | Type |
| ----------- | --------------------------- |
| `autoSleep` | [`AutoSleep`](#autosleep-4) |
##### Returns
`Promise`\<`void`>
#### setHttpPolicy()
> **setHttpPolicy**(`policy`): `Promise`\<`void`>
Attach an HTTP policy to this Sailbox, replacing any policy already
attached (a Sailbox has at most one). Accepts an [HttpPolicy](#httppolicy), a
listing summary, or a policy id string. The policy applies to HTTPS
connections this Sailbox opens after the call; connections already open
keep the previous policy until they close.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------- |
| `policy` | [`HttpPolicyLike`](#httppolicylike) |
##### Returns
`Promise`\<`void`>
#### shell()
> **shell**(`command?`, `options?`): `Promise`\<`number`>
Open an interactive pty session on this Sailbox, bridged to the local
terminal. With no `command`, runs a login shell; pass a command to run
that under a pty instead (e.g. a REPL or an editor). Blocks until the
remote process exits and resolves with its exit code. Requires an
interactive terminal (stdin and stdout TTYs) on a Unix machine. The
session runs as the image's `USER` when the image sets one, root
otherwise; see [ShellOptions.user](#user-3). While the session is open,
browser opens, localhost servers, paste, drag-and-drop, and clipboard are
forwarded to your machine (the clipboard is two-way on devbox images);
see [ShellOptions.noForward](#noforward).
##### Parameters
| Parameter | Type |
| ---------- | ------------------------------- |
| `command?` | `string` |
| `options?` | [`ShellOptions`](#shelloptions) |
##### Returns
`Promise`\<`number`>
#### sleep()
> **sleep**(`wakeAt?`): `Promise`\<`Date` | `undefined`>
Sleep this Sailbox to disk (wakes on traffic), optionally scheduling a
wall-clock wake first. `wakeAt`, when given, records the wake before the
sleep starts and the returned value is the effective wake time: the
sooner of this request and any wake already scheduled. If the Sailbox
is sleeping when that moment arrives, Sail restores it. The wake can
fire a little after the time you set, so treat it as approximate.
Sleeping an already-sleeping Sailbox succeeds and just updates the
scheduled wake.
##### Parameters
| Parameter | Type |
| --------- | ------ |
| `wakeAt?` | `Date` |
##### Returns
`Promise`\<`Date` | `undefined`>
#### terminate()
> **terminate**(): `Promise`\<`void`>
Terminate (delete) this Sailbox (updates [status](#status-2)).
##### Returns
`Promise`\<`void`>
#### unexpose()
> **unexpose**(`guestPort`): `Promise`\<`void`>
Remove a runtime ingress port.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `guestPort` | `number` |
##### Returns
`Promise`\<`void`>
#### upgrade()
> **upgrade**(): `Promise`\<[`UpgradeResult`](#upgraderesult)>
Upgrade this Sailbox's runtime.
##### Returns
`Promise`\<[`UpgradeResult`](#upgraderesult)>
#### waitForListener()
> **waitForListener**(`guestPort`, `options?`): `Promise`\<[`Listener`](#listener-1)>
Block until the listener on `guestPort` is reachable end to end and
return it, or throw [TimeoutError](#timeouterror) after `timeoutSeconds`. An HTTP
listener is ready once the guest server answers; a TCP listener once the
guest sends bytes or holds the connection open. A connectivity check, not
an application health check.
##### Parameters
| Parameter | Type |
| ----------- | --------------------------------------------------- |
| `guestPort` | `number` |
| `options` | [`WaitForListenerOptions`](#waitforlisteneroptions) |
##### Returns
`Promise`\<[`Listener`](#listener-1)>
#### create()
> `static` **create**(`options`): `Promise`\<[`Sailbox`](#sailbox)>
Create a new Sailbox.
A custom image definition passed as `image` is built first.
Sail may sleep a fully idle Sailbox; it wakes transparently on traffic or
the next operation.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------------------- |
| `options` | [`CreateSailboxOptions`](#createsailboxoptions) |
##### Returns
`Promise`\<[`Sailbox`](#sailbox)>
#### fromCheckpoint()
> `static` **fromCheckpoint**(`options`): `Promise`\<[`Sailbox`](#sailbox)>
Create a new running Sailbox from a durable checkpoint handle. The new
Sailbox uses the checkpoint's writable disk and cleaned memory state, so
background processes continue and the new Sailbox runs independently of
the source. Commands started with [Sailbox.exec](#exec-1) stop, though their
writes up to the checkpoint remain. Host-specific identity and network
routes are removed before the checkpoint handle becomes ready. A Sailbox
with volume mounts cannot create a reusable checkpoint. If Sail cannot
resume the saved memory, it starts the child cold with its writable disk
intact and without the saved processes. The new Sailbox keeps the
original's network policy; read it back with [Sailbox.get](#get-1).
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------------------- |
| `options` | [`FromCheckpointOptions`](#fromcheckpointoptions) |
##### Returns
`Promise`\<[`Sailbox`](#sailbox)>
#### fromId()
> `static` **fromId**(`sailboxId`, `options?`): [`Sailbox`](#sailbox)
Bind a handle to an existing Sailbox id without a network call.
The returned handle carries no snapshot fields (its [name](#name-2) and
[status](#status-2) are empty), just the operable surface. The id is not
verified to exist: operations on an unknown or inaccessible id reject
with [NotFoundError](#notfounderror). Use [get](#get-1) to validate the id and fetch
a fresh snapshot instead.
##### Parameters
| Parameter | Type |
| ----------- | --------------------------------- |
| `sailboxId` | `string` |
| `options` | [`ClientOptions`](#clientoptions) |
##### Returns
[`Sailbox`](#sailbox)
#### get()
> `static` **get**(`sailboxId`, `options?`): `Promise`\<[`Sailbox`](#sailbox)>
Fetch an existing Sailbox by id.
##### Parameters
| Parameter | Type |
| ----------- | --------------------------------- |
| `sailboxId` | `string` |
| `options` | [`ClientOptions`](#clientoptions) |
##### Returns
`Promise`\<[`Sailbox`](#sailbox)>
#### list()
> `static` **list**(`params?`): `Promise`\<[`Sailbox`](#sailbox)\[]>
List the Sailboxes that match the filters, fetching pages internally
until every match (or `limit` of them) is collected; use
[listPage](#listpage) to page through results manually instead. `limit` caps
the total returned, bounding the fetch for large orgs. A `client` can
ride along in the query object.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------------------- |
| `params` | [`ListSailboxesOptions`](#listsailboxesoptions) |
##### Returns
`Promise`\<[`Sailbox`](#sailbox)\[]>
#### listPage()
> `static` **listPage**(`params?`): `Promise`\<[`SailboxPage`](#sailboxpage)>
List one page of Sailboxes alongside the pagination envelope
(`total`/`hasMore`). Takes the same filters as [list](#list-2), plus `limit`
and `offset` to select the page.
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------------------------- |
| `params` | [`ListSailboxesPageOptions`](#listsailboxespageoptions) |
##### Returns
`Promise`\<[`SailboxPage`](#sailboxpage)>
***
## App
An app: the billing/ownership scope a Sailbox belongs to. Look one up (or mint
it) with [App.find](#find), then pass it (or its [App.id](#id)) to
[Sailbox.create](#create-1).
### Properties
| Property | Modifier | Type | Description |
| ------------ | ---------- | -------- | -------------- |
| `createdAt` | `readonly` | `Date` | Creation time. |
| `id` | `readonly` | `string` | Stable app id. |
| `name` | `readonly` | `string` | App name. |
### Methods
#### find()
> `static` **find**(`name`, `options?`): `Promise`\<[`App`](#app)>
Find an app by name, optionally minting it if missing.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------- |
| `name` | `string` |
| `options` | [`FindAppOptions`](#findappoptions) |
##### Returns
`Promise`\<[`App`](#app)>
#### list()
> `static` **list**(`options?`): `Promise`\<[`App`](#app)\[]>
Every app the current org owns, newest first.
##### Parameters
| Parameter | Type |
| --------- | --------------------------------- |
| `options` | [`ClientOptions`](#clientoptions) |
##### Returns
`Promise`\<[`App`](#app)\[]>
***
## Image
A Sailbox image: a base, registry, or Dockerfile image plus ordered build
steps. Immutable and fluent: each method returns a new `Image`. Local
files/dirs are recorded here and hashed and uploaded when the image is
resolved to a spec (at [Sailbox.create](#create-1), or via [toSpec](#tospec)), so
chaining stays synchronous.
### Example
```ts theme={null}
const image = Image.debian()
.aptInstall("git")
.pipInstall("numpy")
.addLocalDir("./app", "/app", { ignore: ["*.pyc", "__pycache__/"] })
.runCommand("pip install -e /app");
const box = await Sailbox.create({ app, name: "w", image });
```
### Methods
#### addLocalDir()
> **addLocalDir**(`localPath`, `remotePath`, `options?`): [`Image`](#image)
Bake a local directory tree into the image at `path`. Each regular
file is hashed + uploaded at resolve; symlinks are skipped and file modes
preserved. `ignore` takes gitignore-style patterns.
##### Parameters
| Parameter | Type |
| ------------ | ------------------------------------------- |
| `localPath` | `string` |
| `remotePath` | `string` |
| `options` | [`AddLocalDirOptions`](#addlocaldiroptions) |
##### Returns
[`Image`](#image)
#### addLocalFile()
> **addLocalFile**(`localPath`, `remotePath`, `options?`): [`Image`](#image)
Bake one local file into the image at `path` (absolute POSIX path;
a trailing `/` appends the source basename). Hashed + uploaded at resolve.
##### Parameters
| Parameter | Type |
| ------------ | --------------------------------------------- |
| `localPath` | `string` |
| `remotePath` | `string` |
| `options` | [`AddLocalFileOptions`](#addlocalfileoptions) |
##### Returns
[`Image`](#image)
#### aptInstall()
> **aptInstall**(...`packages`): [`Image`](#image)
Install system packages with apt.
##### Parameters
| Parameter | Type |
| ------------- | ----------- |
| ...`packages` | `string`\[] |
##### Returns
[`Image`](#image)
#### build()
> **build**(`options?`): `Promise`\<[`ImageSpec`](#imagespec)>
Upload any local files and build the image, waiting until it is ready.
Returns the resolved [ImageSpec](#imagespec). [Sailbox.create](#create-1) calls this
for a custom image before creating the Sailbox (the backend serves the
content-addressed built image); a bare base image skips the build.
Local files are re-hashed on every call, so edits always reach the
build, and rebuilding an unchanged, already-built image returns quickly.
Creating Sailboxes from the returned spec needs no further build. For an
image imported with [Image.fromRegistry](#fromregistry) through a tag, the spec
is also pinned to the exact version the build resolved the tag to, even
if the tag later moves upstream. [ImageBuildOptions.forceBuild](#forcebuild)
looks the tag up again and moves the tag's meaning for your whole
organization. For an image built with [Image.fromDockerfile](#fromdockerfile),
the returned spec is likewise pinned to the versions the build
resolved for its `FROM` and `COPY --from` images;
[ImageBuildOptions.forceBuild](#forcebuild) moves those pins for your whole
organization, while specs built earlier keep their pinned versions.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------------- |
| `options` | [`ImageBuildOptions`](#imagebuildoptions) |
##### Returns
`Promise`\<[`ImageSpec`](#imagespec)>
#### env()
> **env**(`env`): [`Image`](#image)
Bake environment variables into the image (keys are trimmed).
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------------ |
| `env` | `Readonly`\<`Record`\<`string`, `string`>> |
##### Returns
[`Image`](#image)
#### pipInstall()
> **pipInstall**(...`packages`): [`Image`](#image)
Install Python packages with pip.
##### Parameters
| Parameter | Type |
| ------------- | ----------- |
| ...`packages` | `string`\[] |
##### Returns
[`Image`](#image)
#### runCommand()
> **runCommand**(`command`): [`Image`](#image)
Run a shell command during the build.
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `command` | `string` |
##### Returns
[`Image`](#image)
#### toSpec()
> **toSpec**(`client?`): `Promise`\<[`ImageSpec`](#imagespec)>
Resolve to an [ImageSpec](#imagespec): walks local files/dirs (honoring
gitignore), hashes them, and uploads their content via `client` (defaults
to the env client). [Sailbox.create](#create-1) calls this for you; use it
directly only if you need the raw spec.
##### Parameters
| Parameter | Type |
| --------- | ------------------- |
| `client?` | [`Client`](#client) |
##### Returns
`Promise`\<[`ImageSpec`](#imagespec)>
#### debian()
> `static` **debian**(`architecture?`): [`Image`](#image)
A Debian base image (defaults to amd64).
##### Parameters
| Parameter | Type | Default value |
| -------------- | ----------------------------------------- | ------------- |
| `architecture` | [`ImageArchitecture`](#imagearchitecture) | `"amd64"` |
##### Returns
[`Image`](#image)
#### devbox()
> `static` **devbox**(`architecture?`): [`Image`](#image)
The devbox base image (defaults to amd64): a prebuilt Debian base with a
baked development layer. Docker is included, and its daemon starts
automatically when the Sailbox boots and keeps running across sleeps.
The daemon can take a few seconds to accept commands right after
boot. If it stops, it is not restarted automatically.
Prebuilt-only, so it does not support build steps or env; start from
[Image.debian](#debian) to customize.
##### Parameters
| Parameter | Type | Default value |
| -------------- | ----------------------------------------- | ------------- |
| `architecture` | [`ImageArchitecture`](#imagearchitecture) | `"amd64"` |
##### Returns
[`Image`](#image)
#### fromDockerfile()
> `static` **fromDockerfile**(`dockerfile`, `options?`): [`Image`](#image)
Build your own Dockerfile into a Sailbox image, with everything Sail
needs layered on top. Pass the path to a Dockerfile on this machine, or
its literal text wrapped as `{ contents }`. The result behaves like any
other image: build steps, env, and pip/apt installs work the same as on
[Image.debian](#debian).
Pass `contextDir` to give the Dockerfile's `COPY` and `ADD`
instructions a build context; if omitted, the build runs without one.
A `.dockerignore` in that directory is honored, and `ignore` patterns
are applied after it, so they take precedence on conflict. A file named
after the Dockerfile, like `Dockerfile.dockerignore`, sitting next to
it is used instead of the context's `.dockerignore`, as it is with
Docker. The files the ignore rules keep are hashed and uploaded when
the image is built, so edits up to that point reach the build. File
modes, empty directories, and symbolic links are carried into the
build.
Every image a `FROM` (or `COPY --from`) instruction names must live on a
supported public registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or
`quay.io`); a short name like `python:3.12` means
`docker.io/library/python:3.12`. Each named image is pinned to the
version its tag pointed at the first time your organization used it,
and those pinned versions become part of the built image's identity,
so rebuilding the same spec reuses the same image even after a tag
moves. Pass `forceBuild` to [Image.build](#build) to look the tags up
again and build what they point at now. The Dockerfile must produce a
Debian- or Ubuntu-based filesystem.
A `# syntax=` line can declare `docker/dockerfile:1` or a release
from 1.4 through 1.22.0. A file that declares anything else is
rejected. The declared release does not change how the file is built.
Multi-stage Dockerfiles work. A `RUN --mount` of type `cache`, `secret`,
or `ssh` is rejected; `tmpfs` mounts work, and `bind` mounts work when
they read from the build context or another build stage. Mount options
must be literal text, and `ONBUILD` is not supported, in the Dockerfile
or in an image a `FROM` names.
The built image's environment variables, working directory, and `USER`
become the defaults for commands you run with [Sailbox.exec](#exec-1) or
[Sailbox.run](#run); per-call `env`, `cwd`, and `user` override them
(pass `user: "0:0"` to run as root on an image that sets `USER`). The
image's `ENTRYPOINT` and `CMD` are not run: a Sailbox manages its own
processes, and your commands say what to execute. Build steps you chain
onto the image (such as `aptInstall`) and SSH sessions still run as root.
##### Parameters
| Parameter | Type |
| ------------ | ------------------------------------------------- |
| `dockerfile` | `string` \| \{ `contents`: `string`; } |
| `options` | [`FromDockerfileOptions`](#fromdockerfileoptions) |
##### Returns
[`Image`](#image)
##### Example
```ts theme={null}
const image = Image.fromDockerfile("./envs/task1/Dockerfile", {
contextDir: "./envs/task1",
});
```
#### fromRegistry()
> `static` **fromRegistry**(`ref`, `options?`): [`Image`](#image)
Your own image as the Sailbox root filesystem, with everything Sail needs
layered on top. The result behaves like any other image: build steps,
env, and pip/apt installs work the same as on [Image.debian](#debian).
Reference an image on a supported public registry (`docker.io`,
`ghcr.io`, `public.ecr.aws`, or `quay.io`), written as you would for
`docker pull`: `python:3.13` means `docker.io/library/python:3.13` and
`acme/tool` means `docker.io/acme/tool`; name the registry for the
others, as in `ghcr.io/acme/tool`. You can pass a tag, a `@sha256:...`
digest, or just the name, which means the `latest` tag. The image must be
Debian- or Ubuntu-based.
Your Sailbox runs on the CPU architecture the image was built for. An
image published for both amd64 and arm64 runs on amd64. Pass
`architecture` to require one instead, and building fails if the image was
not built for it.
A tag is pinned for your organization once an image has been built from
it: later builds keep using that image even after the tag moves
upstream. Call
`build({ forceBuild: true })` to look the tag up again and build the
version it points at now for your whole organization; see
[ImageBuildOptions.forceBuild](#forcebuild) for how the switch propagates. A
digest names exactly one image, so it never moves.
The image's environment variables, working directory, and `USER` become
the defaults for commands you run with [Sailbox.exec](#exec-1) or
[Sailbox.run](#run); per-call `env`, `cwd`, and `user` override them
(pass `user: "0:0"` to run as root on an image that sets `USER`). The
image's `ENTRYPOINT` and `CMD` are not run: a Sailbox manages its own
processes, and your commands say what to execute. Build steps you chain
onto the image (such as `aptInstall`) and SSH sessions still run as root.
##### Parameters
| Parameter | Type |
| --------- | --------------------------------------------- |
| `ref` | `string` |
| `options` | [`FromRegistryOptions`](#fromregistryoptions) |
##### Returns
[`Image`](#image)
##### Example
```ts theme={null}
const image = Image.fromRegistry(
"docker.io/library/python:3.13"
).aptInstall("git");
```
***
## ExecProcess
A live command running in a Sailbox. Stream [stdout](#stdout)/[stderr](#stderr),
write to [writeStdin](#writestdin), and [wait](#wait) for the result. Not killed on GC;
call [close](#close) to detach, or [cancel](#cancel) to stop the command.
Each stream has a buffer, 1 MiB by default (`outputBufferBytes`), and the
`outputMode` says what happens when it fills. With the default `"auto"`:
if you are not consuming a stream, the command never pauses and the stream
keeps only its most recent bytes; if you are consuming a stream and fall
behind, the command pauses when the buffer fills and resumes as you read,
like a pipe. Consuming a stream is how you get every byte, and it slows the
command when you cannot keep up. `"pipe"` holds both streams from the
start, so a consumer that starts late still gets every byte; `"tail"`
never pauses the command for you. See `OutputMode`.
With `"auto"`, start consuming right after `exec()` returns to get every
byte. You can consume stdout without holding stderr, or the reverse; the
stream you are not holding keeps its most recent bytes and never pauses
the command when it fills. If you hold both, consume them at the same time
(`Promise.all`). The exit code is available from [poll](#poll) once the
streams end and from [wait](#wait).
Accessing `proc.stdout` or `proc.stderr` claims nothing. A stream is claimed
when you start iterating it (`for await`, [ExecStream.raw](#raw),
[ExecStream.text](#text), [ExecStream.bytes](#bytes)) or call
[ExecStream.toReadable](#toreadable), and released when the iteration finishes or
you leave it (`break`, `return`, a thrown error), when `.text()` or
`.bytes()` reaches the end, or when the `Readable` is destroyed. Each
stream can be claimed once; a second attempt rejects with
`InvalidArgumentError`.
[wait](#wait) returns each stream's buffer, its most recent output, with
`stdoutTruncated` / `stderrTruncated` set when older output was dropped.
[close](#close), or your process exiting, releases both streams; the command
keeps running, and `wait()` rejects after `close()` unless it already
resolved a result. Sail may reattach
after an interruption, but reattachment does not guarantee exact output
replay. A pty command never pauses; [resync](#resync) requests a fresh screen.
### Example
```ts theme={null}
const proc = await box.exec(["bash", "-lc", "echo hi"]);
for await (const chunk of proc.stdout) process.stdout.write(chunk);
const result = await proc.wait();
console.log(result.exitCode, result.stderr);
```
### Accessors
#### execRequestId
##### Get Signature
> **get** **execRequestId**(): `string`
The durable exec request id: the launch's idempotency key as Sail
recorded it (yours, or the one Sail generated when you did not supply
one; read it here to learn the generated value). Reading it marks a
generated identity as shareable: a second handle started with it takes
over the stream, so from then on this handle no longer reclaims the
stream after any interruption, a clean end or a dropped connection
alike, and resolves from the recorded result instead. Reading back a
key you supplied changes nothing.
##### Returns
`string`
#### output
##### Get Signature
> **get** **output**(): [`ExecStream`](#execstream)
Alias for [stdout](#stdout): under a pty the two output streams merge onto
stdout, and `output` names that merged terminal stream.
##### Returns
[`ExecStream`](#execstream)
#### stderr
##### Get Signature
> **get** **stderr**(): [`ExecStream`](#execstream)
The sole stderr stream: string iteration by default, `.raw()` for bytes.
Claimed and released the same way as [stdout](#stdout).
##### Returns
[`ExecStream`](#execstream)
#### stdout
##### Get Signature
> **get** **stdout**(): [`ExecStream`](#execstream)
The sole stdout stream: string iteration by default, `.raw()` for bytes.
Accessing this property claims nothing; the stream is claimed when you
start consuming it and released when the iteration finishes or you leave
it (see [ExecStream](#execstream)).
##### Returns
[`ExecStream`](#execstream)
### Methods
#### \[asyncDispose]\()
> **\[asyncDispose]**(): `Promise`\<`void`>
`await using` support: detaches on scope exit.
##### Returns
`Promise`\<`void`>
#### \[dispose]\()
> **\[dispose]**(): `void`
`using` support: detaches on scope exit.
##### Returns
`void`
#### cancel()
> **cancel**(`options?`): `Promise`\<`void`>
Cancel the command (SIGINT by default, SIGKILL with `force`).
Transient failures are retried briefly, covering the window right after
the command starts when the guest cannot accept signals for it yet.
##### Parameters
| Parameter | Type |
| --------- | --------------------------------- |
| `options` | [`CancelOptions`](#canceloptions) |
##### Returns
`Promise`\<`void`>
#### close()
> **close**(): `void`
Abandon the handle without killing the command. It releases both
streams. The command keeps running and never pauses, and Sail keeps only
the most recent output of each stream. Call [cancel](#cancel) instead if the
command should stop. [wait](#wait) rejects after `close()` unless it
already resolved a result.
##### Returns
`void`
#### closeStdin()
> **closeStdin**(): `Promise`\<`void`>
Close the command's stdin (send EOF).
##### Returns
`Promise`\<`void`>
#### poll()
> **poll**(): `number` | `null`
The exit code once the output stream has ended, else `null`. Never
blocks and never drops output. If the connection was lost for good
mid-command, the stream ends early with the outcome still unknown:
`poll()` stays `null` and [wait](#wait) fetches the result Sail
recorded. A host-lost exec (the machine running the Sailbox was lost
mid-command) has no real exit code: [wait](#wait) always throws
`SailboxHostLostError` for one, and `poll()` throws it when that
loss is what ended the stream.
##### Returns
`number` | `null`
#### resize()
> **resize**(`cols`, `rows`): `Promise`\<`void`>
Resize the pty (no-op without one).
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `cols` | `number` |
| `rows` | `number` |
##### Returns
`Promise`\<`void`>
#### resync()
> **resync**(): `Promise`\<`void`>
Ask a pty exec to repaint its current screen (no-op without a pty). A
command runs at full speed and never waits for a slow reader, so if you
fall far behind the oldest output is dropped. Call this after that happens
to receive the current screen instead of a broken, partial one. Advisory
and best-effort.
##### Returns
`Promise`\<`void`>
#### wait()
> **wait**(): `Promise`\<[`ExecResult`](#execresult)>
Wait for the command to finish and return its result.
`stdout` and `stderr` on the result hold each stream's buffer, its most
recent output (1 MiB by default), with `stdoutTruncated` /
`stderrTruncated` set when older output was dropped; to get every byte,
consume the stream (see [ExecProcess](#execprocess)). `wait()` itself never
pauses the command and may run alongside an active consumer. With
`outputMode: "pipe"`, a stream nobody consumes pauses the command when its
buffer fills, and `wait()` then waits for as long as the command stays
paused. It rejects after [close](#close) unless it already resolved a
result.
##### Returns
`Promise`\<[`ExecResult`](#execresult)>
#### writeStdin()
> **writeStdin**(`data`): `Promise`\<`void`>
Write to the command's stdin (requires `openStdin`).
##### Parameters
| Parameter | Type |
| --------- | ---------------------------------------------------------------------------- |
| `data` | `string` \| `Buffer`\<`ArrayBufferLike`> \| `Uint8Array`\<`ArrayBufferLike`> |
##### Returns
`Promise`\<`void`>
***
## ExecStream
An async-iterable view of one exec stream (stdout or stderr). Default
iteration yields `string` chunks, incrementally decoded as UTF-8 (a
multibyte character split across chunks is carried until complete); use
[raw](#raw) for the unmodified byte stream. Iteration ends once the command
finishes and its remaining output has been delivered; if the connection was
lost for good mid-command, it ends early with the outcome still unknown,
and [ExecProcess.wait](#wait) fetches the result Sail recorded.
An `ExecStream` can be consumed once: string iteration, [raw](#raw),
[toReadable](#toreadable), [text](#text), or [bytes](#bytes); a second consumer
rejects with `InvalidArgumentError`. Accessing `proc.stdout` claims nothing.
The stream is claimed when you start consuming it, and from then on nothing
is lost: the command pauses when you fall behind. That does not hold for
pty output, for `outputMode: "tail"`, or after `cancel()` or the exec timeout
has ended the pauses (see [ExecProcess](#execprocess)).
It is released when the iteration finishes or you leave it (`break`,
`return`, a thrown error), when [text](#text) or [bytes](#bytes) reaches the
end, or when the `Readable` from [toReadable](#toreadable) is destroyed. While
nobody is consuming, Sail keeps only the stream's most recent bytes (1 MiB
by default) unless the exec runs with `outputMode: "pipe"`, so start consuming
right after `exec()` returns when you need every byte. Pty output never
pauses the command and keeps only its most recent bytes.
### Example
```ts theme={null}
for await (const chunk of proc.stdout) process.stdout.write(chunk);
```
### Implements
* `AsyncIterable`\<`string`>
### Methods
#### \[asyncIterator]\()
> **\[asyncIterator]**(): `AsyncIterator`\<`string`>
##### Returns
`AsyncIterator`\<`string`>
##### Implementation of
`AsyncIterable.[asyncIterator]`
#### bytes()
> **bytes**(): `Promise`\<`Buffer`\<`ArrayBufferLike`>>
Consume and collect the raw byte stream into a single `Buffer`.
##### Returns
`Promise`\<`Buffer`\<`ArrayBufferLike`>>
#### raw()
> **raw**(): `AsyncIterableIterator`\<`Buffer`\<`ArrayBufferLike`>>
Iterate the raw byte stream, exactly as the command wrote it (escape
sequences and binary payloads included). This consumes the stream.
##### Returns
`AsyncIterableIterator`\<`Buffer`\<`ArrayBufferLike`>>
#### text()
> **text**(): `Promise`\<`string`>
Consume and collect the stream into a single string.
##### Returns
`Promise`\<`string`>
#### toReadable()
> **toReadable**(): `Readable`
Consume this stream as a Node `Readable` of string chunks. Calling this
claims the stream at once; destroying the `Readable` releases it at once,
even while a read is waiting for output.
##### Returns
`Readable`
***
## SailboxFs
Filesystem operations on a Sailbox's guest, reached via [Sailbox.fs](#fs).
File I/O streams bytes to/from the guest; the directory helpers create,
remove, test, and transfer paths.
Writes give what they create to the image's `USER` by default (root when
the image sets none), the same identity commands run as, so an uploaded
file is usable by the code in the Sailbox. Reads and the directory helpers
act as root by default, so they work on any path.
Every operation except the reads and the directory download takes an
optional `user` in Docker's `USER` syntax (`name`, `uid`, `name:group`,
or `uid:gid`; `"0:0"` is always root). The directory helpers other than
the transfers run their command as that user, with its permissions
enforced. Writes and the directory upload keep running as root but give
that user what they create, like `COPY --chown`. Reads and the download
take no `user`: a `user` only decides which paths an operation may touch
and who owns what it creates. A read creates nothing in the Sailbox, and
a download reads any path as root, the way the reads do. A `user` other
than `"0:0"` requires a Sailbox whose guest honors requested users; on
older Sailboxes these calls fail until [Sailbox.upgrade](#upgrade) is
called.
### Methods
#### downloadDir()
> **downloadDir**(`dirs`): `Promise`\<`void`>
Download a directory's contents from the Sailbox into a local directory.
`guestDir`'s entries land inside `localDir`, which is created if needed.
Entries the download does not name are left in place; a same-named file
is replaced. The transfer reads every file in the tree, so download
directories of ordinary files: system trees like `/proc` or `/sys` hold
files that
cannot be read as plain data, and downloading them fails. A file that
is being written while the download runs is captured as it is at that
moment, the way copying a live file would; download after writers finish
for a consistent copy. On Windows, a directory that contains symbolic
links cannot be downloaded, since Windows restricts creating them. The
Sailbox's image must provide `tar` and `gzip`, which the transfer uses
to ship the directory as one compressed archive; the default images
do.
##### Parameters
| Parameter | Type |
| --------------- | ------------------------------------------------ |
| `dirs` | \{ `guestDir`: `string`; `localDir`: `string`; } |
| `dirs.guestDir` | `string` |
| `dirs.localDir` | `string` |
##### Returns
`Promise`\<`void`>
#### exists()
> **exists**(`path`, `options?`): `Promise`\<`boolean`>
Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
a dangling symlink reports `false` even though [ls](#ls) lists it. A
`user` reports existence as observable by that user: a path the user lacks
permission to reach also reports `false`.
##### Parameters
| Parameter | Type |
| ---------- | ------------------------- |
| `path` | `string` |
| `options?` | [`FsOptions`](#fsoptions) |
##### Returns
`Promise`\<`boolean`>
#### ls()
> **ls**(`path`, `options?`): `Promise`\<[`DirEntry`](#direntry)\[]>
List a directory's immediate entries as [DirEntry](#direntry) records (no
recursion). A missing path throws, as does a path that is not a directory and
a listing too large for the exec output cap. An entry whose name is not
valid UTF-8 fails the listing, since the path API cannot address it. A
`user` runs the listing as that user, so a directory it may not read fails
with a permission error.
##### Parameters
| Parameter | Type |
| ---------- | ------------------------- |
| `path` | `string` |
| `options?` | [`FsOptions`](#fsoptions) |
##### Returns
`Promise`\<[`DirEntry`](#direntry)\[]>
#### mkdir()
> **mkdir**(`path`, `options?`): `Promise`\<`void`>
Create a directory and any missing parents (like `mkdir -p`); a no-op if
it already exists. A `user` runs the mkdir as that user, so created
directories are owned by it.
##### Parameters
| Parameter | Type |
| ---------- | ------------------------- |
| `path` | `string` |
| `options?` | [`FsOptions`](#fsoptions) |
##### Returns
`Promise`\<`void`>
#### read()
> **read**(`path`): `Promise`\<`Buffer`\<`ArrayBufferLike`>>
Read a guest file fully into memory (convenience over [readStream](#readstream-1)).
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `path` | `string` |
##### Returns
`Promise`\<`Buffer`\<`ArrayBufferLike`>>
#### readStream()
> **readStream**(`path`): `Promise`\<[`FileStream`](#filestream)>
Open a streaming read of a guest file.
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `path` | `string` |
##### Returns
`Promise`\<[`FileStream`](#filestream)>
#### remove()
> **remove**(`path`, `options?`): `Promise`\<`void`>
Remove a file or directory tree (like `rm -rf`); a no-op if it is already
absent. A `user` runs the removal as that user, limiting it to what that
user may delete.
##### Parameters
| Parameter | Type |
| ---------- | ------------------------- |
| `path` | `string` |
| `options?` | [`FsOptions`](#fsoptions) |
##### Returns
`Promise`\<`void`>
#### uploadDir()
> **uploadDir**(`dirs`): `Promise`\<`void`>
Upload a local directory's contents into a directory on the Sailbox.
`localDir`'s entries land inside `guestDir`, which is created if needed.
Entries the upload does not name are left in place; a same-named file is
replaced. Uploaded files belong to the image's `USER`, the same identity
commands run as, so the code in the Sailbox can use them. When the image
sets no `USER`, or that user cannot be resolved in the Sailbox, they
belong to root. `guestDir` and any missing parents the upload creates
get the same owner. Files keep their permission bits, except that the
setuid, setgid, and sticky bits are cleared.
A `user` (the same syntax the other operations take) gives the entries
to that user instead, like `COPY --chown`; it must exist in the Sailbox,
and like the other operations' `user` it requires a Sailbox whose guest
honors requested users. The Sailbox's image must provide `tar` and
`gzip`, which the transfer uses to ship the directory as one compressed
archive; the default images do.
##### Parameters
| Parameter | Type |
| --------------- | ------------------------------------------------------------------- |
| `dirs` | \{ `guestDir`: `string`; `localDir`: `string`; `user?`: `string`; } |
| `dirs.guestDir` | `string` |
| `dirs.localDir` | `string` |
| `dirs.user?` | `string` |
##### Returns
`Promise`\<`void`>
#### write()
> **write**(`path`, `data`, `options?`): `Promise`\<`void`>
Write `data` to a guest file: [writeFiles](#writefiles-1) with one entry. Strings
use UTF-8. Missing parent directories are created unless `createParents`
is false. Use [writeStream](#writestream-1) to stream a large source.
##### Parameters
| Parameter | Type |
| ---------- | ---------------------------------------------------------------------------- |
| `path` | `string` |
| `data` | `string` \| `Buffer`\<`ArrayBufferLike`> \| `Uint8Array`\<`ArrayBufferLike`> |
| `options?` | [`WriteOptions`](#writeoptions) |
##### Returns
`Promise`\<`void`>
#### writeFiles()
> **writeFiles**(`files`, `options?`): `Promise`\<`void`>
Write several complete files in one call. `files` maps each absolute
guest path to its contents (strings use UTF-8). Each file is its own
request, up to eight at a time, and every file gets the same options. A
batch is not atomic across paths: the first failure stops the batch,
files that already completed stay written, writes already in flight
finish, and the error names the file that failed. A path may appear only
once. Use [writeStream](#writestream-1) to stream a large source.
##### Parameters
| Parameter | Type |
| ---------- | ---------------------------------------------------------------------- |
| `files` | `Readonly`\<`Record`\<`string`, `Buffer` \| `Uint8Array` \| `string`>> |
| `options?` | [`WriteOptions`](#writeoptions) |
##### Returns
`Promise`\<`void`>
#### writeStream()
> **writeStream**(`path`, `options?`): `Promise`\<[`FileWriter`](#filewriter)>
Open a streaming upload to a guest file.
##### Parameters
| Parameter | Type |
| ---------- | ------------------------------- |
| `path` | `string` |
| `options?` | [`WriteOptions`](#writeoptions) |
##### Returns
`Promise`\<[`FileWriter`](#filewriter)>
***
## FileWriter
A streaming write to a guest file. Push chunks with [write](#write), then
confirm with [finish](#finish); only `finish` commits the write. A writer
that goes away without finishing ([abort](#abort), an error path, or garbage
collection) cancels the transfer instead; the guest file state is then
unspecified.
### Methods
#### \[asyncDispose]\()
> **\[asyncDispose]**(): `Promise`\<`void`>
`await using` support; same semantics as the synchronous form.
##### Returns
`Promise`\<`void`>
#### \[dispose]\()
> **\[dispose]**(): `void`
`using` support: aborts the write if it was never finished, so leaving
scope on an error path cancels instead of committing a partial file.
`abort` is synchronous, so the plain form suffices.
##### Returns
`void`
#### abort()
> **abort**(): `void`
Abort the write: cancel the request so the server does not commit it.
Idempotent. A later [finish](#finish) reports the abort instead of
succeeding; the guest file state after an abort is unspecified.
##### Returns
`void`
#### finish()
> **finish**(): `Promise`\<`void`>
Confirm the write, creating an empty file if nothing was written.
##### Returns
`Promise`\<`void`>
#### toWritable()
> **toWritable**(): `Writable`
Adapt to a Node `Writable`: `end()` runs [finish](#finish) (only that
commits the write), destroying the stream aborts it, and backpressure
follows the underlying transfer since each chunk's callback fires when
its [write](#write) resolves.
##### Returns
`Writable`
#### write()
> **write**(`data`): `Promise`\<`void`>
Write bytes (a `string` is encoded as UTF-8). The SDK splits them into
transport-sized chunks.
##### Parameters
| Parameter | Type |
| --------- | ---------------------------------------------------------------------------- |
| `data` | `string` \| `Buffer`\<`ArrayBufferLike`> \| `Uint8Array`\<`ArrayBufferLike`> |
##### Returns
`Promise`\<`void`>
***
## FileStream
An async-iterable download of a guest file. Chunks are `Buffer`s; iteration
ends at end of file. The underlying stream is released when iteration finishes
or is abandoned (via a generator `finally`), or explicitly via [close](#close-1).
### Implements
* `AsyncIterable`\<`Buffer`>
### Methods
#### \[asyncDispose]\()
> **\[asyncDispose]**(): `Promise`\<`void`>
`await using` support.
##### Returns
`Promise`\<`void`>
#### \[asyncIterator]\()
> **\[asyncIterator]**(): `AsyncIterator`\<`Buffer`\<`ArrayBufferLike`>>
##### Returns
`AsyncIterator`\<`Buffer`\<`ArrayBufferLike`>>
##### Implementation of
`AsyncIterable.[asyncIterator]`
#### bytes()
> **bytes**(): `Promise`\<`Buffer`\<`ArrayBufferLike`>>
Collect the whole file into a single `Buffer`.
##### Returns
`Promise`\<`Buffer`\<`ArrayBufferLike`>>
#### close()
> **close**(): `Promise`\<`void`>
Release the underlying download stream (idempotent).
##### Returns
`Promise`\<`void`>
#### toReadable()
> **toReadable**(): `Readable`
Adapt to a Node `Readable`.
##### Returns
`Readable`
***
## Volume
A managed NFS volume that can be mounted into Sailboxes. Look one up (or
mint it) with [Volume.find](#find-1), then pass it (or its [Volume.id](#id-2))
in a Sailbox's `volumes` mapping.
Volumes are currently in Alpha. To pilot them, reach out in the Sail Slack:
[https://join.slack.com/t/sailresearchcrew/shared\_invite/zt-41pdcym9j-UU0Ey\~A\~r6n2H0DQVQsQHQ](https://join.slack.com/t/sailresearchcrew/shared_invite/zt-41pdcym9j-UU0Ey~A~r6n2H0DQVQsQHQ).
### Properties
| Property | Modifier | Type | Default value | Description |
| ------------ | ---------- | ----------------------- | ------------- | ----------------------------------------------------------------- |
| `backend` | `readonly` | `string` | `undefined` | Storage backend serving the volume. |
| `createdAt` | `readonly` | `Date` \| `undefined` | `undefined` | Creation time, if reported. |
| `id` | `readonly` | `string` | `undefined` | Stable volume id. |
| `mountPath` | `readonly` | `string` \| `undefined` | `undefined` | Guest mount path, when loaded via [Volume.fromMount](#frommount). |
| `name` | `readonly` | `string` | `undefined` | Volume name. |
| `status` | `readonly` | `string` | `undefined` | Lifecycle status. |
| `updatedAt` | `readonly` | `Date` \| `undefined` | `undefined` | Last-update time, if reported. |
### Methods
#### delete()
> **delete**(`options?`): `Promise`\<`boolean`>
Delete this volume. Resolves `true` if it was deleted, `false` if it was
already gone (only possible with `allowMissing`).
##### Parameters
| Parameter | Type |
| --------- | --------------------------------------------- |
| `options` | [`DeleteVolumeOptions`](#deletevolumeoptions) |
##### Returns
`Promise`\<`boolean`>
#### find()
> `static` **find**(`name`, `options?`): `Promise`\<[`Volume`](#volume)>
Look up an NFS volume by name, optionally minting it if missing.
##### Parameters
| Parameter | Type |
| --------- | ----------------------------------------- |
| `name` | `string` |
| `options` | [`FindVolumeOptions`](#findvolumeoptions) |
##### Returns
`Promise`\<[`Volume`](#volume)>
#### fromMount()
> `static` **fromMount**(`path`): [`Volume`](#volume)
Guest-side: load the volume handle for a path mounted into this
Sailbox (reads the mount's metadata; only available inside a guest).
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `path` | `string` |
##### Returns
[`Volume`](#volume)
#### list()
> `static` **list**(`options?`): `Promise`\<[`Volume`](#volume)\[]>
List NFS volumes in the current org.
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------------- |
| `options` | [`ListVolumesOptions`](#listvolumesoptions) |
##### Returns
`Promise`\<[`Volume`](#volume)\[]>
***
## HTTP policies
See [Credential injection](/sailboxes-credentials) for setup, examples, and cleanup. The entries below list the available TypeScript calls.
### Secret
A value an HTTP policy can insert into matching HTTPS requests.
Secrets belong to your organization. Sail never returns a stored value.
Get and list calls return only the secret's name and timestamps.
#### Properties
| Property | Modifier | Type | Description |
| ------------ | ---------- | -------- | --------------------------------------------------- |
| `createdAt` | `readonly` | `Date` | When the secret was first set. |
| `name` | `readonly` | `string` | The secret's name, unique within your organization. |
| `updatedAt` | `readonly` | `Date` | When the secret's value last changed. |
#### Methods
##### delete()
> **delete**(): `Promise`\<`void`>
Delete this secret.
A secret cannot be deleted while an HTTP policy refers to it; throws
[SecretInUseError](#secretinuseerror) until every referencing policy is deleted.
Summaries from [HttpPolicy.list](#list-1) include the secret names they
use.
###### Returns
`Promise`\<`void`>
##### deleteByName()
> `static` **deleteByName**(`name`, `options?`): `Promise`\<`void`>
Delete the named secret. Same contract as [Secret.delete](#delete-1).
###### Parameters
| Parameter | Type |
| --------- | --------------------------------- |
| `name` | `string` |
| `options` | [`ClientOptions`](#clientoptions) |
###### Returns
`Promise`\<`void`>
##### get()
> `static` **get**(`name`, `options?`): `Promise`\<[`Secret`](#secret)>
Fetch one secret's name and timestamps. The value is never returned. Throws
[NotFoundError](#notfounderror) when no secret has that name.
###### Parameters
| Parameter | Type |
| --------- | --------------------------------- |
| `name` | `string` |
| `options` | [`ClientOptions`](#clientoptions) |
###### Returns
`Promise`\<[`Secret`](#secret)>
##### list()
> `static` **list**(`options?`): `Promise`\<[`Secret`](#secret)\[]>
List your organization's secret names and timestamps, sorted by name.
###### Parameters
| Parameter | Type |
| --------- | --------------------------------- |
| `options` | [`ClientOptions`](#clientoptions) |
###### Returns
`Promise`\<[`Secret`](#secret)\[]>
##### set()
> `static` **set**(`name`, `value`, `options?`): `Promise`\<[`Secret`](#secret)>
Set (create or update) the named secret's value. An HTTP policy inserts
it with `${secrets.NAME}`.
After this call succeeds, the next matching request from any Sailbox
whose attached HTTP policy uses this secret gets the new value.
Names start with a letter or number and use letters, numbers,
underscores, and dashes (up to 128 characters). Values cannot be empty.
They can be up to 64 KiB and cannot contain ASCII control characters such
as tabs or line breaks.
###### Parameters
| Parameter | Type |
| --------- | --------------------------------- |
| `name` | `string` |
| `value` | `string` |
| `options` | [`ClientOptions`](#clientoptions) |
###### Returns
`Promise`\<[`Secret`](#secret)>
***
### HttpPolicy
Rules that shape the HTTPS requests your Sailboxes send.
A policy is a named document owned by your organization. Obtain one from
[HttpPolicy.create](#create) or [HttpPolicy.get](#get); do not construct it
directly. The document cannot change after creation, but
[HttpPolicy.rename](#rename) can change its name.
#### Properties
| Property | Modifier | Type | Description |
| ------------ | ---------- | --------------------------------------------- | ------------------------------------------------------------------------------------ |
| `createdAt` | `readonly` | `Date` | When the policy was created. |
| `document` | `readonly` | [`HttpPolicyDocument`](#httppolicydocument-1) | The saved policy document: Sail's normalized form of the document given at creation. |
| `id` | `readonly` | `string` | The policy's stable identifier. |
| `name` | `readonly` | `string` | The policy's name (the only mutable field). |
| `updatedAt` | `readonly` | `Date` | When the policy's name last changed. |
#### Methods
##### delete()
> **delete**(): `Promise`\<`void`>
Delete the policy. A policy still attached to a Sailbox cannot be
deleted; throws [HttpPolicyInUseError](#httppolicyinuseerror) until every Sailbox clears
or replaces it.
###### Returns
`Promise`\<`void`>
##### rename()
> **rename**(`name`): `Promise`\<[`HttpPolicy`](#httppolicy)>
Rename the policy and resolve the updated policy object. The document
cannot change; create a new policy to change behavior. Names follow the
same rules as [HttpPolicy.create](#create).
###### Parameters
| Parameter | Type |
| --------- | -------- |
| `name` | `string` |
###### Returns
`Promise`\<[`HttpPolicy`](#httppolicy)>
##### create()
> `static` **create**(`name`, `document`, `options?`): `Promise`\<[`HttpPolicy`](#httppolicy)>
Create a policy from `document`.
Every `${secrets.NAME}` in the document must name a secret that already
exists. An invalid document throws [InvalidArgumentError](#invalidargumenterror) naming
the field to fix. Sail saves a normalized form of the document (for
example, host names are lowercased and defaults are filled in), so
reading the policy back can return a different shape with the same
behavior.
Policy names must contain visible text, use at most 128 characters, and
cannot contain tabs, line breaks, or other control characters.
Sail does not retry this call. If the connection ends before the result
arrives, list policies before trying again; a second call can create a
second policy.
###### Parameters
| Parameter | Type |
| ---------- | --------------------------------------------- |
| `name` | `string` |
| `document` | [`HttpPolicyDocument`](#httppolicydocument-1) |
| `options` | [`ClientOptions`](#clientoptions) |
###### Returns
`Promise`\<[`HttpPolicy`](#httppolicy)>
##### get()
> `static` **get**(`policyIdentifier`, `options?`): `Promise`\<[`HttpPolicy`](#httppolicy)>
Fetch one policy by id, document included. Throws
[NotFoundError](#notfounderror) when no policy has that id.
###### Parameters
| Parameter | Type |
| ------------------ | --------------------------------- |
| `policyIdentifier` | `string` |
| `options` | [`ClientOptions`](#clientoptions) |
###### Returns
`Promise`\<[`HttpPolicy`](#httppolicy)>
##### list()
> `static` **list**(`options?`): `Promise`\<[`HttpPolicySummary`](#httppolicysummary)\[]>
List your organization's policies as summaries, without documents.
Fetch a policy's document with [HttpPolicy.get](#get).
###### Parameters
| Parameter | Type |
| --------- | ----------------------------------------------------- |
| `options` | [`ListHttpPoliciesOptions`](#listhttppoliciesoptions) |
###### Returns
`Promise`\<[`HttpPolicySummary`](#httppolicysummary)\[]>
***
## ingressAuthHeaders()
> **ingressAuthHeaders**(): `Record`\<`string`, `string`>
Guest-side: headers that authenticate this Sailbox as an ingress
allowlist source (only available inside a Sailbox guest).
### Returns
`Record`\<`string`, `string`>
***
## Client
A configured Sail client: the low-level, one-method-per-operation surface
(one config snapshot; env vars are read at construction). Every client
operation is here. The object-model API ([Sailbox](#sailbox), [App](#app),
[Volume](#volume)) is built on top of it.
Construct with [Client.fromEnv](#fromenv) or [Client.fromConfig](#fromconfig).
### Methods
#### buildImageDefinition()
> **buildImageDefinition**(`def`, `timeoutSeconds`, `options?`): `Promise`\<[`ImageSpec`](#imagespec)>
Resolve an image definition and build it to ready, returning the
content-addressed [ImageSpec](#imagespec) to create Sailboxes from. A bare
Debian or devbox base image skips the build; `timeoutSeconds` bounds the whole pipeline
(hashing, uploads, and the build).
By default, Sail may reuse an existing ready build for this
definition. `forceBuild` builds it again and waits for the fresh build
to become ready: new Sailboxes use the fresh image once it is ready,
Sailboxes that already exist keep the filesystem they were created
with, and a forced build that fails changes nothing. For an image
imported with [Image.fromRegistry](#fromregistry) through a tag, a forced build
also asks the registry what the tag points at now and builds that
version. The tag then means that version for your whole organization,
while specs built earlier keep their pinned version. A forced build of
an image built with [Image.fromDockerfile](#fromdockerfile) looks up the tags its
`FROM` and `COPY --from` instructions name and moves those pins for
your whole organization, while specs built earlier keep the versions
their build used. If forced builds overlap, the last-requested
one that succeeds decides which image new Sailboxes use and, for a
tag, what the tag means.
##### Parameters
| Parameter | Type |
| --------------------- | ------------------------------------- |
| `def` | [`ImageDefinition`](#imagedefinition) |
| `timeoutSeconds` | `number` |
| `options` | \{ `forceBuild?`: `boolean`; } |
| `options.forceBuild?` | `boolean` |
##### Returns
`Promise`\<[`ImageSpec`](#imagespec)>
#### buildSpecToReady()
> **buildSpecToReady**(`spec`, `timeoutSeconds`, `options?`): `Promise`\<[`ImageBuild`](#imagebuild-1)>
Build an already-resolved spec to ready (submit + poll), bounded by
`timeoutSeconds`. `forceBuild` builds the image again even if a build
already exists; see [Client.buildImageDefinition](#buildimagedefinition).
##### Parameters
| Parameter | Type |
| --------------------- | ------------------------------ |
| `spec` | [`ImageSpec`](#imagespec) |
| `timeoutSeconds` | `number` |
| `options` | \{ `forceBuild?`: `boolean`; } |
| `options.forceBuild?` | `boolean` |
##### Returns
`Promise`\<[`ImageBuild`](#imagebuild-1)>
#### checkpointSailbox()
> **checkpointSailbox**(`sailboxId`, `options?`): `Promise`\<[`SailboxCheckpoint`](#sailboxcheckpoint-1)>
Take a checkpoint of a Sailbox. `name` sets the handle's display name;
`ttlSeconds`, when given, overrides the server's default retention
window.
##### Parameters
| Parameter | Type |
| ----------- | ----------------------------------------- |
| `sailboxId` | `string` |
| `options` | [`CheckpointOptions`](#checkpointoptions) |
##### Returns
`Promise`\<[`SailboxCheckpoint`](#sailboxcheckpoint-1)>
#### clearSailboxHttpPolicy()
> **clearSailboxHttpPolicy**(`sailboxId`): `Promise`\<`void`>
Clear a Sailbox's attached HTTP policy. The change applies to HTTPS
connections the Sailbox opens after the call; connections already open
keep the previous policy until they close. This also succeeds when no
policy is attached.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<`void`>
#### createFromCheckpoint()
> **createFromCheckpoint**(`params`): `Promise`\<[`SailboxHandle`](#sailboxhandle)>
Create a new Sailbox from a checkpoint.
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------------------- |
| `params` | [`FromCheckpointRequest`](#fromcheckpointrequest) |
##### Returns
`Promise`\<[`SailboxHandle`](#sailboxhandle)>
#### createHttpPolicy()
> **createHttpPolicy**(`name`, `document`): `Promise`\<[`HttpPolicyInfo`](#httppolicyinfo)>
Create an HTTP policy from JSON-encoded text. The document cannot change
after creation. Most callers should use [HttpPolicy.create](#create), which
accepts an object and documents the name rules.
##### Parameters
| Parameter | Type |
| ---------- | -------- |
| `name` | `string` |
| `document` | `string` |
##### Returns
`Promise`\<[`HttpPolicyInfo`](#httppolicyinfo)>
#### createSailbox()
> **createSailbox**(`req`, `timeoutSeconds?`): `Promise`\<[`SailboxHandle`](#sailboxhandle)>
Create a Sailbox. `timeoutSeconds` bounds each create attempt (default
600s); pass `0` for no client-side timeout. A timed-out attempt is retried,
and a retry usually reattaches to the Sailbox already coming up rather than
starting another. When the overall budget is exhausted the Sailbox may still
be coming up server-side: find or terminate it by `name`. `image` defaults
to a plain Debian base.
##### Parameters
| Parameter | Type | Default value |
| ---------------- | ----------------------------------------------- | ------------- |
| `req` | [`CreateSailboxRequest`](#createsailboxrequest) | `undefined` |
| `timeoutSeconds` | `number` | `600` |
##### Returns
`Promise`\<[`SailboxHandle`](#sailboxhandle)>
#### deleteHttpPolicy()
> **deleteHttpPolicy**(`policyId`): `Promise`\<`void`>
Delete an HTTP policy by id. While the policy is attached to a Sailbox,
the call fails with a 409 [ApiError](#apierror) ([HttpPolicy.delete](#delete)
maps that to [HttpPolicyInUseError](#httppolicyinuseerror)).
##### Parameters
| Parameter | Type |
| ---------- | -------- |
| `policyId` | `string` |
##### Returns
`Promise`\<`void`>
#### deleteSecret()
> **deleteSecret**(`name`): `Promise`\<`void`>
Delete a secret by name. While an HTTP policy refers to it, the call
fails with a 409 [ApiError](#apierror) ([Secret.delete](#delete-1) maps that to
[SecretInUseError](#secretinuseerror)).
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `name` | `string` |
##### Returns
`Promise`\<`void`>
#### deleteVolume()
> **deleteVolume**(`volumeId`, `allowMissing?`): `Promise`\<[`VolumeInfo`](#volumeinfo) | `null`>
Delete a volume by id. `allowMissing` tolerates an already-deleted
volume, resolving `null` instead of throwing.
##### Parameters
| Parameter | Type | Default value |
| -------------- | --------- | ------------- |
| `volumeId` | `string` | `undefined` |
| `allowMissing` | `boolean` | `false` |
##### Returns
`Promise`\<[`VolumeInfo`](#volumeinfo) | `null`>
#### downloadDir()
> **downloadDir**(`sailboxId`, `dirs`): `Promise`\<`void`>
Download a guest directory's contents into a local directory, named in
`dirs`.
##### Parameters
| Parameter | Type |
| --------------- | ------------------------------------------------ |
| `sailboxId` | `string` |
| `dirs` | \{ `guestDir`: `string`; `localDir`: `string`; } |
| `dirs.guestDir` | `string` |
| `dirs.localDir` | `string` |
##### Returns
`Promise`\<`void`>
#### enableSsh()
> **enableSsh**(`sailboxId`, `options?`): `Promise`\<[`SshEndpoint`](#sshendpoint) | `null`>
Enable SSH on a Sailbox: trust the org SSH CA, start `sshd`, and expose
guest port 22 as TCP once the CA-only daemon owns it. A non-empty
`allowlist` restricts port 22 to those source addresses or ranges,
replacing any existing restriction. With `wait` (the default), polls until
the endpoint is reachable and returns it, throwing [TimeoutError](#timeouterror) if
it is not within `timeoutSeconds`; with `wait: false`, skips the probe and
resolves `null`.
##### Parameters
| Parameter | Type |
| ----------- | --------------------------------------- |
| `sailboxId` | `string` |
| `options` | [`EnableSshOptions`](#enablesshoptions) |
##### Returns
`Promise`\<[`SshEndpoint`](#sshendpoint) | `null`>
#### exec()
> **exec**(`sailboxId`, `command`, `options?`): `Promise`\<[`ExecProcess`](#execprocess)>
Run a command in a Sailbox and return a handle to the live process. A
`string` command is run via `/bin/sh -lc`; a `string[]` is exec'd directly.
By default a stream you are consuming pauses the command when you fall
behind, so nothing is lost until a cancel or the exec timeout ends the
pauses, and a stream you are not consuming keeps only
its most recent 1 MiB; `outputMode` and `outputBufferBytes` change that (see
[ExecProcess](#execprocess) and [ExecOptions](#execoptions)).
`cwd`/`background` apply to string commands (see [ExecOptions](#execoptions)).
Stopping the command is the caller's job via [ExecProcess.cancel](#cancel).
##### Parameters
| Parameter | Type |
| ----------- | -------------------------------- |
| `sailboxId` | `string` |
| `command` | `string` \| readonly `string`\[] |
| `options` | [`ExecOptions`](#execoptions) |
##### Returns
`Promise`\<[`ExecProcess`](#execprocess)>
#### exposeListener()
> **exposeListener**(`sailboxId`, `guestPort`, `protocol?`, `allowlist?`): `Promise`\<[`Listener`](#listener-1)>
Expose a guest port at runtime. Re-exposing a port under the same
protocol sets its `allowlist` to what you pass, so pass the whole list
every time; an empty one clears the restriction and reopens the port. The
route status starts "unknown": the response confirms configuration, not
reachability.
##### Parameters
| Parameter | Type | Default value |
| ----------- | ------------------------------------- | ------------- |
| `sailboxId` | `string` | `undefined` |
| `guestPort` | `number` | `undefined` |
| `protocol` | [`IngressProtocol`](#ingressprotocol) | `"http"` |
| `allowlist` | readonly `string`\[] | `[]` |
##### Returns
`Promise`\<[`Listener`](#listener-1)>
#### findApp()
> **findApp**(`name`, `mintIfMissing?`): `Promise`\<[`AppInfo`](#appinfo)>
Find an app by name; `mintIfMissing` creates it when absent.
##### Parameters
| Parameter | Type | Default value |
| --------------- | --------- | ------------- |
| `name` | `string` | `undefined` |
| `mintIfMissing` | `boolean` | `false` |
##### Returns
`Promise`\<[`AppInfo`](#appinfo)>
#### getHttpPolicy()
> **getHttpPolicy**(`policyId`): `Promise`\<[`HttpPolicyInfo`](#httppolicyinfo)>
Fetch one HTTP policy by id, including its document.
##### Parameters
| Parameter | Type |
| ---------- | -------- |
| `policyId` | `string` |
##### Returns
`Promise`\<[`HttpPolicyInfo`](#httppolicyinfo)>
#### getListener()
> **getListener**(`sailboxId`, `guestPort`): `Promise`\<[`Listener`](#listener-1)>
Fetch one listener by guest port without waking the Sailbox.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `guestPort` | `number` |
##### Returns
`Promise`\<[`Listener`](#listener-1)>
#### getSailbox()
> **getSailbox**(`sailboxId`): `Promise`\<[`SailboxInfo`](#sailboxinfo)>
Fetch one Sailbox by id.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<[`SailboxInfo`](#sailboxinfo)>
#### getSecret()
> **getSecret**(`name`): `Promise`\<[`SecretInfo`](#secretinfo)>
Fetch one secret's name and timestamps, never its value.
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `name` | `string` |
##### Returns
`Promise`\<[`SecretInfo`](#secretinfo)>
#### getVolume()
> **getVolume**(`name`, `mintIfMissing?`): `Promise`\<[`VolumeInfo`](#volumeinfo)>
Look up an NFS volume by name; `mintIfMissing` creates it when absent.
##### Parameters
| Parameter | Type | Default value |
| --------------- | --------- | ------------- |
| `name` | `string` | `undefined` |
| `mintIfMissing` | `boolean` | `false` |
##### Returns
`Promise`\<[`VolumeInfo`](#volumeinfo)>
#### ingressAuthHeaders()
> **ingressAuthHeaders**(`sailboxId`): `Promise`\<`Record`\<`string`, `string`>>
Ingress-identity headers for this Sailbox, as a name→value map.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<`Record`\<`string`, `string`>>
#### listApps()
> **listApps**(): `Promise`\<[`AppInfo`](#appinfo)\[]>
Every app the current org owns, newest first.
##### Returns
`Promise`\<[`AppInfo`](#appinfo)\[]>
#### listDir()
> **listDir**(`sailboxId`, `path`, `user?`): `Promise`\<[`DirEntry`](#direntry)\[]>
List a directory's immediate entries as structured records.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `path` | `string` |
| `user?` | `string` |
##### Returns
`Promise`\<[`DirEntry`](#direntry)\[]>
#### listHttpPolicies()
> **listHttpPolicies**(`params`): `Promise`\<[`HttpPolicyPage`](#httppolicypage)>
List HTTP policies with paging and an optional id or name search.
##### Parameters
| Parameter | Type |
| ---------------- | ---------------------------------------------------------------- |
| `params` | \{ `limit`: `number`; `offset`: `number`; `search?`: `string`; } |
| `params.limit` | `number` |
| `params.offset` | `number` |
| `params.search?` | `string` |
##### Returns
`Promise`\<[`HttpPolicyPage`](#httppolicypage)>
#### listListeners()
> **listListeners**(`sailboxId`): `Promise`\<[`Listener`](#listener-1)\[]>
List a Sailbox's listeners without waking it.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<[`Listener`](#listener-1)\[]>
#### listSailboxes()
> **listSailboxes**(`params?`): `Promise`\<[`SailboxInfoPage`](#sailboxinfopage)>
List one page of Sailboxes in the current org.
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------------- |
| `params` | [`ListSailboxesQuery`](#listsailboxesquery) |
##### Returns
`Promise`\<[`SailboxInfoPage`](#sailboxinfopage)>
#### listSecrets()
> **listSecrets**(): `Promise`\<[`SecretInfo`](#secretinfo)\[]>
List the organization's secret names and timestamps.
##### Returns
`Promise`\<[`SecretInfo`](#secretinfo)\[]>
#### listVolumes()
> **listVolumes**(`maxObjects?`): `Promise`\<[`VolumeInfo`](#volumeinfo)\[]>
List NFS volumes in the current org.
##### Parameters
| Parameter | Type |
| ------------- | -------- |
| `maxObjects?` | `number` |
##### Returns
`Promise`\<[`VolumeInfo`](#volumeinfo)\[]>
#### makeDir()
> **makeDir**(`sailboxId`, `path`, `user?`): `Promise`\<`void`>
Create a directory and any missing parents (like `mkdir -p`); a no-op if
it already exists.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `path` | `string` |
| `user?` | `string` |
##### Returns
`Promise`\<`void`>
#### orgSshCaPublicKey()
> **orgSshCaPublicKey**(): `Promise`\<`string`>
Fetch (creating on first use) the org SSH certificate authority public key.
Used to preflight SSH before a Sailbox is provisioned.
##### Returns
`Promise`\<`string`>
#### pathExists()
> **pathExists**(`sailboxId`, `path`, `user?`): `Promise`\<`boolean`>
Whether `path` exists in the guest.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `path` | `string` |
| `user?` | `string` |
##### Returns
`Promise`\<`boolean`>
#### pauseSailbox()
> **pauseSailbox**(`sailboxId`): `Promise`\<`void`>
Pause a Sailbox in memory.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<`void`>
#### readStream()
> **readStream**(`sailboxId`, `path`): `Promise`\<[`FileStream`](#filestream)>
Open a streaming read of a guest file.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `path` | `string` |
##### Returns
`Promise`\<[`FileStream`](#filestream)>
#### removePath()
> **removePath**(`sailboxId`, `path`, `user?`): `Promise`\<`void`>
Remove a file or directory tree (like `rm -rf`); a no-op if it is already
absent.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `path` | `string` |
| `user?` | `string` |
##### Returns
`Promise`\<`void`>
#### renameHttpPolicy()
> **renameHttpPolicy**(`policyId`, `name`): `Promise`\<[`HttpPolicyInfo`](#httppolicyinfo)>
Rename an HTTP policy. Its document stays unchanged. Names follow the
same rules as [HttpPolicy.create](#create).
##### Parameters
| Parameter | Type |
| ---------- | -------- |
| `policyId` | `string` |
| `name` | `string` |
##### Returns
`Promise`\<[`HttpPolicyInfo`](#httppolicyinfo)>
#### resolveImage()
> **resolveImage**(`def`): `Promise`\<[`ImageSpec`](#imagespec)>
Resolve an image definition into a content-addressed [ImageSpec](#imagespec):
the SDK walks local directories (gitignore-style `ignore`), hashes every
file, and uploads content the server does not already have.
##### Parameters
| Parameter | Type |
| --------- | ------------------------------------- |
| `def` | [`ImageDefinition`](#imagedefinition) |
##### Returns
`Promise`\<[`ImageSpec`](#imagespec)>
#### resumeSailbox()
> **resumeSailbox**(`sailboxId`): `Promise`\<[`SailboxHandle`](#sailboxhandle)>
Resume a paused or sleeping Sailbox.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<[`SailboxHandle`](#sailboxhandle)>
#### sailboxHttpPolicy()
> **sailboxHttpPolicy**(`sailboxId`): `Promise`\<[`HttpPolicyInfo`](#httppolicyinfo) | `null`>
The HTTP policy attached to a Sailbox, or `null` when none is attached.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<[`HttpPolicyInfo`](#httppolicyinfo) | `null`>
#### setSailboxAutoSleep()
> **setSailboxAutoSleep**(`sailboxId`, `autoSleep`): `Promise`\<`void`>
Replace when Sail may sleep a Sailbox on its own. Each call replaces the
whole setting: switching to `{ automatic: false }` clears any minimum wait
set earlier. Most callers use [Sailbox.setAutoSleep](#setautosleep).
##### Parameters
| Parameter | Type |
| ----------- | --------------------------- |
| `sailboxId` | `string` |
| `autoSleep` | [`AutoSleep`](#autosleep-4) |
##### Returns
`Promise`\<`void`>
#### setSailboxHttpPolicy()
> **setSailboxHttpPolicy**(`sailboxId`, `policyId`): `Promise`\<`void`>
Attach an HTTP policy to a Sailbox, replacing any previous one. The
policy applies to HTTPS connections the Sailbox opens after the call;
connections already open keep the previous policy until they close.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `policyId` | `string` |
##### Returns
`Promise`\<`void`>
#### setSecret()
> **setSecret**(`name`, `value`): `Promise`\<[`SecretInfo`](#secretinfo)>
Set (create or update) an organization secret. Sail never returns the
stored value. After this resolves, the next matching request from a
Sailbox whose attached HTTP policy uses the secret gets the new value.
Names and values follow the same rules as [Secret.set](#set).
##### Parameters
| Parameter | Type |
| --------- | -------- |
| `name` | `string` |
| `value` | `string` |
##### Returns
`Promise`\<[`SecretInfo`](#secretinfo)>
#### shell()
> **shell**(`sailboxId`, `command?`, `options?`): `Promise`\<`number`>
Open an interactive pty session on a Sailbox, bridged to the local
terminal: raw keystrokes reach the remote process, output renders
locally, and resizes propagate. Resolves with the remote process's exit
code. Requires an interactive terminal (stdin and stdout TTYs).
##### Parameters
| Parameter | Type |
| ----------- | ------------------------------- |
| `sailboxId` | `string` |
| `command?` | `string` |
| `options?` | [`ShellOptions`](#shelloptions) |
##### Returns
`Promise`\<`number`>
#### sleepSailbox()
> **sleepSailbox**(`sailboxId`, `wakeAt?`): `Promise`\<`string` | `null`>
Sleep a Sailbox to disk (wakes on traffic), optionally scheduling a
wall-clock wake first. `wakeAt` is an RFC 3339 timestamp; the returned
value is the effective (sooner) wake time, or null when no wake was
requested. Most callers use [Sailbox.sleep](#sleep), which takes and
returns `Date`.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `wakeAt?` | `string` |
##### Returns
`Promise`\<`string` | `null`>
#### terminateSailbox()
> **terminateSailbox**(`sailboxId`): `Promise`\<`void`>
Terminate a Sailbox (idempotent).
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<`void`>
#### unexposeListener()
> **unexposeListener**(`sailboxId`, `guestPort`): `Promise`\<`void`>
Remove a runtime ingress port.
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
| `guestPort` | `number` |
##### Returns
`Promise`\<`void`>
#### upgradeSailbox()
> **upgradeSailbox**(`sailboxId`): `Promise`\<[`UpgradeResult`](#upgraderesult)>
Upgrade a Sailbox's runtime (now if running, else at next wake).
##### Parameters
| Parameter | Type |
| ----------- | -------- |
| `sailboxId` | `string` |
##### Returns
`Promise`\<[`UpgradeResult`](#upgraderesult)>
#### uploadDir()
> **uploadDir**(`sailboxId`, `dirs`): `Promise`\<`void`>
Upload a local directory's contents into a guest directory, named in
`dirs`. `user` gives the uploaded entries to that user instead of the
image's `USER`.
##### Parameters
| Parameter | Type |
| --------------- | ------------------------------------------------------------------- |
| `sailboxId` | `string` |
| `dirs` | \{ `guestDir`: `string`; `localDir`: `string`; `user?`: `string`; } |
| `dirs.guestDir` | `string` |
| `dirs.localDir` | `string` |
| `dirs.user?` | `string` |
##### Returns
`Promise`\<`void`>
#### waitForListener()
> **waitForListener**(`sailboxId`, `guestPort`, `timeoutSeconds`): `Promise`\<[`Listener`](#listener-1)>
Block until the listener on `guestPort` is reachable end to end and
return it, throwing [TimeoutError](#timeouterror) after `timeoutSeconds`. An HTTP
listener is ready once the guest server answers; a TCP listener once the
guest sends bytes or holds the connection open. A connectivity check, not
an application health check.
##### Parameters
| Parameter | Type |
| ---------------- | -------- |
| `sailboxId` | `string` |
| `guestPort` | `number` |
| `timeoutSeconds` | `number` |
##### Returns
`Promise`\<[`Listener`](#listener-1)>
#### writeFiles()
> **writeFiles**(`sailboxId`, `files`, `options?`): `Promise`\<`void`>
Write several complete files in one call, up to eight at a time,
stopping at the first failure.
##### Parameters
| Parameter | Type |
| ----------- | ---------------------------------------------------------------------- |
| `sailboxId` | `string` |
| `files` | `Readonly`\<`Record`\<`string`, `Buffer` \| `Uint8Array` \| `string`>> |
| `options` | [`WriteOptions`](#writeoptions) |
##### Returns
`Promise`\<`void`>
#### writeStream()
> **writeStream**(`sailboxId`, `path`, `options?`): `Promise`\<[`FileWriter`](#filewriter)>
Open a streaming upload to a guest file.
##### Parameters
| Parameter | Type |
| ----------- | ------------------------------- |
| `sailboxId` | `string` |
| `path` | `string` |
| `options` | [`WriteOptions`](#writeoptions) |
##### Returns
`Promise`\<[`FileWriter`](#filewriter)>
#### fromConfig()
> `static` **fromConfig**(`config`): [`Client`](#client)
Build a client from an explicit [ClientConfig](#clientconfig).
##### Parameters
| Parameter | Type |
| --------- | ------------------------------- |
| `config` | [`ClientConfig`](#clientconfig) |
##### Returns
[`Client`](#client)
#### fromEnv()
> `static` **fromEnv**(): [`Client`](#client)
Build a client from the environment (`SAIL_API_KEY`, ...).
##### Returns
[`Client`](#client)
***
## defaultClient()
> **defaultClient**(): [`Client`](#client)
The process-wide client used by the object-model statics ([Sailbox](#sailbox),
[App](#app), [Volume](#volume)) when no explicit `client` is passed. Created
lazily from the environment on first use.
### Returns
[`Client`](#client)
***
## setDefaultClient()
> **setDefaultClient**(`client`): `void`
Override (or clear, with `undefined`) the process-wide default client. Useful
for tests or to point the object-model API at an explicitly configured client.
### Parameters
| Parameter | Type |
| --------- | ---------------------------------- |
| `client` | [`Client`](#client) \| `undefined` |
### Returns
`void`
***
## resolveConfig()
> **resolveConfig**(): [`ResolvedConfig`](#resolvedconfig)
Resolve the SDK config from the environment (`SAIL_API_KEY`, `SAIL_API_URL`,
`SAILBOX_API_URL`, ...) and `~/.sail`, without requiring an API key. This is
the same resolution a client performs at construction.
### Returns
[`ResolvedConfig`](#resolvedconfig)
***
## isSailError()
> **isSailError**(`err`): `err is SailError`
Whether `err` is a [SailError](#sailerror), matched on the stable shape (`code`
string plus `retryable` boolean) rather than the prototype chain. Use it
where `instanceof` can lie: across realms (worker threads, `vm` contexts)
or when two copies of the SDK are loaded. It does not survive
`structuredClone` or `postMessage` serialization, which strip an Error's
custom fields; send `{ name, message, code, retryable }` yourself when an
error must cross a serialization boundary.
### Parameters
| Parameter | Type |
| --------- | --------- |
| `err` | `unknown` |
### Returns
`err is SailError`
## Types
Plain data types accepted by and returned from the calls above.
### AddLocalDir
A tree of local files copied into the image.
#### Properties
| Property | Type | Description |
| -------------- | ---------------------------------------- | ------------------------------------------ |
| `files?` | [`AddLocalDirFile`](#addlocaldirfile)\[] | The files to place under `remotePath`. |
| `remotePath?` | `string` | Absolute guest path of the directory root. |
***
### AddLocalDirFile
One file within an `addLocalDir` step.
#### Properties
| Property | Type | Description |
| ----------------- | -------- | ----------------------------------------------- |
| `contentSha256?` | `string` | SHA-256 of the (already uploaded) file content. |
| `mode?` | `number` | Permission bits (low 9). |
| `relativePath?` | `string` | Path relative to the directory root. |
***
### AddLocalDirOptions
Options for [Image.addLocalDir](#addlocaldir).
#### Properties
| Property | Type | Description |
| -------------- | -------------------- | -------------------------------------------------------------------- |
| `ignore?` | readonly `string`\[] | Gitignore-style patterns to skip (e.g. `"*.pyc"`, `"__pycache__/"`). |
| `ignoreFile?` | `string` | A gitignore-style file whose patterns to skip (e.g. `.gitignore`). |
***
### AddLocalFile
One local file copied into the image, referenced by content hash.
#### Properties
| Property | Type | Description |
| ----------------- | -------- | ------------------------------------------------------------ |
| `contentSha256?` | `string` | SHA-256 of the (already uploaded) file content. |
| `mode?` | `number` | Permission bits (low 9); 0 means the builder default (0644). |
| `remotePath?` | `string` | Absolute guest path to place the file at. |
***
### AddLocalFileOptions
Options for [Image.addLocalFile](#addlocalfile).
#### Properties
| Property | Type | Description |
| -------- | -------- | ---------------------------------------------------------------- |
| `mode?` | `number` | Unix mode bits (low 9); omitted uses the builder default (0644). |
***
### AppInfo
A Sail app.
#### Properties
| Property | Type | Description |
| ------------ | -------- | ------------------------- |
| `createdAt` | `string` | Creation time (RFC 3339). |
| `id` | `string` | Stable app id. |
| `name` | `string` | App name. |
***
### AutoSleep
> **AutoSleep** = [`AutomaticSleep`](#automaticsleep) | [`NeverSleep`](#neversleep)
When Sail may put a Sailbox to sleep on its own.
Sail sleeps Sailboxes that are doing nothing, freeing their memory and
waking them the moment anything needs them again. Waking takes a couple
of seconds: free for a batch job, unwelcome if someone is waiting at a
terminal.
An explicit idle window replaces Sail's default and can make automatic sleep
happen sooner or later. The window only controls when Sail may consider
sleeping the Sailbox. Sail still sleeps it only when it sits fully idle: no
busy process, no imminent timer, nothing a sleep would interrupt. Calling
`sleep()` yourself is unaffected, and so are `pause`, `resume`, and
scheduled wakes.
The two forms are alternatives, so `minSecondsBeforeSleep` cannot be combined
with turning automatic sleep off.
***
### AutomaticSleep
Let Sail decide when to sleep a Sailbox, optionally after a minimum wait.
#### Properties
| Property | Modifier | Type | Description |
| ------------------------- | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `automatic?` | `readonly` | `true` | Sail decides when to sleep it. |
| `minSecondsBeforeSleep?` | `readonly` | `number` | Use this idle window instead of Sail's default. Once it passes, Sail may sleep the Sailbox only when it is fully idle. Whole-second values from 1 through 3600 are accepted; 0 restores Sail's default, and other numeric values are rejected. |
***
### BaseImage
> **BaseImage** = `"debian"` | `"devbox"`
***
### CancelOptions
Options for cancelling an exec.
#### Properties
| Property | Type | Description |
| --------- | --------- | ------------------------------- |
| `force?` | `boolean` | Send SIGKILL instead of SIGINT. |
***
### CheckpointOptions
Options for [Sailbox.checkpoint](#checkpoint).
#### Properties
| Property | Type | Description |
| -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name?` | `string` | Display name for the checkpoint handle. |
| `ttlSeconds?` | `number` | Retention override in whole seconds (must be positive). Set it when you keep a checkpoint to reuse as a template, so the handle does not expire while you still need it; omitted uses the server default. |
***
### ClientConfig
Explicit client configuration (an alternative to environment resolution).
#### Extends
* `Omit`\<`native.ClientConfig`, `"mode"`>
#### Properties
| Property | Type | Description | Inherited from |
| ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| `apiKey` | `string` | Bearer API key. Required. | `Omit.apiKey` |
| `apiUrl?` | `string` | Override the Sail API URL. | `Omit.apiUrl` |
| `ingressUrl?` | `string` | Override the listener ingress base URL (what `SAILBOX_INGRESS_URL` sets from the environment), for custom or self-hosted Sailbox stacks. | `Omit.ingressUrl` |
| `sailboxApiUrl?` | `string` | Override the Sailbox-API URL. | `Omit.sailboxApiUrl` |
***
### ClientOptions
Options for statics that select which [Client](#client) to use.
#### Extended by
* [`FindAppOptions`](#findappoptions)
* [`FindVolumeOptions`](#findvolumeoptions)
* [`ListVolumesOptions`](#listvolumesoptions)
* [`ListHttpPoliciesOptions`](#listhttppoliciesoptions)
* [`CreateSailboxOptions`](#createsailboxoptions)
* [`FromCheckpointOptions`](#fromcheckpointoptions)
* [`ListSailboxesOptions`](#listsailboxesoptions)
* [`ListSailboxesPageOptions`](#listsailboxespageoptions)
#### Properties
| Property | Type | Description |
| ---------- | ------------------- | ------------------------------------------------------- |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. |
***
### CreateSailboxOptions
Options for [Sailbox.create](#create-1): the create request plus a per-attempt
timeout and an optional explicit client. `image` defaults to the prebuilt
Debian base.
#### Extends
* `Omit`\<[`CreateSailboxRequest`](#createsailboxrequest), `"image"` | `"appId"` | `"volumeMounts"` | `"ingressPorts"` | `"visibility"`>.[`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Overrides | Inherited from |
| ---------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------- |
| `app` | `string` \| [`App`](#app) | The owning app, or its id. | - | - |
| `autoSleep?` | [`AutoSleep`](#autosleep-4) | When Sail may sleep this Sailbox on its own. Omit for the default. | - | [`CreateSailboxRequest`](#createsailboxrequest).[`autoSleep`](#autosleep-2) |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | - | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `diskLimitGib?` | `number` | Disk limit in whole GiB within the size's range; the size's default when omitted. | - | `Omit.diskLimitGib` |
| `image?` | [`ImageSpec`](#imagespec) \| [`Image`](#image) | Image spec, or an [Image](#image) builder (built and resolved at create). Defaults to the prebuilt Debian base, which needs no image build. | - | - |
| `imageBuildTimeoutSeconds?` | `number` | Timeout in seconds for building a custom `Image` before create (1800). | `Omit.imageBuildTimeoutSeconds` | - |
| `ingressPorts?` | readonly (`number` \| [`IngressPortInput`](#ingressportinput))\[] | Guest ports to expose: a bare number is HTTP shorthand. | - | - |
| `memoryLimitGib?` | `number` | Memory limit in whole GiB within the size's range; the size's default when omitted. | - | `Omit.memoryLimitGib` |
| `name` | `string` | The Sailbox name. | - | `Omit.name` |
| `networkPolicy?` | [`NetworkPolicy`](#networkpolicy-4) | The Sailbox's network policy. Omit for `"public"`. | - | [`CreateSailboxRequest`](#createsailboxrequest).[`networkPolicy`](#networkpolicy-2) |
| `size?` | [`SailboxSize`](#sailboxsize) | Resource size; `"m"` when omitted. | - | [`CreateSailboxRequest`](#createsailboxrequest).[`size`](#size-1) |
| `timeoutSeconds?` | `number` | Bounds each create attempt (default 600s); pass `0` for no client-side timeout. A timed-out attempt is retried, and a retry usually reattaches to the Sailbox already coming up rather than starting another. When the overall budget is exhausted the Sailbox may still be coming up server-side: find or terminate it by `name`. | - | - |
| `visibility?` | `"org"` \| `"private"` | Who may operate the Sailbox, fixed for its life. `"org"` (the default) lets any credential in your org exec, copy files, SSH, or run lifecycle operations on it; `"private"` restricts all of that to you. An org admin can override a private Sailbox with a recorded reason for exec, files, setting a wake time, and the pause, sleep, resume, terminate, and upgrade operations. SSH, exposing or removing listeners, checkpoint, and restore stay creator-only. `"private"` requires an API key minted by your user. SSH is enabled after create with [Sailbox.enableSsh](#enablessh-1). | - | - |
| `volumes?` | `Readonly`\<`Record`\<`string`, `string` \| [`Volume`](#volume)>> | Shared volumes to mount, mapping an absolute guest path to a [Volume](#volume) or volume id. Volumes are currently in Alpha. To pilot them, reach out in the Sail Slack: [https://join.slack.com/t/sailresearchcrew/shared\_invite/zt-41pdcym9j-UU0Ey\~A\~r6n2H0DQVQsQHQ](https://join.slack.com/t/sailresearchcrew/shared_invite/zt-41pdcym9j-UU0Ey~A~r6n2H0DQVQsQHQ). | - | - |
***
### CreateSailboxRequest
#### Extends
* `Omit`\<`native.CreateSailboxRequest`, `"image"` | `"ingressPorts"` | `"size"` | `"volumeMounts"` | `"autoSleep"` | `"networkPolicy"` | `"visibility"` | `"networkAllowedHosts"`>
#### Properties
| Property | Type | Description | Inherited from |
| ---------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `appId` | `string` | Identifier of the owning app. | `Omit.appId` |
| `autoSleep?` | [`AutoSleep`](#autosleep-4) | When Sail may sleep this Sailbox on its own. Omit for the default. | - |
| `diskLimitGib?` | `number` | Disk limit in whole GiB within the size's range; the size's default when omitted. | `Omit.diskLimitGib` |
| `image?` | [`ImageSpec`](#imagespec) | Image to boot; defaults to a plain Debian base when omitted. | - |
| `imageBuildTimeoutSeconds?` | `number` | Budget in seconds for rebuilding the image if Sail needs to rebuild it before the Sailbox is created; the default build budget applies when omitted. | `Omit.imageBuildTimeoutSeconds` |
| `ingressPorts?` | readonly [`IngressPortInput`](#ingressportinput)\[] | Guest ports to reserve for ingress. | - |
| `memoryLimitGib?` | `number` | Memory limit in whole GiB within the size's range; the size's default when omitted. | `Omit.memoryLimitGib` |
| `name` | `string` | The Sailbox name. | `Omit.name` |
| `networkPolicy?` | [`NetworkPolicy`](#networkpolicy-4) | The Sailbox's network policy. Omit for `"public"`. | - |
| `size?` | [`SailboxSize`](#sailboxsize) | Resource size; `"m"` when omitted. | - |
| `visibility?` | `"org"` \| `"private"` | Who may operate the Sailbox: `"org"` (the default) lets any credential in your org exec, copy files, SSH, or run lifecycle operations on it; `"private"` restricts all of that to the creating user and requires a user-scoped API key. Fixed for the Sailbox's life. | - |
| `volumeMounts?` | readonly [`VolumeMountInput`](#volumemountinput)\[] | NFS volumes to mount. | - |
***
### DeleteVolumeOptions
Options for [Volume.delete](#delete-2).
#### Properties
| Property | Type | Description |
| ---------------- | --------- | ----------------------------------------------------------- |
| `allowMissing?` | `boolean` | Tolerate a volume that is already gone instead of throwing. |
***
### DirEntry
One entry in a directory listing from `Sailbox.fs.ls`, with `type`
narrowed to [DirEntryType](#direntrytype-1).
#### Extends
* `Omit`\<`native.DirEntry`, `"type"`>
#### Properties
| Property | Type | Description | Inherited from |
| --------------- | --------------------------------- | --------------------------------------------------------------------------- | ------------------- |
| `mode` | `number` | Unix permission bits, e.g. `0o644`. The file-type bits are not included. | `Omit.mode` |
| `modifiedTime` | `number` | Last-modified time as a Unix timestamp in seconds (with a fractional part). | `Omit.modifiedTime` |
| `name` | `string` | The entry's base name, with no directory prefix. | `Omit.name` |
| `size` | `number` | Size in bytes as reported by the guest. | `Omit.size` |
| `type` | [`DirEntryType`](#direntrytype-1) | - | - |
***
### DirEntryType
> **DirEntryType** = `"file"` | `"directory"` | `"symlink"` | `"other"`
The kind of a directory entry, reported for the entry itself: a symlink is
`"symlink"` regardless of what it points at.
***
### DockerfileContextDir
One directory of a Dockerfile build context.
#### Properties
| Property | Type | Description |
| ---------------- | -------- | ----------------------------------------------------------------- |
| `mode?` | `number` | Directory mode permission bits; omitted means the default (0755). |
| `relativePath?` | `string` | Slash-separated path relative to the context root. |
***
### DockerfileContextSymlink
One symbolic link of a Dockerfile build context.
#### Properties
| Property | Type | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------------- |
| `relativePath?` | `string` | Slash-separated path relative to the context root. |
| `target?` | `string` | Raw link target, exactly as the link stores it; resolved only inside the built image. |
***
### DockerfileFromResolution
What one external image reference in a Dockerfile resolved to when an
image was built.
#### Properties
| Property | Type | Description |
| ------------- | -------- | ------------------------------------------------------------------- |
| `digestRef?` | `string` | The digest-pinned form of the same reference the build used. |
| `reference?` | `string` | The reference as the Dockerfile's `FROM` or `COPY --from` names it. |
***
### DockerfileImage
Your own Dockerfile built into an image, with its resolved build context.
A `RUN --mount` of type `cache`, `secret`, or `ssh`, a `bind` mount
reading from another image, mount options that are not literal text, and
`ONBUILD` instructions are rejected.
#### Properties
| Property | Type | Description |
| ------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `buildArgs?` | `Record`\<`string`, `string`> | Values for the Dockerfile's `ARG` instructions, like `--build-arg`. Names may not start with the reserved `BUILDKIT_` prefix, and Docker's proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `FTP_PROXY`, `NO_PROXY`, `ALL_PROXY`, in any letter case) are rejected; a step that needs a proxy can set one inside its `RUN` command. |
| `contextDirs?` | [`DockerfileContextDir`](#dockerfilecontextdir)\[] | Every directory in the context with its mode, so `COPY` of an empty directory works and directory modes survive like they do in a docker build. |
| `contextFiles?` | [`AddLocalDirFile`](#addlocaldirfile)\[] | Content manifest of the build context the Dockerfile's `COPY` and `ADD` instructions read from; empty builds without a context. |
| `contextSymlinks?` | [`DockerfileContextSymlink`](#dockerfilecontextsymlink)\[] | Symbolic links in the context, carried as links the way a docker build context carries them. |
| `dockerfile?` | `string` | Full Dockerfile text. |
| `pinnedFrom?` | [`DockerfileFromResolution`](#dockerfilefromresolution)\[] | The version each external image reference resolved to when this spec was built, filled in on the spec a completed build returns. A spec carrying these keeps naming the image its build produced, even after a forced build moves what the references mean for your organization; a forced build looks every reference up again instead. |
***
### DockerfileSourceInput
A Dockerfile to build into the image, with its local build context.
A `RUN --mount` of type `cache`, `secret`, or `ssh`, a `bind` mount
reading from another image, mount options that are not literal text, and
`ONBUILD` instructions are rejected.
#### Properties
| Property | Type | Description |
| -------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `buildArgs?` | `Record`\<`string`, `string`> | Values for the Dockerfile's `ARG` instructions, like `--build-arg`. Names may not start with the reserved `BUILDKIT_` prefix, and Docker's proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `FTP_PROXY`, `NO_PROXY`, `ALL_PROXY`, in any letter case) are rejected; a step that needs a proxy can set one inside its `RUN` command. |
| `contextDir?` | `string` | Local directory the Dockerfile's `COPY` and `ADD` instructions read from; if omitted, the build runs without a context. |
| `dockerfile` | `string` | Path to a Dockerfile on this machine, or with `isContents` its full contents. |
| `ignore?` | `string`\[] | `.dockerignore`-style patterns to skip in the context directory, applied after the `.dockerignore` rules in effect so they take precedence on conflict. |
| `isContents?` | `boolean` | Read `dockerfile` as literal Dockerfile text instead of a path. |
***
### EnableSshOptions
Options for enabling SSH on a Sailbox.
#### Properties
| Property | Type | Description |
| ------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowlist?` | readonly `string`\[] | Source addresses or ranges allowed to reach port 22, replacing any existing restriction. Left empty, a first enable opens the port to any source, and a re-enable leaves an existing restriction unchanged. |
| `timeoutSeconds?` | `number` | Give up waiting after this many seconds (default 60; `Infinity` waits indefinitely). |
| `wait?` | `boolean` | Poll until the SSH route is ready (default true). |
***
### ExecOptions
#### Extends
* `Omit`\<`native.ExecStartOptions`, `"env"` | `"outputMode"` | `"pty"` | `"term"` | `"cols"` | `"rows"`>
#### Properties
| Property | Type | Description | Inherited from |
| --------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `background?` | `boolean` | Detach the command so it keeps running and the call returns immediately; output is discarded (shell commands only, incompatible with `openStdin`/`pty`). | `Omit.background` |
| `cwd?` | `string` | Working directory to run the command in (shell commands only). Unset starts the command in the image's working directory, or `/` when the image does not set one. | `Omit.cwd` |
| `env?` | `Readonly`\<`Record`\<`string`, `string`>> | Extra environment for the command. Entries override the guest's defaults (including `LANG` and the `IS_SANDBOX=1` sandbox marker) and the image env, but a few reserved variables that identify the Sailbox (such as `SAILBOX_ID`) cannot be overridden. | - |
| `idempotencyKey?` | `string` | Stable key so a reconnect reattaches to the same command. An exec has one live handle at a time: a second handle started with the same key takes over the stream, and the first stops receiving live output and resolves from a bounded recorded result. A first handle reconnecting after a dropped connection can race a handle that attached meanwhile, and either handle's result may come back incomplete; avoid overlapping same-key handles. The UTF-8 value can be up to 256 KiB. | `Omit.idempotencyKey` |
| `openStdin?` | `boolean` | Leave stdin open for `writeStdin`. | `Omit.openStdin` |
| `outputBufferBytes?` | `number` | Size of each stream's output buffer in bytes, 1 MiB by default. Must be between 64 KiB and 64 MiB. | `Omit.outputBufferBytes` |
| `outputMode?` | [`OutputMode`](#outputmode-1) | What happens when a stream's output buffer fills; see [OutputMode](#outputmode-1). `"auto"` by default. | - |
| `pty?` | `boolean` \| [`PtyConfig`](#ptyconfig) | Run the command under a pseudo-terminal: `true` for the default terminal, or a [PtyConfig](#ptyconfig) to set `term` and the initial window. `isatty()` is true, control bytes on stdin become signals, stdout and stderr merge onto one stream, and `resize` adjusts the window. Implies `openStdin`. | - |
| `timeoutSeconds?` | `number` | Wall-clock limit in seconds before the server kills the command. | `Omit.timeoutSeconds` |
| `user?` | `string` | Run the command as this guest user: a user name or numeric uid, optionally with a group appended after a colon (`"alice"`, `"1000"`, `"alice:staff"`, the Docker `USER` syntax). A named user must exist in the Sailbox's `/etc/passwd`; a numeric uid need not. `HOME` (and `USER`/`LOGNAME` when a name resolves) default to the resolved account, with `env` entries still winning. When unset, commands run as the image's `USER` if the image sets one, root otherwise; pass `"0:0"` to force root (`"root"` is a user name like any other, resolved through the Sailbox's `/etc/passwd`). Sailboxes created before user support shipped must call `upgrade()` once first; until then such execs fail rather than run as root. The exact spelling `"0:0"` needs no upgrade. | `Omit.user` |
***
### ExecResult
The result of a finished exec.
`stdout` and `stderr` hold only each stream's buffer, its most recent
output (`outputBufferBytes`, 1 MiB by default). To get every byte, consume
the live stream right after `exec()` returns; see `ExecProcess` for how
consuming a stream affects the command.
#### Properties
| Property | Type | Description |
| ------------------ | --------- | -------------------------------------------------------------------------------------------------------- |
| `exitCode` | `number` | The command's exit code. |
| `stderr` | `string` | The most recent standard error, up to the exec's buffer size (1 MiB by default; see `stderrTruncated`). |
| `stderrTruncated` | `boolean` | The command wrote more stderr than `stderr` holds, so older bytes are missing. |
| `stdout` | `string` | The most recent standard output, up to the exec's buffer size (1 MiB by default; see `stdoutTruncated`). |
| `stdoutTruncated` | `boolean` | The command wrote more stdout than `stdout` holds, so older bytes are missing. |
| `timedOut` | `boolean` | Whether the command was killed for exceeding its timeout. |
***
### ExposeOptions
Options for [Sailbox.expose](#expose).
#### Properties
| Property | Type | Description |
| ------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowlist?` | readonly `string`\[] | Sources allowed to reach the port: an address or a range, or a Sail app name on an `http` listener. An app name cannot read as an address or a range, and cannot contain a `/`. An address must not carry an IPv6 zone, such as `fe80::1%eth0`, which names an interface on one machine rather than a source. Empty allows all. |
| `protocol?` | [`IngressProtocol`](#ingressprotocol) | Wire protocol to expose (default `"http"`). |
***
### FindAppOptions
Options for [App.find](#find).
#### Extends
* [`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ----------------- | ------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `mintIfMissing?` | `boolean` | Create the app when it does not exist yet. | - |
***
### FindVolumeOptions
Options for [Volume.find](#find-1).
#### Extends
* [`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ----------------- | ------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `mintIfMissing?` | `boolean` | Create the volume when it does not exist yet. | - |
***
### FromCheckpointOptions
Options for [Sailbox.fromCheckpoint](#fromcheckpoint).
#### Extends
* [`FromCheckpointRequest`](#fromcheckpointrequest).[`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `checkpointId` | `string` | The checkpoint to restore from. | [`FromCheckpointRequest`](#fromcheckpointrequest).[`checkpointId`](#checkpointid-1) |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `name` | `string` | Name for the new Sailbox. | [`FromCheckpointRequest`](#fromcheckpointrequest).[`name`](#name-11) |
| `timeoutSeconds?` | `number` | Whole seconds, positive when given. Bounds the call, since restoring a checkpoint can block for many minutes while the new Sailbox queues for capacity. A call that times out fails, and the restore may still finish in the background; the new Sailbox then shows up when you list Sailboxes. Unset waits without a bound. | [`FromCheckpointRequest`](#fromcheckpointrequest).[`timeoutSeconds`](#timeoutseconds-4) |
***
### FromCheckpointRequest
The create-from-checkpoint request.
#### Extended by
* [`FromCheckpointOptions`](#fromcheckpointoptions)
#### Properties
| Property | Type | Description |
| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `checkpointId` | `string` | The checkpoint to restore from. |
| `name` | `string` | Name for the new Sailbox. |
| `timeoutSeconds?` | `number` | Whole seconds, positive when given. Bounds the call, since restoring a checkpoint can block for many minutes while the new Sailbox queues for capacity. A call that times out fails, and the restore may still finish in the background; the new Sailbox then shows up when you list Sailboxes. Unset waits without a bound. |
***
### FromDockerfileOptions
Options for [Image.fromDockerfile](#fromdockerfile).
#### Properties
| Property | Type | Description |
| ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `architecture?` | [`ImageArchitecture`](#imagearchitecture) | Target CPU architecture the image is built for (default `amd64`). |
| `buildArgs?` | `Readonly`\<`Record`\<`string`, `string`>> | Values for the Dockerfile's `ARG` instructions, like `--build-arg`. Names may not start with the reserved `BUILDKIT_` prefix, and Docker's proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `FTP_PROXY`, `NO_PROXY`, `ALL_PROXY`, in any letter case) are rejected; a step that needs a proxy can set one inside its `RUN` command. |
| `contextDir?` | `string` | Local directory the Dockerfile's `COPY` and `ADD` instructions read from; if omitted, the build runs without a context. |
| `ignore?` | readonly `string`\[] | `.dockerignore`-style patterns to skip in the context directory, applied after the `.dockerignore` rules in effect so they take precedence on conflict. |
***
### FromRegistryOptions
Options for [Image.fromRegistry](#fromregistry).
#### Properties
| Property | Type | Description |
| ---------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `architecture?` | [`ImageArchitecture`](#imagearchitecture) | Require the image to have been built for this CPU architecture. Leave it unset to use the architecture the image was built for. |
***
### FsOptions
Options for the directory helpers ([SailboxFs.mkdir](#mkdir),
[SailboxFs.remove](#remove), [SailboxFs.exists](#exists), [SailboxFs.ls](#ls)).
#### Properties
| Property | Type | Description | | |
| -------- | -------- | -------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `user?` | `string` | Run the operation as this user (Docker's `USER` syntax: \`name | uid\[:group | gid]`), with its permissions enforced by the guest kernel. Unset runs as root, so the helpers work on any path; `"0:0"`is always root. A`user`other than`"0:0"\` requires a Sailbox whose guest honors requested users; on older Sailboxes the operation fails until [Sailbox.upgrade](#upgrade) is called. |
***
### HttpEndpoint
The routable HTTPS address of an `http` listener.
#### Properties
| Property | Type | Description |
| -------- | -------- | ----------------------------------------- |
| `kind` | `"http"` | - |
| `url` | `string` | The HTTPS URL to reach the guest service. |
***
### HttpPolicyAddChange
> **HttpPolicyAddChange** = \{ `headers`: [`HttpPolicyValueMap`](#httppolicyvaluemap); `query?`: [`HttpPolicyValueMap`](#httppolicyvaluemap); } | \{ `headers?`: [`HttpPolicyValueMap`](#httppolicyvaluemap); `query`: [`HttpPolicyValueMap`](#httppolicyvaluemap); }
Headers or query parameters to append to a request. At least one of
`headers` or `query` is required, and the union encodes that, so an empty
`add` is a compile error.
***
### HttpPolicyDocument
> **HttpPolicyDocument** = `object`
A policy document: host patterns mapped to their rules.
A key is an exact hostname (`api.example.com`), a single-label wildcard
(`*.example.com`), or the catch-all `*`. Sail picks one host, most
specific first, and host entries never combine.
```ts theme={null}
const document: HttpPolicyDocument = {
"api.example.com": {
rules: [
{
match: { path: { prefix: "/v1/" } },
request: {
set: { headers: { authorization: "Bearer ${secrets.API_KEY}" } },
},
},
],
},
};
```
Sail validates the document when you create the policy and identifies any
field that needs to be fixed. See the
[HTTP policy guide](/sailboxes-http-policies) for the accepted fields and
examples.
#### Index Signature
\[`host`: `string`]: [`HttpPolicyHost`](#httppolicyhost)
***
### HttpPolicyForward
Send the request to a different HTTPS host. `host` is an exact hostname;
`port` defaults to 443.
#### Properties
| Property | Type | Description |
| -------- | -------- | ---------------------------------------------------- |
| `host` | `string` | Exact hostname, without a scheme, port, or wildcard. |
| `port?` | `number` | HTTPS port. Defaults to 443. |
***
### HttpPolicyHost
The rules for one host, in order.
#### Properties
| Property | Type | Description |
| ---------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing_alpn?` | `"http/1.1"` | How to treat an HTTPS connection that does not announce an HTTP version when it connects. By default such a connection passes through with the policy not applied. Set `"http/1.1"` to treat it as HTTP/1.1 so the rules apply. Allowed only on an exact host. Almost every HTTP client announces a version, so most policies do not need this. |
| `rules` | readonly [`HttpPolicyRule`](#httppolicyrule)\[] | Rules for this host, in the order Sail tests them. |
***
### HttpPolicyInfo
A policy's id, name, and document. Timestamps are RFC 3339 strings.
#### Properties
| Property | Type | Description |
| ------------ | -------- | ------------------------------------------------------------------------------------------------- |
| `createdAt` | `string` | When the policy was created (RFC 3339). |
| `document` | `string` | The saved policy document as JSON text: Sail's normalized form of the document given at creation. |
| `id` | `string` | The policy's stable identifier. |
| `name` | `string` | The policy's name (the only mutable field). |
| `updatedAt` | `string` | When the policy's name last changed (RFC 3339). |
***
### HttpPolicyLike
> **HttpPolicyLike** = [`HttpPolicy`](#httppolicy) | [`HttpPolicySummary`](#httppolicysummary) | `string`
A policy object, listing summary, or policy id accepted by
[Sailbox.setHttpPolicy](#sethttppolicy).
***
### HttpPolicyListItem
One HTTP policy returned by [Client.listHttpPolicies](#listhttppolicies).
#### Properties
| Property | Type | Description |
| ------------------------ | ----------- | ----------------------------------------------------------- |
| `attachmentCount` | `number` | How many Sailboxes the policy is currently attached to. |
| `createdAt` | `string` | When the policy was created, as an RFC 3339 string. |
| `hostCount` | `number` | How many hosts the document covers. |
| `id` | `string` | The policy's stable identifier. |
| `name` | `string` | The policy's name. |
| `referencedSecretNames` | `string`\[] | The secret names the document refers to. |
| `ruleCount` | `number` | How many rules the document carries across every host. |
| `updatedAt` | `string` | When the policy's name last changed, as an RFC 3339 string. |
***
### HttpPolicyMatcher
> **HttpPolicyMatcher** = `string` | \{ `equals`: `string`; } | \{ `prefix`: `string`; } | \{ `one_of`: readonly `string`\[]; }
Match one exact string, a prefix, or one string from a list.
A plain string is an exact match.
***
### HttpPolicyNameValueMatcher
> **HttpPolicyNameValueMatcher** = \{ `name`: [`HttpPolicyMatcher`](#httppolicymatcher); `present?`: `true`; `value?`: [`HttpPolicyMatcher`](#httppolicymatcher); } | \{ `name?`: [`HttpPolicyMatcher`](#httppolicymatcher); `present?`: `true`; `value`: [`HttpPolicyMatcher`](#httppolicymatcher); } | \{ `name`: [`HttpPolicyMatcher`](#httppolicymatcher); `present`: `false`; `value?`: `never`; }
Match a header or query parameter by name, value, or both. Every
condition needs a `name` or a `value`; `present` defaults to `true`,
meaning a header or parameter matching the condition must be present.
Set `present` to `false` with `name` alone to require that name to be
absent. The union
encodes those rules, so a condition with neither field, or a `value`
combined with `present: false`, is a compile error.
***
### HttpPolicyPage
One page returned by [Client.listHttpPolicies](#listhttppolicies).
#### Properties
| Property | Type | Description |
| ---------- | ---------------------------------------------- | ------------------------------------------------- |
| `hasMore` | `boolean` | Whether another page is available. |
| `items` | [`HttpPolicyListItem`](#httppolicylistitem)\[] | The policies on this page. |
| `limit` | `number` | The requested page size. |
| `offset` | `number` | The requested zero-based offset. |
| `total` | `number` | The number of matching policies across all pages. |
***
### HttpPolicyRemoveChange
> **HttpPolicyRemoveChange** = \{ `headers`: readonly `string`\[]; `query?`: readonly `string`\[]; } | \{ `headers?`: readonly `string`\[]; `query`: readonly `string`\[]; }
Header and query parameter names to delete from a request. At least one
of `headers` or `query` is required, and the union encodes that, so an
empty `remove` is a compile error.
***
### HttpPolicyRequestChange
> **HttpPolicyRequestChange** = \{ `add?`: [`HttpPolicyAddChange`](#httppolicyaddchange); `remove?`: [`HttpPolicyRemoveChange`](#httppolicyremovechange); `set`: [`HttpPolicySetChange`](#httppolicysetchange); } | \{ `add`: [`HttpPolicyAddChange`](#httppolicyaddchange); `remove?`: [`HttpPolicyRemoveChange`](#httppolicyremovechange); `set?`: [`HttpPolicySetChange`](#httppolicysetchange); } | \{ `add?`: [`HttpPolicyAddChange`](#httppolicyaddchange); `remove`: [`HttpPolicyRemoveChange`](#httppolicyremovechange); `set?`: [`HttpPolicySetChange`](#httppolicysetchange); }
Change the outbound request before it is sent.
`set` replaces or creates values, `add` appends, `remove` deletes. At
least one operation is required, and the union encodes that, so an empty
change is a compile error. Only `set.headers` and `set.query` may contain
a `${secrets.NAME}` reference.
***
### HttpPolicyRequestMatch
> **HttpPolicyRequestMatch** = \{ `headers?`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; `method`: [`HttpPolicyStringList`](#httppolicystringlist); `path?`: [`HttpPolicyMatcher`](#httppolicymatcher); `query?`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; } | \{ `headers?`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; `method?`: [`HttpPolicyStringList`](#httppolicystringlist); `path`: [`HttpPolicyMatcher`](#httppolicymatcher); `query?`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; } | \{ `headers`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; `method?`: [`HttpPolicyStringList`](#httppolicystringlist); `path?`: [`HttpPolicyMatcher`](#httppolicymatcher); `query?`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; } | \{ `headers?`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; `method?`: [`HttpPolicyStringList`](#httppolicystringlist); `path?`: [`HttpPolicyMatcher`](#httppolicymatcher); `query`: readonly [`HttpPolicyNameValueMatcher`](#httppolicynamevaluematcher)\[]; }
The conditions a request must meet for a rule to apply. At least one
condition is required, and the union encodes that, so an empty `match`
is a compile error. `method` is one or more case-sensitive HTTP methods,
such as `GET`; `path` matches the absolute request path; `headers` and
`query` are condition lists in which every entry must match.
***
### HttpPolicyResponse
Return a response without sending an HTTP request to the destination.
#### Properties
| Property | Type | Description |
| ----------- | ------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `body?` | `string` | Response body. Set a content-type header when needed. Not allowed with status 204 or 304. |
| `headers?` | [`HttpPolicyValueMap`](#httppolicyvaluemap) | Headers to return. |
| `status` | `number` | HTTP status code to return. |
***
### HttpPolicyRule
> **HttpPolicyRule** = \{ `forward?`: `never`; `match?`: [`HttpPolicyRequestMatch`](#httppolicyrequestmatch); `request?`: `never`; `respond`: [`HttpPolicyResponse`](#httppolicyresponse); } | \{ `forward?`: [`HttpPolicyForward`](#httppolicyforward); `match?`: [`HttpPolicyRequestMatch`](#httppolicyrequestmatch); `request?`: [`HttpPolicyRequestChange`](#httppolicyrequestchange); `respond?`: `never`; }
One rule. `match` narrows which requests it covers; omitting it matches
every request, which is only allowed on the last rule. The first matching
rule decides the outcome, so order matters.
A rule can return a response without sending the request (`respond`),
forward the request to another HTTPS host (`forward`), change the request
before it is sent (`request`), or send it unchanged. `forward` and
`request` combine; `respond` cannot be combined with either, and the union
encodes that, so a rule that mixes them is a compile error.
***
### HttpPolicySetChange
> **HttpPolicySetChange** = \{ `headers?`: [`HttpPolicyValueMap`](#httppolicyvaluemap); `path`: `string`; `query?`: [`HttpPolicyValueMap`](#httppolicyvaluemap); } | \{ `headers`: [`HttpPolicyValueMap`](#httppolicyvaluemap); `path?`: `string`; `query?`: [`HttpPolicyValueMap`](#httppolicyvaluemap); } | \{ `headers?`: [`HttpPolicyValueMap`](#httppolicyvaluemap); `path?`: `string`; `query`: [`HttpPolicyValueMap`](#httppolicyvaluemap); }
Values to replace or create on a request. At least one of `path`,
`headers`, or `query` is required, and the union encodes that, so an
empty `set` is a compile error. `path` is a replacement absolute path.
Values under `headers` and `query` are templates: `${secrets.NAME}` inserts
a secret, and a literal dollar sign must be written `$$`. Stored secret
references are allowed only under `headers` and `query`, never in `path`.
***
### HttpPolicyStringList
> **HttpPolicyStringList** = `string` | readonly `string`\[]
One string or a nonempty list of strings.
***
### HttpPolicySummary
A policy as returned by [HttpPolicy.list](#list-1), with usage counts but
without the document. Fetch the full policy with [HttpPolicy.get](#get).
#### Properties
| Property | Type | Description |
| ------------------------ | ----------- | ------------------------------------------------------- |
| `attachmentCount` | `number` | How many Sailboxes the policy is currently attached to. |
| `createdAt` | `Date` | When the policy was created. |
| `hostCount` | `number` | How many hosts the document covers. |
| `id` | `string` | The policy's stable identifier. |
| `name` | `string` | The policy's name. |
| `referencedSecretNames` | `string`\[] | The secret names the document refers to. |
| `ruleCount` | `number` | How many rules the document carries across every host. |
| `updatedAt` | `Date` | When the policy's name last changed. |
***
### HttpPolicyValueMap
> **HttpPolicyValueMap** = `object`
Header or query parameter names mapped to one or more values.
#### Index Signature
\[`name`: `string`]: [`HttpPolicyStringList`](#httppolicystringlist)
***
### ImageArchitecture
> **ImageArchitecture** = `"amd64"` | `"arm64"`
***
### ImageBuild
The state of a custom image build.
#### Extends
* `Omit`\<`native.ImageBuild`, `"status"`>
#### Properties
| Property | Type | Description | Inherited from |
| ------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `dockerfilePins?` | [`DockerfileFromResolution`](#dockerfilefromresolution)\[] | What each external image reference resolved to when the spec's source is a Dockerfile; absent otherwise. Carrying these in the spec's `pinnedFrom` keeps creating from the image this build produced, even after a forced build moves what the references mean for your organization. | `Omit.dockerfilePins` |
| `errorMessage?` | `string` | Human-readable failure detail; present when `status` is `failed`. | `Omit.errorMessage` |
| `imageId` | `string` | The content-addressed image id. | `Omit.imageId` |
| `resolvedOciRef?` | `string` | The digest-pinned form of the spec's registry reference when the spec's source is an OCI image; absent otherwise. Creating from this reference instead of the submitted tag keeps naming the same registry content even if the tag has moved since. | `Omit.resolvedOciRef` |
| `status` | [`ImageBuildStatus`](#imagebuildstatus-1) | - | - |
***
### ImageBuildOptions
Options for [Image.build](#build).
#### Properties
| Property | Type | Description |
| ------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. |
| `forceBuild?` | `boolean` | Build the image again even if a build already exists, and wait for the fresh build to become ready. New Sailboxes use the fresh image once it is ready, Sailboxes that already exist keep the filesystem they were created with, and a forced build that fails changes nothing. For an image imported with [Image.fromRegistry](#fromregistry) through a tag, a forced build also asks the registry what the tag points at now and builds that version. The tag then means that version for your whole organization, while specs built earlier keep their pinned version. A forced build of an image built with [Image.fromDockerfile](#fromdockerfile) looks up the tags its `FROM` and `COPY --from` instructions name and moves those pins for your whole organization, while specs built earlier keep the versions their build used. If forced builds overlap, the last-requested one that succeeds decides which image new Sailboxes use and, for a tag, what the tag means. |
| `timeoutSeconds?` | `number` | Timeout in seconds bounding the whole pipeline, including hashing, uploads, the build, and any automatic retries (default 1800). |
***
### ImageBuildStatus
> **ImageBuildStatus** = `"unknown"` | `"queued"` | `"building"` | `"ready"` | `"failed"`
The status of a custom image build.
***
### ImageBuildStep
> **ImageBuildStep** = \{ `addLocalDir?`: `never`; `addLocalFile?`: `never`; `aptInstall`: [`PackageInstall`](#packageinstall); `pipInstall?`: `never`; `runCommand?`: `never`; } | \{ `addLocalDir?`: `never`; `addLocalFile?`: `never`; `aptInstall?`: `never`; `pipInstall`: [`PackageInstall`](#packageinstall); `runCommand?`: `never`; } | \{ `addLocalDir?`: `never`; `addLocalFile?`: `never`; `aptInstall?`: `never`; `pipInstall?`: `never`; `runCommand`: [`RunCommand`](#runcommand-2); } | \{ `addLocalDir?`: `never`; `addLocalFile`: [`AddLocalFile`](#addlocalfile-1); `aptInstall?`: `never`; `pipInstall?`: `never`; `runCommand?`: `never`; } | \{ `addLocalDir`: [`AddLocalDir`](#addlocaldir-1); `addLocalFile?`: `never`; `aptInstall?`: `never`; `pipInstall?`: `never`; `runCommand?`: `never`; }
One build step: exactly one operation. Each union member `never`-types the
other operations, so a step that sets two of them is a compile error (a
bare union of the operations would accept it).
***
### ImageDefinition
A custom image definition: a base, registry, or Dockerfile image plus
ordered build steps, where local-file steps still reference paths on
this machine.
#### Properties
| Property | Type | Description |
| ---------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `architecture?` | `string` | Target CPU architecture: `amd64` or `arm64`. Unset means `amd64` with `base` and `dockerfile`, and with `ociRef` means whichever architecture the registry image was built for (`amd64` when it was built for both). Setting it with `ociRef` requires the image to provide that architecture. |
| `base?` | `string` | Base image to build on: `debian` or `devbox`. The devbox base is a prebuilt development environment that includes Docker, with the daemon started automatically when the Sailbox boots and kept running across sleeps. The daemon can take a few seconds to accept commands right after boot. If it stops, it is not restarted automatically. Mutually exclusive with `ociRef` and `dockerfile`. |
| `dockerfile?` | [`DockerfileSourceInput`](#dockerfilesourceinput) | Your own Dockerfile built into the image; mutually exclusive with `base` and `ociRef`. Every image its `FROM` (and `COPY --from`) instructions name must live on a supported public registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`; a short name like `python:3.12` means `docker.io/library/python:3.12`). Each named image is pinned to the version its tag pointed at the first time your organization used it, and those pinned versions become part of the built image's identity, so rebuilding the same spec reuses the same image even after a tag moves. A forced build looks the tags up again and builds what they point at now. The built image's `ENV`, `WORKDIR`, and `USER` become the Sailbox defaults for commands you run; its `ENTRYPOINT` and `CMD` are not run, because a Sailbox manages its own processes. |
| `env?` | `Record`\<`string`, `string`> | Environment variables baked into the image. |
| `ociRef?` | `string` | Your own image as the root filesystem: a reference to a Debian- or Ubuntu-based image whose first segment names a supported public registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`), with an optional `:tag` or `@sha256:<64 hex>` pin (no tag means the `latest` tag). A tag is pinned for your organization once an image has been built from it: later builds keep getting that version, even if the tag moves upstream. A forced build looks the tag up again and moves the pin for your whole organization. If forced builds of the same tag overlap, the last-requested one that succeeds decides what the tag means, no matter which build finishes first. A digest names exactly one image, so it never moves. The image's `ENV`, `WORKDIR`, and `USER` become the Sailbox defaults for commands you run; its `ENTRYPOINT` and `CMD` are not run, because a Sailbox manages its own processes. Mutually exclusive with `base` and `dockerfile`. |
| `steps?` | [`ImageDefinitionStep`](#imagedefinitionstep)\[] | Ordered build steps. |
***
### ImageDefinitionStep
One image-definition step. Exactly one of the fields must be set.
#### Properties
| Property | Type | Description |
| ---------------- | ----------------------------------- | ------------------------------------------- |
| `addLocalDir?` | [`LocalDirInput`](#localdirinput) | Bake a local directory tree into the image. |
| `addLocalFile?` | [`LocalFileInput`](#localfileinput) | Bake one local file into the image. |
| `aptInstall?` | `string`\[] | Install system packages with apt. |
| `pipInstall?` | `string`\[] | Install Python packages with pip. |
| `runCommand?` | `string` | Run a shell command during the build. |
***
### ImageSpec
A Sailbox image: a base or registry image plus ordered build steps.
#### Extends
* `Omit`\<`native.ImageSpec`, `"base"` | `"buildSteps"` | `"architecture"` | `"filesystem"`>
#### Properties
| Property | Type | Description | Inherited from |
| ---------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------- |
| `architecture?` | [`ImageArchitecture`](#imagearchitecture) | Target CPU architecture. Unset means `amd64` with `base` and `dockerfile`, and with `ociRef` means whichever architecture the registry image was built for (`amd64` when it was built for both). Setting it with `ociRef` requires the image to provide that architecture. | - |
| `base?` | [`BaseImage`](#baseimage) | Base image to build on. The `devbox` base is a prebuilt development environment that includes Docker, with the daemon started automatically when the Sailbox boots and kept running across sleeps. The daemon can take a few seconds to accept commands right after boot. If it stops, it is not restarted automatically. Mutually exclusive with `ociRef` and `dockerfile`. | - |
| `buildSteps?` | [`ImageBuildStep`](#imagebuildstep)\[] | Ordered build steps applied on top of the image source. | - |
| `dockerfile?` | [`DockerfileImage`](#dockerfileimage) | Your own Dockerfile built into the image; mutually exclusive with `base` and `ociRef`. Every image its `FROM` (and `COPY --from`) instructions name must live on a supported public registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`; a short name like `python:3.12` means `docker.io/library/python:3.12`). Each named image is pinned to the version its tag pointed at the first time your organization used it, and those pinned versions become part of the built image's identity, so rebuilding the same spec reuses the same image even after a tag moves. A forced build looks the tags up again and builds what they point at now. The built image's `ENV`, `WORKDIR`, and `USER` become the Sailbox defaults for commands you run; its `ENTRYPOINT` and `CMD` are not run, because a Sailbox manages its own processes. | `Omit.dockerfile` |
| `env?` | `Record`\<`string`, `string`> | Environment variables baked into the image. | `Omit.env` |
| `ociRef?` | `string` | Your own image as the root filesystem: a reference to a Debian- or Ubuntu-based image whose first segment names a supported public registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`), with an optional `:tag` or `@sha256:<64 hex>` pin (no tag means the `latest` tag). A tag is pinned for your organization once an image has been built from it: later builds keep getting that version, even if the tag moves upstream. A forced build looks the tag up again and moves the pin for your whole organization. If forced builds of the same tag overlap, the last-requested one that succeeds decides what the tag means, no matter which build finishes first. A digest names exactly one image, so it never moves. The image's `ENV`, `WORKDIR`, and `USER` become the Sailbox defaults for commands you run; its `ENTRYPOINT` and `CMD` are not run, because a Sailbox manages its own processes. Mutually exclusive with `base` and `dockerfile`. | `Omit.ociRef` |
***
### IngressPortInput
A guest port to reserve for ingress at create time.
#### Extends
* `Omit`\<`native.IngressPortInput`, `"protocol"` | `"allowlist"`>
#### Properties
| Property | Type | Description | Inherited from |
| ------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| `allowlist?` | readonly `string`\[] | Sources allowed to reach the port: an address or a range, or a Sail app name on an `http` listener. An app name cannot read as an address or a range, and cannot contain a `/`. An address must not carry an IPv6 zone, such as `fe80::1%eth0`, which names an interface on one machine rather than a source. Empty allows all. | - |
| `guestPort` | `number` | The in-guest port to expose (1-65535). | `Omit.guestPort` |
| `protocol` | [`IngressProtocol`](#ingressprotocol) | `http` or `tcp`. | - |
***
### IngressProtocol
> **IngressProtocol** = `"tcp"` | `"http"`
The protocol you request when exposing a port.
***
### IngressScheme
> **IngressScheme** = `"path"` | `"subdomain"`
How a listener's URL is addressed under `ingressBase`.
***
### ListHttpPoliciesOptions
Options for [HttpPolicy.list](#list-1).
#### Extends
* [`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | ------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `limit?` | `number` | Cap the total number of policies returned. | - |
| `search?` | `string` | Filter by id or name, case-insensitively. | - |
***
### ListSailboxesOptions
Options for [Sailbox.list](#list-2): the server-side filters, a total-cap
`limit`, and an optional `client`.
#### Extends
* `Omit`\<[`ListSailboxesQuery`](#listsailboxesquery), `"limit"` | `"offset"`>.[`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `appId?` | `string` | Filter to one app by its id. | `Omit.appId` |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `limit?` | `number` | Cap on the total number of Sailboxes returned, bounding the fetch for large orgs; omit to fetch every match. | - |
| `order?` | [`SailboxListOrder`](#sailboxlistorder) | Result ordering; `"newest_active"` (most recently active first) when omitted; `"newest_created"` lists the newest-created first. | [`ListSailboxesPageOptions`](#listsailboxespageoptions).[`order`](#order-1) |
| `search?` | `string` | Substring filter on the Sailbox name. | `Omit.search` |
| `status?` | [`SailboxStatusFilter`](#sailboxstatusfilter) | Filter by lifecycle status. | [`ListSailboxesPageOptions`](#listsailboxespageoptions).[`status`](#status-9) |
***
### ListSailboxesPageOptions
Options for [Sailbox.listPage](#listpage): the same filters as
[ListSailboxesOptions](#listsailboxesoptions), plus `limit`/`offset` page selection and an
optional `client`.
#### Extends
* [`ListSailboxesQuery`](#listsailboxesquery).[`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `appId?` | `string` | Filter to one app by its id. | [`ListSailboxesQuery`](#listsailboxesquery).[`appId`](#appid-5) |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `limit?` | `number` | Page size. | [`ListSailboxesQuery`](#listsailboxesquery).[`limit`](#limit-4) |
| `offset?` | `number` | Page offset. | [`ListSailboxesQuery`](#listsailboxesquery).[`offset`](#offset-2) |
| `order?` | [`SailboxListOrder`](#sailboxlistorder) | Result ordering; `"newest_active"` (most recently active first) when omitted; `"newest_created"` lists the newest-created first. | [`ListSailboxesQuery`](#listsailboxesquery).[`order`](#order-2) |
| `search?` | `string` | Substring filter on the Sailbox name. | [`ListSailboxesQuery`](#listsailboxesquery).[`search`](#search-3) |
| `status?` | [`SailboxStatusFilter`](#sailboxstatusfilter) | Filter by lifecycle status. | [`ListSailboxesQuery`](#listsailboxesquery).[`status`](#status-10) |
***
### ListSailboxesQuery
Filters for listing Sailboxes.
#### Extends
* `Omit`\<`native.ListSailboxesQuery`, `"status"` | `"order"`>
#### Extended by
* [`ListSailboxesPageOptions`](#listsailboxespageoptions)
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `appId?` | `string` | Filter to one app by its id. | `Omit.appId` |
| `limit?` | `number` | Page size. | `Omit.limit` |
| `offset?` | `number` | Page offset. | `Omit.offset` |
| `order?` | [`SailboxListOrder`](#sailboxlistorder) | Result ordering; `"newest_active"` (most recently active first) when omitted; `"newest_created"` lists the newest-created first. | - |
| `search?` | `string` | Substring filter on the Sailbox name. | `Omit.search` |
| `status?` | [`SailboxStatusFilter`](#sailboxstatusfilter) | Filter by lifecycle status. | - |
***
### ListVolumesOptions
Options for [Volume.list](#list-4).
#### Extends
* [`ClientOptions`](#clientoptions)
#### Properties
| Property | Type | Description | Inherited from |
| -------------- | ------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | [`ClientOptions`](#clientoptions).[`client`](#client-2) |
| `maxObjects?` | `number` | Maximum number of volumes to return. | - |
***
### Listener
An exposed guest port and how to reach it.
#### Properties
| Property | Type | Description |
| -------------- | ----------------------------------------------- | ------------------------------------------------------------------ |
| `endpoint?` | [`ListenerEndpoint`](#listenerendpoint-1) | How to reach the port; `undefined` until the listener is routable. |
| `guestPort` | `number` | The in-guest port traffic is forwarded to. |
| `protocol` | [`Protocol`](#protocol-3) | Wire protocol exposed. |
| `routeStatus` | [`ListenerRouteStatus`](#listenerroutestatus-1) | Status of the listener's ingress route. |
***
### ListenerEndpoint
> **ListenerEndpoint** = [`HttpEndpoint`](#httpendpoint) | [`TcpEndpoint`](#tcpendpoint)
How to reach an exposed listener; discriminate on `kind`.
***
### ListenerRouteStatus
> **ListenerRouteStatus** = `"unknown"` | `"pending"` | `"active"` | `"restoring"` | `"unavailable"` | `string` & `object`
Status of a listener's ingress route (open: tolerates unknown values).
***
### LocalDirInput
A local directory tree to bake into the image (walked, hashed, and
uploaded at resolve; symlinks skipped, file modes preserved).
#### Properties
| Property | Type | Description |
| -------------- | ----------- | ------------------------------------------------------------------ |
| `ignore?` | `string`\[] | Gitignore-style patterns to skip. |
| `ignoreFile?` | `string` | A gitignore-style file whose patterns to skip (e.g. `.gitignore`). |
| `localPath` | `string` | Path on this machine. |
| `remotePath` | `string` | Absolute POSIX path of the directory root inside the image. |
***
### LocalFileInput
One local file to bake into the image (hashed and uploaded at resolve).
#### Properties
| Property | Type | Description |
| ------------- | -------- | --------------------------------------------------------------------------------- |
| `localPath` | `string` | Path on this machine. |
| `mode?` | `number` | Permission bits (low 9); omitted uses the builder default (0644). |
| `remotePath` | `string` | Absolute POSIX path inside the image; a trailing `/` appends the source basename. |
***
### NetworkAllowlist
Restrict the destinations a Sailbox can reach, chosen when it is created
and fixed for its whole life.
Each entry is a hostname, a `*.` wildcard hostname (one extra name part),
an IPv4 address, or an IPv4 range in CIDR form such as `203.0.113.0/24`.
Give at least one entry and at most 128; a list that breaks the entry rules
is rejected before the Sailbox is created. Only connections the Sailbox
opens are limited, so `ingressPorts` and SSH still work. The
[network policy guide](https://docs.sailresearch.com/sailboxes-network-policy)
has the entry rules and what each entry allows.
#### Properties
| Property | Modifier | Type | Description |
| --------------- | ---------- | -------------------- | ----------------------------------------------------- |
| `allowedHosts` | `readonly` | readonly `string`\[] | The destinations the Sailbox may reach; at least one. |
| `mode` | `readonly` | `"allowlist"` | Always `"allowlist"`. |
***
### NetworkPolicy
> **NetworkPolicy** = `"public"` | `"no_network"` | [`NetworkAllowlist`](#networkallowlist)
How a Sailbox may reach the network, chosen at creation and fixed for its
life. `"public"` leaves network access open (the default); `"no_network"`
cuts the Sailbox off from other hosts and the internet, so it cannot make
outbound connections or expose inbound services and name resolution does not
work. Running commands is unaffected (`exec` and the shell reach the Sailbox
over a Sail-internal path, not its network), and mounted volumes and other
platform features it was created with keep working. A [NetworkAllowlist](#networkallowlist)
restricts outbound access to a list of destinations instead.
***
### NetworkPolicyInfo
A Sailbox's network policy as reported by [Sailbox.get](#get-1)/[Sailbox.list](#list-2). `mode` is the raw wire mode (`"no_network"` or `"allowlist"`);
it is not narrowed to the [NetworkPolicy](#networkpolicy-4) create union, so a mode a
newer backend adds is reported faithfully. `allowedHosts` carries the
destinations in allowlist mode. Absent on a snapshot means public.
The fields are `readonly`: the object a Sailbox returns is frozen, so this
type matches the runtime and a caller cannot rewrite an audited policy.
#### Properties
| Property | Modifier | Type | Description |
| --------------- | ---------- | -------------------- | ---------------------------------------------------------------- |
| `allowedHosts` | `readonly` | readonly `string`\[] | Allowlist destinations when the mode uses them; empty otherwise. |
| `mode` | `readonly` | `string` | The policy mode, for example `"no_network"`. |
***
### NeverSleep
Stop Sail sleeping a Sailbox on its own. `minSecondsBeforeSleep` belongs to
[AutomaticSleep](#automaticsleep), so it cannot be combined with this.
#### Properties
| Property | Modifier | Type | Description |
| ------------------------- | ---------- | ----------- | ------------------------------------------- |
| `automatic` | `readonly` | `false` | Stop Sail sleeping this Sailbox on its own. |
| `minSecondsBeforeSleep?` | `readonly` | `undefined` | - |
***
### OutputMode
> **OutputMode** = `"auto"` | `"pipe"` | `"tail"`
What happens when a stream's output buffer fills. Each stream has its own
buffer, 1 MiB by default (`outputBufferBytes` in [ExecOptions](#execoptions)).
Sending `cancel()`, and the exec timeout, end every pause: from then on
each stream keeps only its most recent bytes, so a consumer more than a
buffer behind skips. A command that ignores the cancel signal keeps running
that way; `cancel({ force: true })` stops it. The command keeps its
original timeout. If this handle attaches to a command launched earlier
under the same `idempotencyKey`, this handle's pause deadline starts when
the attachment succeeds, so it can release the pauses one full timeout
after that; `cancel()` and `close()` release them at once.
* `"auto"`, the default: a stream you are consuming pauses the command when
its buffer fills and resumes as you read, like a pipe; a stream you are
not consuming never pauses the command and keeps only its most recent
bytes.
* `"pipe"`: both streams pause the command when their buffer fills, until
you consume them, so nothing is lost while you are late to start.
Consume both streams, or the command stays paused on the one you ignore.
Once a stream is released, it goes back to keeping only its most recent
bytes. Not available with `pty`.
* `"tail"`: the command never pauses for you. Each stream keeps only its
most recent bytes, even while you are consuming it, so a slow consumer
skips output without notice; `stdoutTruncated` and `stderrTruncated`
say only that the result holds less than the command wrote. A pty
command always behaves this way.
***
### PackageInstall
A set of packages to install (apt or pip).
#### Properties
| Property | Type | Description |
| ------------ | ----------- | -------------- |
| `packages?` | `string`\[] | Package names. |
***
### Protocol
> **Protocol** = `"tcp"` | `"http"` | `string` & `object`
The protocol reported on a listener (open: tolerates unknown values).
***
### PtyConfig
The pseudo-terminal a `pty` exec runs under. Every field has a default, so
`{}` (or `pty: true`) is a usable terminal.
#### Properties
| Property | Type | Description |
| -------- | -------- | ----------------------------------------------- |
| `cols?` | `number` | Initial width in columns (default 80). |
| `rows?` | `number` | Initial height in rows (default 24). |
| `term?` | `string` | `$TERM` for the pty (default `xterm-256color`). |
***
### ResolvedConfig
The config resolved from the environment and `~/.sail`.
#### Extends
* `Omit`\<`native.ResolvedConfig`, `"ingressScheme"` | `"mode"`>
#### Properties
| Property | Type | Description | Inherited from |
| ---------------- | ----------------------------------- | ----------------------------------------------------- | -------------------- |
| `apiKey?` | `string` | The resolved API key; absent when none is configured. | `Omit.apiKey` |
| `apiUrl` | `string` | Sail API URL. | `Omit.apiUrl` |
| `ingressBase` | `string` | Base host/URL public listeners are addressed under. | `Omit.ingressBase` |
| `ingressScheme` | [`IngressScheme`](#ingressscheme-1) | - | - |
| `sailboxApiUrl` | `string` | Sailbox-API URL. | `Omit.sailboxApiUrl` |
***
### RunCommand
A shell command to run during the build.
#### Properties
| Property | Type | Description |
| ----------- | -------- | ----------------------------------------- |
| `command?` | `string` | The command, run via the builder's shell. |
***
### RunOptions
Options for [Sailbox.run](#run): the subset of [ExecOptions](#execoptions) that fits
a buffered, run-to-completion command.
#### Extends
* `Pick`\<[`ExecOptions`](#execoptions), `"timeoutSeconds"` | `"cwd"` | `"env"` | `"user"` | `"idempotencyKey"` | `"outputBufferBytes"`>
#### Properties
| Property | Type | Description | Inherited from |
| --------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `check?` | `boolean` | Reject with `CommandFailedError` (carrying the completed result) when the command exits nonzero or times out, instead of resolving. | - |
| `cwd?` | `string` | Working directory to run the command in (shell commands only). Unset starts the command in the image's working directory, or `/` when the image does not set one. | `Pick.cwd` |
| `env?` | `Readonly`\<`Record`\<`string`, `string`>> | Extra environment for the command. Entries override the guest's defaults (including `LANG` and the `IS_SANDBOX=1` sandbox marker) and the image env, but a few reserved variables that identify the Sailbox (such as `SAILBOX_ID`) cannot be overridden. | [`ExecOptions`](#execoptions).[`env`](#env-1) |
| `idempotencyKey?` | `string` | Stable key so a reconnect reattaches to the same command. An exec has one live handle at a time: a second handle started with the same key takes over the stream, and the first stops receiving live output and resolves from a bounded recorded result. A first handle reconnecting after a dropped connection can race a handle that attached meanwhile, and either handle's result may come back incomplete; avoid overlapping same-key handles. The UTF-8 value can be up to 256 KiB. | `Pick.idempotencyKey` |
| `outputBufferBytes?` | `number` | Size of each stream's output buffer in bytes, 1 MiB by default. Must be between 64 KiB and 64 MiB. | `Pick.outputBufferBytes` |
| `signal?` | `AbortSignal` | Aborting rejects with the signal's reason and force-cancels the remote command (SIGKILL, like [ExecProcess.cancel](#cancel) with `force`), briefly retrying transient failures. Best effort: the kill is sent once the submission settles (an abort mid-submission cancels the command as soon as its launch is confirmed), and a kill that still fails leaves the command running. | - |
| `timeoutSeconds?` | `number` | Wall-clock limit in seconds before the server kills the command. | `Pick.timeoutSeconds` |
| `user?` | `string` | Run the command as this guest user: a user name or numeric uid, optionally with a group appended after a colon (`"alice"`, `"1000"`, `"alice:staff"`, the Docker `USER` syntax). A named user must exist in the Sailbox's `/etc/passwd`; a numeric uid need not. `HOME` (and `USER`/`LOGNAME` when a name resolves) default to the resolved account, with `env` entries still winning. When unset, commands run as the image's `USER` if the image sets one, root otherwise; pass `"0:0"` to force root (`"root"` is a user name like any other, resolved through the Sailbox's `/etc/passwd`). Sailboxes created before user support shipped must call `upgrade()` once first; until then such execs fail rather than run as root. The exact spelling `"0:0"` needs no upgrade. | `Pick.user` |
***
### SailboxCheckpoint
A durable checkpoint handle.
#### Extends
* `Omit`\<`native.SailboxCheckpoint`, `"status"` | `"expiresAt"`>
#### Properties
| Property | Type | Description | Inherited from |
| ----------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `checkpointGeneration` | `number` | Checkpoint generation captured by this checkpoint. | `Omit.checkpointGeneration` |
| `checkpointId` | `string` | The checkpoint id. | `Omit.checkpointId` |
| `expiresAt?` | `Date` | When the checkpoint expires: seven days out unless a TTL asked for a different window. Starting a Sailbox from it after that fails. | - |
| `sailboxId` | `string` | The Sailbox the checkpoint was taken from. | `Omit.sailboxId` |
| `status` | [`SailboxStatus`](#sailboxstatus-1) | - | - |
***
### SailboxDeprecation
> **SailboxDeprecation** = `native.SailboxDeprecation`
Actionable notice that a Sailbox's runtime should be upgraded: a `deadline`
date and a `message` with upgrade instructions.
***
### SailboxHandle
Returned by create / resume / fromCheckpoint: the Sailbox's identity and
lifecycle status.
#### Properties
| Property | Type | Description |
| ------------ | ----------------------------------- | ----------------------------------------------------- |
| `name` | `string` | The caller-supplied Sailbox name. |
| `sailboxId` | `string` | The Sailbox's stable identifier. |
| `status` | [`SailboxStatus`](#sailboxstatus-1) | Lifecycle status at the time the operation completed. |
***
### SailboxInfo
A read snapshot of a Sailbox (get / list). Timestamps are RFC 3339 strings.
#### Extends
* `Omit`\<`native.SailboxInfo`, `"status"` | `"autoSleep"` | `"networkPolicy"`>
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ----------------------- | ---------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `appId` | `public` | `string` | Identifier of the owning app. | `Omit.appId` |
| `appName` | `public` | `string` | Name of the owning app. | `Omit.appName` |
| `architecture` | `public` | `string` | CPU architecture (for example `arm64`). | `Omit.architecture` |
| `autoSleep?` | `public` | [`AutoSleep`](#autosleep-4) | When Sail may sleep this Sailbox on its own. | - |
| `checkpointGeneration` | `public` | `number` | Monotonic checkpoint generation counter. | `Omit.checkpointGeneration` |
| `cpuRequestedVcpu` | `public` | `number` | Requested CPU, in vCPUs. | `Omit.cpuRequestedVcpu` |
| `cpuUsedVcpu` | `public` | `number` | Current CPU usage, in vCPUs. | `Omit.cpuUsedVcpu` |
| `createdAt` | `public` | `string` | When the Sailbox was created (RFC 3339). | `Omit.createdAt` |
| `createdByUserId?` | `public` | `string` | The user whose credential created this Sailbox (for a restore, the user who ran it). Absent for service-key creates. | `Omit.createdByUserId` |
| `deprecation?` | `public` | `SailboxDeprecation` | Actionable runtime deprecation notice, when an upgrade is needed. | `Omit.deprecation` |
| `diskRequestedBytes` | `public` | `number` | Requested disk, in bytes. | `Omit.diskRequestedBytes` |
| `diskUsedBytes` | `public` | `number` | Current disk usage, in bytes. | `Omit.diskUsedBytes` |
| `errorMessage?` | `public` | `string` | Human-readable error detail when the Sailbox is in an error state. | `Omit.errorMessage` |
| `guestSchemaVersion?` | `public` | `number` | The Sailbox runtime schema version the Sailbox last booted with. | `Omit.guestSchemaVersion` |
| `imageId` | `public` | `string` | Identifier of the image the Sailbox was created from. | `Omit.imageId` |
| `lastCheckpointedAt?` | `public` | `string` | When the most recent checkpoint was taken, if any (RFC 3339). | `Omit.lastCheckpointedAt` |
| `memoryMib` | `public` | `number` | Configured memory, in MiB. | `Omit.memoryMib` |
| `memoryRequestedBytes` | `public` | `number` | Requested memory, in bytes. | `Omit.memoryRequestedBytes` |
| `memoryUsedBytes` | `public` | `number` | Current memory usage, in bytes. | `Omit.memoryUsedBytes` |
| `name` | `public` | `string` | The Sailbox name. | `Omit.name` |
| `networkPolicy?` | `readonly` | [`NetworkPolicyInfo`](#networkpolicyinfo) | The Sailbox's network policy; absent means public. Frozen when read. | - |
| `sailboxId` | `public` | `string` | The Sailbox id. | `Omit.sailboxId` |
| `startedAt?` | `public` | `string` | When the Sailbox first started running, if it ever has (RFC 3339). A resume does not rewrite it. | `Omit.startedAt` |
| `stateDiskSizeGib` | `public` | `number` | Configured state-disk size, in GiB. | `Omit.stateDiskSizeGib` |
| `status` | `public` | [`SailboxStatus`](#sailboxstatus-1) | - | - |
| `updatedAt` | `public` | `string` | When the Sailbox was last updated (RFC 3339). | `Omit.updatedAt` |
| `vcpuCount` | `public` | `number` | Configured number of vCPUs. | `Omit.vcpuCount` |
| `visibility?` | `public` | `string` | `"private"` when access is restricted to the creator; absent/`"org"` is the default org-wide access. | `Omit.visibility` |
| `volumeMounts` | `public` | `SailboxVolumeMount`\[] | Volumes attached to this Sailbox and the paths they are mounted at. Empty when the Sailbox has none. | `Omit.volumeMounts` |
***
### SailboxInfoPage
One page of list results plus the pagination envelope.
#### Extends
* `Omit`\<`native.SailboxInfoPage`, `"items"`>
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | -------------------------------- | ------------------------------------------ | -------------- |
| `hasMore` | `boolean` | Whether more results exist past this page. | `Omit.hasMore` |
| `items` | [`SailboxInfo`](#sailboxinfo)\[] | - | - |
| `limit` | `number` | The page size that was applied. | `Omit.limit` |
| `offset` | `number` | The offset that was applied. | `Omit.offset` |
| `total` | `number` | Total matching Sailboxes across all pages. | `Omit.total` |
***
### SailboxListOrder
> **SailboxListOrder** = `"newest_active"` | `"newest_created"`
Result ordering for a Sailbox list: most recently active first, or newest
created first.
***
### SailboxPage
One page of [Sailbox](#sailbox) instances plus the pagination envelope.
#### Extends
* `Omit`\<[`SailboxInfoPage`](#sailboxinfopage), `"items"`>
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | ------------------------ | ------------------------------------------ | -------------- |
| `hasMore` | `boolean` | Whether more results exist past this page. | `Omit.hasMore` |
| `items` | [`Sailbox`](#sailbox)\[] | - | - |
| `limit` | `number` | The page size that was applied. | `Omit.limit` |
| `offset` | `number` | The offset that was applied. | `Omit.offset` |
| `total` | `number` | Total matching Sailboxes across all pages. | `Omit.total` |
***
### SailboxSize
> **SailboxSize** = `"s"` | `"m"` | `"l"`
Named resource size; each sets the vCPU count plus default memory/disk.
***
### SailboxStatus
> **SailboxStatus** = `"running"` | `"paused"` | `"sleeping"` | `"failed"` | `"terminated"` | `string` & `object`
Lifecycle status of a Sailbox. Open: tolerates values added server-side.
***
### SailboxStatusFilter
> **SailboxStatusFilter** = `"running"` | `"paused"` | `"sleeping"` | `"failed"` | `"terminated"`
The closed set of statuses accepted as a list filter.
***
### SecretInfo
A secret's name and timestamps. Sail never returns the stored value, so no
value field exists. Timestamps are RFC 3339 strings.
#### Properties
| Property | Type | Description |
| ------------ | -------- | --------------------------------------------------- |
| `createdAt` | `string` | When the secret was first set (RFC 3339). |
| `name` | `string` | The secret's name, unique within your organization. |
| `updatedAt` | `string` | When the secret's value last changed (RFC 3339). |
***
### ShellOptions
Options for [Sailbox.shell](#shell-1).
#### Properties
| Property | Type | Description | | |
| ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cwd?` | `string` | Working directory for the session. Unset starts it in the image's working directory, or `/` when the image does not set one. | | |
| `env?` | `Readonly`\<`Record`\<`string`, `string`>> | Extra environment for the session, with the precedence and reserved names of [ExecOptions.env](#env-1). | | |
| `noForward?` | `boolean` | While attached, the Sailbox's browser opens and localhost servers reach your machine, files dragged onto the terminal upload and paste as guest paths, and Ctrl+V forwards your clipboard. On devbox images the clipboard is two-way (pastes land on the Sailbox's clipboard, and what you copy inside the Sailbox comes back); other images upload a pasted image as a file and paste its path. Set `true` to turn all of it off, for example for an untrusted or automated session. | | |
| `shell?` | `string` | Login shell to run when no command is given (default: the guest's `$SHELL`, else `/bin/bash`). Ignored when a command is given. | | |
| `term?` | `string` | `$TERM` for the remote pty (default: the local `$TERM`). | | |
| `timeoutSeconds?` | `number` | Wall-clock limit for the session in seconds; omit for no limit. | | |
| `user?` | `string` | Run the session as this user (Docker's `USER` syntax: \`name | uid\[:group | gid]`). Unset runs as the image's `USER`when the image sets one, root otherwise: the same identity [Sailbox.exec](#exec-1) uses.`"0:0"`is always root. A`user`other than`"0:0"\` requires a Sailbox whose guest honors requested users; on older Sailboxes the session fails until [Sailbox.upgrade](#upgrade) is called. |
***
### SshEndpoint
The public TCP endpoint a Sailbox's SSH listener is reachable at.
#### Properties
| Property | Type | Description |
| -------- | -------- | ----------------- |
| `host` | `string` | Hostname to dial. |
| `port` | `number` | Port to dial. |
***
### TcpEndpoint
The address to dial for a `tcp` listener.
#### Properties
| Property | Type | Description |
| -------- | -------- | ----------------- |
| `host` | `string` | Hostname to dial. |
| `kind` | `"tcp"` | - |
| `port` | `number` | Port to dial. |
***
### UpgradeResult
The outcome of a Sailbox runtime upgrade.
#### Extends
* `Omit`\<`native.UpgradeResult`, `"status"`>
#### Properties
| Property | Type | Description | Inherited from |
| ---------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `applied` | `boolean` | True when no upgrade is left to apply, either because the Sailbox took one just now or because it was already current. False when the upgrade is recorded and takes effect the next time the Sailbox wakes. | `Omit.applied` |
| `status` | [`SailboxStatus`](#sailboxstatus-1) | - | - |
***
### VolumeInfo
A managed NFS volume. Timestamps are RFC 3339 strings.
#### Properties
| Property | Type | Description |
| ------------- | -------- | ----------------------------------------- |
| `backend` | `string` | Storage backend serving the volume. |
| `createdAt?` | `string` | Creation time (RFC 3339), if reported. |
| `name` | `string` | The volume name. |
| `status` | `string` | Lifecycle status. |
| `updatedAt?` | `string` | Last-update time (RFC 3339), if reported. |
| `volumeId` | `string` | The volume id. |
***
### VolumeMountInput
An NFS volume to mount at create time.
#### Properties
| Property | Type | Description |
| ------------ | -------- | ----------------------------------------------- |
| `mountPath` | `string` | Absolute guest path to mount at. |
| `volumeId` | `string` | The volume id (from `getVolume`/`listVolumes`). |
***
### WaitForListenerOptions
Options for [Sailbox.waitForListener](#waitforlistener-1).
#### Properties
| Property | Type | Description |
| ------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `signal?` | `AbortSignal` | Aborting stops the wait and rejects with the signal's reason. An abandoned in-flight probe winds down on its own (by `timeoutSeconds` at the latest); it does not touch the listener. Because the wind-down relies on the timeout, `signal` requires a finite `timeoutSeconds`. |
| `timeoutSeconds?` | `number` | Give up waiting after this many seconds (default 60; `Infinity` waits indefinitely). |
***
### WriteOptions
Options for uploading a file.
#### Properties
| Property | Type | Description | | |
| ----------------- | --------- | ---------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createParents?` | `boolean` | Create missing parent directories. | | |
| `mode?` | `number` | Unix mode bits for the written file (default `0o644`). | | |
| `user?` | `string` | Owner for the written file and any parent directories the write creates, in Docker's `USER` syntax: \`name | uid\[:group | gid]`. The write itself always runs as root, like `COPY --chown`, so it succeeds even where that owner could not write. Unset follows the image's `USER`when the image sets one, root otherwise: the same identity commands run as, so an uploaded file is usable by the code in the Sailbox. Pass`"0:0"`to force root ownership. Requires a Sailbox whose guest honors requested users; on older Sailboxes the write fails until [Sailbox.upgrade](#upgrade) is called, except with the exact spelling`"0:0"\`, which needs none. |
## Errors
Errors thrown by this SDK surface. All of them extend [SailError](#sailerror), so an `instanceof SailError` check matches everything below.
### SailError
Base class for every error surfaced by the SDK.
#### Extends
* `Error`
#### Extended by
* [`InvalidArgumentError`](#invalidargumenterror)
* [`InternalError`](#internalerror)
* [`NotFoundError`](#notfounderror)
* [`PermissionDeniedError`](#permissiondeniederror)
* [`FileNotFoundError`](#filenotfounderror)
* [`BrokenPipeError`](#brokenpipeerror)
* [`TimeoutError`](#timeouterror)
* [`TransportError`](#transporterror)
* [`ApiError`](#apierror)
* [`SailboxCreationError`](#sailboxcreationerror)
* [`ImageBuildError`](#imagebuilderror)
* [`SailboxExecutionError`](#sailboxexecutionerror)
#### Constructors
##### Constructor
> **new SailError**(`message`, `code?`, `details?`): [`SailError`](#sailerror)
###### Parameters
| Parameter | Type | Default value |
| --------- | ------------------ | ------------- |
| `message` | `string` | `undefined` |
| `code` | `string` | `"SailError"` |
| `details` | `SailErrorDetails` | `{}` |
###### Returns
[`SailError`](#sailerror)
###### Overrides
`Error.constructor`
#### Properties
| Property | Modifier | Type | Description |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. |
***
### ApiError
A non-2xx API response.
#### Extends
* [`SailError`](#sailerror)
#### Extended by
* [`SecretInUseError`](#secretinuseerror)
* [`HttpPolicyInUseError`](#httppolicyinuseerror)
#### Constructors
##### Constructor
> **new ApiError**(`message`, `details?`): [`ApiError`](#apierror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`ApiError`](#apierror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `body?` | `readonly` | `unknown` | Parsed response body from the failed request, when available. | - |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
| `status?` | `readonly` | `number` | HTTP status code returned by the API, when the failure carries one. | - |
***
### BrokenPipeError
A stream (e.g. exec stdin) was closed and can no longer be written.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new BrokenPipeError**(`message`, `details?`): [`BrokenPipeError`](#brokenpipeerror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`BrokenPipeError`](#brokenpipeerror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### CommandFailedError
Thrown by [Sailbox.run](#run) with `check` when the command exits nonzero
or times out. Carries the completed result as [result](#result).
#### Extends
* [`SailboxExecutionError`](#sailboxexecutionerror)
#### Constructors
##### Constructor
> **new CommandFailedError**(`message`, `result`): [`CommandFailedError`](#commandfailederror)
###### Parameters
| Parameter | Type |
| --------- | --------------------------- |
| `message` | `string` |
| `result` | [`ExecResult`](#execresult) |
###### Returns
[`CommandFailedError`](#commandfailederror)
###### Overrides
[`SailboxExecutionError`](#sailboxexecutionerror).[`constructor`](#constructor-12)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-12) |
| `result` | `readonly` | [`ExecResult`](#execresult) | - | - |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailboxExecutionError`](#sailboxexecutionerror).[`retryable`](#retryable-12) |
| `rpcStatus` | `readonly` | `string` | Transport status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-15), which stays the taxonomy discriminator on every SailError. | [`SailboxExecutionError`](#sailboxexecutionerror).[`rpcStatus`](#rpcstatus-2) |
***
### FileNotFoundError
A remote file path does not exist.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new FileNotFoundError**(`message`, `details?`): [`FileNotFoundError`](#filenotfounderror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`FileNotFoundError`](#filenotfounderror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### HttpPolicyInUseError
Deleting an HTTP policy that is still attached to a Sailbox. Clear or
replace it on every Sailbox first, then delete it.
#### Extends
* [`ApiError`](#apierror)
#### Constructors
##### Constructor
> **new HttpPolicyInUseError**(`message`, `details?`): [`HttpPolicyInUseError`](#httppolicyinuseerror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`HttpPolicyInUseError`](#httppolicyinuseerror)
###### Inherited from
[`ApiError`](#apierror).[`constructor`](#constructor)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `body?` | `readonly` | `unknown` | Parsed response body from the failed request, when available. | [`ApiError`](#apierror).[`body`](#body) |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`ApiError`](#apierror).[`code`](#code) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`ApiError`](#apierror).[`retryable`](#retryable) |
| `status?` | `readonly` | `number` | HTTP status code returned by the API, when the failure carries one. | [`ApiError`](#apierror).[`status`](#status) |
***
### ImageBuildError
A custom image could not be built or its local content could not be uploaded.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new ImageBuildError**(`message`, `details?`): [`ImageBuildError`](#imagebuilderror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`ImageBuildError`](#imagebuilderror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### InternalError
An unexpected internal SDK failure.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new InternalError**(`message`, `details?`): [`InternalError`](#internalerror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`InternalError`](#internalerror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### InvalidArgumentError
Invalid arguments or configuration (bad request, missing/invalid API key).
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new InvalidArgumentError**(`message`, `details?`): [`InvalidArgumentError`](#invalidargumenterror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`InvalidArgumentError`](#invalidargumenterror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### NotFoundError
The Sailbox, volume, or other resource does not exist (or is another org's).
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new NotFoundError**(`message`, `details?`): [`NotFoundError`](#notfounderror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`NotFoundError`](#notfounderror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### PermissionDeniedError
The credential is not permitted to perform the operation.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new PermissionDeniedError**(`message`, `details?`): [`PermissionDeniedError`](#permissiondeniederror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`PermissionDeniedError`](#permissiondeniederror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### SailboxCreationError
A Sailbox could not be created (provisioning failed).
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new SailboxCreationError**(`message`, `details?`): [`SailboxCreationError`](#sailboxcreationerror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`SailboxCreationError`](#sailboxcreationerror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `body?` | `readonly` | `unknown` | Parsed response body from the failed create request, when available. | - |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
| `status?` | `readonly` | `number` | HTTP status code returned by the create request, when the failure carries one. | - |
***
### SailboxExecRequestNotFoundError
The exec request could not be found (for example after the Sailbox
moved machines).
#### Extends
* [`SailboxExecutionError`](#sailboxexecutionerror)
#### Constructors
##### Constructor
> **new SailboxExecRequestNotFoundError**(`message`, `details?`): [`SailboxExecRequestNotFoundError`](#sailboxexecrequestnotfounderror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`SailboxExecRequestNotFoundError`](#sailboxexecrequestnotfounderror)
###### Overrides
[`SailboxExecutionError`](#sailboxexecutionerror).[`constructor`](#constructor-12)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-12) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailboxExecutionError`](#sailboxexecutionerror).[`retryable`](#retryable-12) |
| `rpcStatus` | `readonly` | `string` | Transport status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-15), which stays the taxonomy discriminator on every SailError. | [`SailboxExecutionError`](#sailboxexecutionerror).[`rpcStatus`](#rpcstatus-2) |
***
### SailboxExecutionError
Base class for failures during an exec.
#### Extends
* [`SailError`](#sailerror)
#### Extended by
* [`CommandFailedError`](#commandfailederror)
* [`SailboxTerminatedError`](#sailboxterminatederror)
* [`SailboxExecRequestNotFoundError`](#sailboxexecrequestnotfounderror)
* [`SailboxHostLostError`](#sailboxhostlosterror)
#### Constructors
##### Constructor
> **new SailboxExecutionError**(`message`, `code?`, `details?`): [`SailboxExecutionError`](#sailboxexecutionerror)
###### Parameters
| Parameter | Type | Default value |
| --------- | ------------------ | ------------------------- |
| `message` | `string` | `undefined` |
| `code` | `string` | `"SailboxExecutionError"` |
| `details` | `SailErrorDetails` | `{}` |
###### Returns
[`SailboxExecutionError`](#sailboxexecutionerror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
| `rpcStatus` | `readonly` | `string` | Transport status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-15), which stays the taxonomy discriminator on every SailError. | - |
***
### SailboxHostLostError
The machine hosting the Sailbox was lost while an exec was in flight.
#### Extends
* [`SailboxExecutionError`](#sailboxexecutionerror)
#### Constructors
##### Constructor
> **new SailboxHostLostError**(`message`, `details?`): [`SailboxHostLostError`](#sailboxhostlosterror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`SailboxHostLostError`](#sailboxhostlosterror)
###### Overrides
[`SailboxExecutionError`](#sailboxexecutionerror).[`constructor`](#constructor-12)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-12) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailboxExecutionError`](#sailboxexecutionerror).[`retryable`](#retryable-12) |
| `rpcStatus` | `readonly` | `string` | Transport status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-15), which stays the taxonomy discriminator on every SailError. | [`SailboxExecutionError`](#sailboxexecutionerror).[`rpcStatus`](#rpcstatus-2) |
***
### SailboxTerminatedError
The Sailbox was terminated while an exec was in flight.
#### Extends
* [`SailboxExecutionError`](#sailboxexecutionerror)
#### Constructors
##### Constructor
> **new SailboxTerminatedError**(`message`, `details?`): [`SailboxTerminatedError`](#sailboxterminatederror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`SailboxTerminatedError`](#sailboxterminatederror)
###### Overrides
[`SailboxExecutionError`](#sailboxexecutionerror).[`constructor`](#constructor-12)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-12) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailboxExecutionError`](#sailboxexecutionerror).[`retryable`](#retryable-12) |
| `rpcStatus` | `readonly` | `string` | Transport status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-15), which stays the taxonomy discriminator on every SailError. | [`SailboxExecutionError`](#sailboxexecutionerror).[`rpcStatus`](#rpcstatus-2) |
***
### SecretInUseError
Deleting a secret that HTTP policies still refer to. Delete those
policies first, then delete the secret.
#### Extends
* [`ApiError`](#apierror)
#### Constructors
##### Constructor
> **new SecretInUseError**(`message`, `details?`): [`SecretInUseError`](#secretinuseerror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`SecretInUseError`](#secretinuseerror)
###### Inherited from
[`ApiError`](#apierror).[`constructor`](#constructor)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `body?` | `readonly` | `unknown` | Parsed response body from the failed request, when available. | [`ApiError`](#apierror).[`body`](#body) |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`ApiError`](#apierror).[`code`](#code) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`ApiError`](#apierror).[`retryable`](#retryable) |
| `status?` | `readonly` | `number` | HTTP status code returned by the API, when the failure carries one. | [`ApiError`](#apierror).[`status`](#status) |
***
### TimeoutError
A request exceeded its timeout.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new TimeoutError**(`message`, `details?`): [`TimeoutError`](#timeouterror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`TimeoutError`](#timeouterror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
***
### TransportError
A network/connection transport failure.
#### Extends
* [`SailError`](#sailerror)
#### Constructors
##### Constructor
> **new TransportError**(`message`, `details?`): [`TransportError`](#transporterror)
###### Parameters
| Parameter | Type |
| --------- | ------------------ |
| `message` | `string` |
| `details` | `SailErrorDetails` |
###### Returns
[`TransportError`](#transporterror)
###### Overrides
[`SailError`](#sailerror).[`constructor`](#constructor-15)
#### Properties
| Property | Modifier | Type | Description | Inherited from |
| ------------ | ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-15) |
| `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient exec failure). `false` when the failure is deterministic (bad argument, missing resource, permission). The SDK's built-in retry already covers the transient window for most calls; this classifies what leaks past it. | [`SailError`](#sailerror).[`retryable`](#retryable-15) |
# Sending requests at scale
Source: https://docs.sailresearch.com/requests_at_scale
Best practices for submitting thousands of concurrent requests to the Sail API
When you need to submit thousands (or tens of thousands) of requests to Sail, serial requests become a major bottleneck. Sail offers two ways to handle high-volume workloads: the **Batch API** and the **Responses API**.
## Batch API
The Batch API lets you submit up to 100,000 requests in a single call (max 256 MB per batch).
1. **Submit a batch.** Send all your requests in one `POST /batches` call.
2. **Poll for status.** Check `GET /batches/{batch_id}` until all requests are completed.
3. **Retrieve results.** Fetch individual results via `GET /batches/{batch_id}/{custom_id}`.
To view all your previously submitted batches, use `GET /batches`.
Attach an `Idempotency-Key` header on submission so a client retry after a network blip replays the original batch reservation instead of creating a duplicate. See [Idempotent Requests](/idempotency).
Batch items default to `metadata.completion_window: "balanced"` when the field
is omitted. If you set it explicitly, it must be either `"balanced"` or
`"flex"`. Other values are rejected for batch items. For low-latency
workloads, use the [Responses API](#responses-api-with-background-mode)
instead. See [Completion Windows](/completion-windows) for the full tier
definitions and per-model availability.
### Python example
First, install the `requests` library:
```bash theme={null}
pip install requests
```
```python theme={null}
import time
import requests
BASE_URL = "https://api.sailresearch.com/v1"
API_KEY = "YOUR_KEY_HERE"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
# 1. Submit a batch
batch_requests = [
{
"custom_id": f"request-{i}",
"params": {
"model": "zai-org/GLM-5.3",
"max_output_tokens": 2048,
"input": [{"role": "user", "content": f"What is a fun fact about the number {i}?"}],
"metadata": {"completion_window": "balanced"},
},
}
for i in range(100)
]
resp = requests.post(
f"{BASE_URL}/batches",
headers=HEADERS,
json={
"endpoint": "/v1/responses",
"label": "my-batch-job",
"requests": batch_requests,
},
)
batch = resp.json()
batch_id = batch["id"]
print(f"Created batch {batch_id} with {batch['request_counts']} requests")
# 2. Poll until all requests are completed
while True:
status_resp = requests.get(f"{BASE_URL}/batches/{batch_id}", headers=HEADERS)
status = status_resp.json()
request_status = status["request_status"]
done = sum(1 for r in request_status.values() if r["status"] in ("COMPLETED", "FAILED", "CANCELLED"))
print(f"Progress: {done}/{len(request_status)}")
if done == len(request_status):
break
time.sleep(5)
# 3. Retrieve results
for custom_id, info in request_status.items():
if info["status"] == "COMPLETED":
result = requests.get(
f"{BASE_URL}/batches/{batch_id}/{custom_id}", headers=HEADERS
).json()
output_text = "".join(
c["text"]
for item in result.get("output", [])
for c in item.get("content", [])
if c.get("type") == "output_text"
)
print(f"{custom_id}: {output_text[:100]}...")
```
## Responses API with background mode
You can also submit requests individually using the Responses API with `background=True`. For large-volume workloads (1,000+ requests), we recommend:
1. **Use `AsyncOpenAI` with `DefaultAioHttpClient()`.** The OpenAI SDK's built-in aiohttp client is more efficient than the default `httpx` backend for high-concurrency workloads.
2. **Gate concurrency with an `asyncio.Semaphore`.** This gives you fine-grained control over how many simultaneous connections are opened (e.g. 200), preventing connection exhaustion.
3. **Submit all requests concurrently with `background=True`, then poll.** Fire off all submissions in parallel (gated by the semaphore), collect the response IDs, and poll for completions in a separate loop.
4. **Send a per-request `Idempotency-Key`** so a retry after a transient failure replays the reservation instead of duplicating inference work. See [Idempotent Requests](/idempotency).
### Python example
First, install tqdm and the OpenAI SDK with the aiohttp extra:
```bash theme={null}
pip install tqdm
pip install 'openai[aiohttp]'
```
```python theme={null}
import argparse
import asyncio
from tqdm import tqdm
from openai import DefaultAioHttpClient
from openai import AsyncOpenAI
async def main(
num_requests: int,
input_tokens: int,
max_output_tokens: int,
model: str,
):
base_url = "https://api.sailresearch.com/v1"
api_key = "YOUR_KEY_HERE"
async with AsyncOpenAI(
base_url=base_url,
api_key=api_key,
http_client=DefaultAioHttpClient(),
) as client:
# List supported models
models = await client.models.list()
supported_models = [m.id for m in models.data]
print("Supported Models:")
print(supported_models)
# Submit requests concurrently
sem = asyncio.Semaphore(200)
pbar_submit = tqdm(total=num_requests, desc="Submitting requests")
async def submit(i):
filler = ""
if input_tokens > 0:
filler = " " + ("word " * input_tokens)
content = f"TASK {i}: What is a fun fact about the number {i}? Then, find the word at index {i} in the following sequence of words: {filler}"
async with sem:
response = await client.responses.create(
model=model,
input=[{"role": "user", "content": content}],
max_output_tokens=max_output_tokens,
background=True,
)
pbar_submit.update(1)
return response.id
response_ids = await asyncio.gather(*[submit(i) for i in range(num_requests)])
pbar_submit.close()
response_ids = list(response_ids)
print(f"Created {len(response_ids)} response IDs")
# Poll for completions
usable = {}
poll_sem = asyncio.Semaphore(200)
pbar = tqdm(total=len(response_ids), desc="Polling responses")
async def poll(response_id):
for _ in range(3600): # 1 hour timeout
async with poll_sem:
response = await client.responses.retrieve(response_id)
if response.status == "completed":
usable[response_id] = response
pbar.update(1)
return
if response.status == "incomplete":
reason = getattr(response.incomplete_details, "reason", None)
if reason == "max_output_tokens":
usable[response_id] = response
else:
print(f"{response_id} is incomplete because of {reason or 'an unknown reason'}")
pbar.update(1)
return
if response.status in {"failed", "cancelled"}:
print(f"{response_id} ended with status {response.status}")
pbar.update(1)
return
await asyncio.sleep(1)
print(f"Timed out waiting for {response_id}")
await asyncio.gather(*[poll(rid) for rid in response_ids])
pbar.close()
for response_id in response_ids:
resp = usable.get(response_id)
if resp is None:
continue
output_text = "".join(
c.text
for item in resp.output
for c in getattr(item, "content", []) or []
if getattr(c, "type", None) == "output_text"
)
print(f"\n\n{response_id} {resp.status} with output:\n{output_text}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--num-requests", type=int, default=20000)
parser.add_argument("--input-tokens", type=int, default=50)
parser.add_argument("--max-output-tokens", type=int, default=4000)
parser.add_argument("--model", type=str, default="zai-org/GLM-5.3")
args = parser.parse_args()
asyncio.run(
main(
num_requests=args.num_requests,
input_tokens=args.input_tokens,
max_output_tokens=args.max_output_tokens,
model=args.model,
)
)
```
The semaphore limit of 200 is a good starting point. Lower it if you run into connection errors or timeouts; raise it if you have headroom and want faster submission throughput.
# Sailboxes
Source: https://docs.sailresearch.com/sailbox-sdk
The Sailbox class: create, operate, and tear down Sailboxes
A `Sailbox` is a Linux VM on the Sail platform: a full cloud environment
designed for long-horizon agents. Create one, then run commands, transfer
files, expose ports, and checkpoint or pause it. For a guided walkthrough, see
the [Sailboxes guide](/sailboxes); this page is the API reference. In a language
without a Sail SDK, reach for the [HTTP API](/sailboxes-http-api).
```python Python theme={null}
import sail
sb = sail.Sailbox.create(
app=sail.App.find(name="example-app", mint_if_missing=True),
name="sandbox-1",
)
print(sb.sailbox_id, sb.status)
```
```typescript TypeScript theme={null}
import { App, Sailbox } from "@sailresearch/sdk";
const app = await App.find("example-app", { mintIfMissing: true });
const sb = await Sailbox.create({ app, name: "sandbox-1" });
console.log(sb.sailboxId, sb.status);
```
```rust Rust theme={null}
use sail::{Client, CreateSailboxRequest};
let client = Client::from_env()?;
let app = client.find_app("example-app", /* mint_if_missing */ true).await?;
let sb = client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id,
name: "sandbox-1".into(),
..Default::default()
},
/* timeout */ None,
)
.await?;
println!("{}", sb.sailbox_id());
```
## Sync and async
* **Python** methods are synchronous, and every method that does I/O has an
async twin under `.aio` (`await sb.exec.aio(...)`). Handles returned by
async calls are already async: `await sb.exec.aio(...)` returns a process
whose own methods (`wait`, `cancel`, `resize`) are awaited directly, with
no `.aio`. The one exception to the rule is [`shell`](#shell), which is
sync-only since it drives your local terminal.
* **TypeScript** is async-only: every operation returns a `Promise`.
* **Rust** is async-only, on a Tokio runtime. Synchronous code can drive any
call with `sail::block_on`.
```python Python theme={null}
import asyncio
import sail
async def main():
app = await sail.App.find.aio(name="example-app", mint_if_missing=True)
sb = await sail.Sailbox.create.aio(app=app, name="sandbox-1")
proc = await sb.exec.aio("echo hi")
result = await proc.wait()
print(result.stdout)
await sb.terminate.aio()
asyncio.run(main())
```
```typescript TypeScript theme={null}
const sb = await Sailbox.create({ app, name: "sandbox-1" });
const proc = await sb.exec("echo hi");
const result = await proc.wait();
console.log(result.stdout);
await sb.terminate();
```
```rust Rust theme={null}
use sail::ExecOptions;
let sb = client.sailbox("sb_...");
let proc = sb.exec_shell("echo hi", ExecOptions::default()).await?;
let result = proc.wait().await?;
print!("{}", result.stdout);
sb.terminate().await?;
```
## Naming
The three SDKs expose the same operations with each language's conventions:
Python is `snake_case` with keyword arguments, TypeScript is `camelCase` with
options objects and unit-suffixed durations (`timeoutSeconds`), and Rust pairs
a `Sailbox` object with explicit argument structs. Parameter tables on this
page use the Python spelling; each section's TypeScript signature shows the
real names. Every TypeScript static also accepts an explicitly constructed
`client` in its options object (see
[Configuration](/reference/sdk-configuration)); the signatures on this page omit it.
## Attributes
Every `Sailbox` carries its identity and lifecycle state: `sailbox_id`,
`name`, and `status` (`running`, `paused`, `sleeping`, `failed`,
`terminated`). A `Sailbox` returned by [`get`](#sailbox-get) or
[`list`](#sailbox-list) also carries the monitoring snapshot from that fetch:
owning app, image, configured and observed resource usage, timestamps, and its
network policy (see [snapshot fields](#sailboxinfo); absent means public).
Treat `sailbox_id` as the durable handle: store the id, and turn it back into
a usable `Sailbox` any time with [`Sailbox.get`](#sailbox-get) (which fetches
fresh state), or without a network call via `Sailbox.from_id` /
`Sailbox.fromId` / `client.sailbox(id)`. The `get` result reflects the
Sailbox at the time of the call; call it again for fresh state.
***
## Sailbox.create
```python Python theme={null}
@classmethod
def create(
*,
app: App | str,
image: ImageDefinition | None = None,
name: str,
image_build_timeout: int = 1800,
timeout: int = 600,
size: SailboxSize | None = None,
memory_limit_gib: int | None = None,
disk_limit_gib: int | None = None,
ingress_ports: Sequence[int | IngressPort] | None = (),
volumes: Mapping[str, Volume | str] | None = None,
visibility: Literal["org", "private"] = "org",
network_policy: NetworkPolicy | NetworkAllowlist | Literal["public", "no_network"] = NetworkPolicy.PUBLIC,
) -> Sailbox
```
```typescript TypeScript theme={null}
static create(options: {
app: App | string;
name: string;
image?: ImageSpec | Image; // defaults to a plain Debian base
imageBuildTimeoutSeconds?: number; // 1800
timeoutSeconds?: number; // 600 per create attempt; 0 = unbounded
size?: "s" | "m" | "l";
memoryLimitGib?: number;
diskLimitGib?: number;
ingressPorts?: (number | IngressPortInput)[];
volumes?: Record;
visibility?: "org" | "private";
networkPolicy?: NetworkPolicy;
}): Promise
```
```rust Rust theme={null}
pub async fn create_sailbox(
&self, // Client
req: &CreateSailboxRequest,
timeout: Option,
) -> Result
```
Creates a new Sailbox. The SDK builds any custom image first, then blocks
until the VM is running or creation fails.
`size` is a resource ceiling, not a billing reservation; ongoing Sailbox
billing uses your actual CPU, memory, and disk consumption. Each size also has
a one-time creation charge. `memory_limit_gib` and `disk_limit_gib` optionally tune the
size's ceilings, in whole GiB within the size's range; raising a ceiling does
not increase the ongoing rate, and lowering one caps what the Sailbox can use.
See [Sailbox pricing](/sailboxes-pricing).
| Parameter | Default | Description |
| --------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `image` | `None` | A `sail.Image` value or custom [image definition](/sailbox-sdk-images). Defaults to a prebuilt Debian base (no image build). |
| `app` | required | The owning [app](/sailbox-sdk-apps): an `App` from `App.find()`, or its id. |
| `name` | required | Human-readable Sailbox name. |
| `image_build_timeout` | `1800` | Seconds to wait for a custom image build before creating the VM. Must be `> 0`. |
| `timeout` | `600` | Seconds to bound each create attempt while the Sailbox waits for capacity and boots. Pass `0` to wait without a client-side bound. |
| `size` | `None` | Resource size: `"s"`, `"m"` (the platform default), or `"l"`. Each size sets the vCPU count plus default memory and disk; `"s"` gives the fastest cold starts and resumes, and caps what a runaway workload can consume. |
| `memory_limit_gib` | `None` | Memory ceiling in whole GiB, within the size's range: 2-64 for `"s"`, 8-128 for `"m"`, 16-256 for `"l"`. The size's default when omitted. |
| `disk_limit_gib` | `None` | Disk ceiling in whole GiB, within the size's range: 8-128 for `"s"`, 32-512 for `"m"`, 64-1024 for `"l"`. The size's default when omitted. |
| `ingress_ports` | empty | Guest ports to expose. Each entry is a bare `int` (HTTP shorthand) or an [`IngressPort`](#ingressport). |
| `volumes` | `None` | Shared volumes to mount, mapping an absolute guest path to a [`Volume`](#volumes) (or its id). |
| `visibility` | `"org"` | Who may operate the Sailbox: `"org"` (anyone in your org) or `"private"` (only you). See below. |
| `network_policy` | `NetworkPolicy.PUBLIC` | How the Sailbox may reach the network, chosen at creation and fixed for its life: `NetworkPolicy.PUBLIC` (the default), `NetworkPolicy.NO_NETWORK` (TypeScript `"no_network"`) for no network access at all, or a `NetworkAllowlist(...)` to allow only the listed destinations. `NO_NETWORK` cannot be combined with `ingress_ports`; an allowlist can. See [Network policy](#network-policy). |
Volumes are currently in Alpha. To pilot them, reach out in the [Sail
Slack](https://join.slack.com/t/sailresearchcrew/shared_invite/zt-41pdcym9j-UU0Ey~A~r6n2H0DQVQsQHQ).
**Returns** a running `Sailbox`.
**Raises** a creation error when Sail cannot create the Sailbox, a
permission error on 401/403, and an invalid-argument error for an unknown
`size` or invalid ingress ports and
volume mounts.
In Python, pin `image=sail.Image.debian_arm64` or `debian_amd64` when using
`@sail.function`; those images match your local Python version so function
bytecode can deserialize in the guest. See [Images &
Functions](/sailbox-sdk-images).
### SSH
For a quick shell, use [`shell()`](#shell) or `sail box shell`: it streams a
PTY over the same channel as `exec` and uses no ingress port. SSH is opt-in,
for when you want a standard SSH endpoint: a devbox, your own client, `scp`,
or port forwarding.
Enable SSH after create with [`enable_ssh`](#sailbox-enable-ssh), which trusts
your org's SSH certificate authority, starts `sshd`, and exposes guest port `22`
as raw TCP. See the [SSH access guide](/sailboxes-networking#ssh-access) for the
access model, how to connect, and source restrictions.
`visibility` chooses who may operate the Sailbox, fixed for its life. `"org"`,
the default, lets any credential in your org exec, copy files, SSH into, or run
lifecycle operations on it. `"private"` restricts all of that to you: only your
credential can operate the Sailbox (your org can still see it in listings, but
not act on it). It requires an API key minted by your user, not a service key. Org admins can override the restriction for exec, file,
setting a wake time, and the pause, sleep, resume, terminate, and upgrade
operations by setting `SAIL_OWNER_OVERRIDE_REASON` (or the
`X-Sail-Owner-Override-Reason` header on raw HTTP calls); exposing or removing
listeners, checkpoint, and restore stay creator-only. Every override
requires that reason, and your org's audit log records it. SSH has no override:
a private Sailbox's SSH server
accepts only its creator's certificates. From the CLI, pass
`--visibility private` to `sail box create`.
***
## Sailbox.get
```python Python theme={null}
@classmethod
def get(sailbox_id: str) -> Sailbox
```
```typescript TypeScript theme={null}
static get(sailboxId: string): Promise
```
```rust Rust theme={null}
pub fn sailbox(&self, sailbox_id: impl Into) -> Sailbox // Client
```
Fetches a Sailbox by id and returns a fully usable `Sailbox`: run commands,
read and write files, and manage listeners on it directly. `get` never wakes a
paused or sleeping Sailbox. Operations that run inside it (commands, file
reads and writes, network traffic) wake a sleeping Sailbox on demand; a
paused Sailbox rejects them until you call `resume`. The returned object
reflects the Sailbox at the time of the call, so call `get` again for fresh
state. Unknown and wrong-org ids both raise a not-found error, so you cannot
tell an unknown id from one owned by another org.
In Rust, `client.sailbox(id)` binds the id without a network call; fetch
current state with `sb.info()`.
```python Python theme={null}
sb = sail.Sailbox.get("sb_...")
result = sb.exec("echo hello").wait()
```
```typescript TypeScript theme={null}
const sb = await Sailbox.get("sb_...");
const result = await (await sb.exec("echo hello")).wait();
```
```rust Rust theme={null}
use sail::ExecOptions;
let sb = client.sailbox("sb_...");
let result = sb
.exec_shell("echo hello", ExecOptions::default())
.await?
.wait()
.await?;
```
***
## Sailbox.list
```python Python theme={null}
@classmethod
def list(
*,
app_id: str | None = None,
status: str | None = None,
search: str | None = None,
order: Literal["newest_active", "newest_created"] | None = None,
limit: int | None = None,
) -> list[Sailbox]
```
```typescript TypeScript theme={null}
static list(params?: {
appId?: string;
status?: SailboxStatusFilter;
search?: string;
order?: SailboxListOrder;
limit?: number;
}): Promise
```
```rust Rust theme={null}
pub async fn list_sailboxes(
&self, // Client
query: &ListSailboxesQuery,
) -> Result
```
Lists Sailboxes for the current org. Python and TypeScript fetch pages
internally until every match (or `limit` of them) is collected; `limit` caps
the total returned, bounding the fetch for large orgs. (Rust's
`list_sailboxes` returns one page with its envelope.) `app_id` filters by the owning
app id (resolve a name through [`App.find`](/sailbox-sdk-apps) first).
`search` filters by name substring. `order` is `"newest_active"` (most recently
active first, the default) or `"newest_created"` (newest-created first). Use
[`Sailbox.list_page`](#sailbox-list-page) to control paging yourself or to
read the pagination envelope.
***
## Sailbox.list\_page
```python Python theme={null}
@classmethod
def list_page(
*,
app_id: str | None = None,
status: str | None = None,
search: str | None = None,
order: Literal["newest_active", "newest_created"] | None = None,
limit: int = 50,
offset: int = 0,
) -> SailboxPage
```
```typescript TypeScript theme={null}
static listPage(query?: ListSailboxesQuery): Promise
```
```rust Rust theme={null}
pub async fn list_sailboxes(&self, query: &ListSailboxesQuery) -> Result
```
Same call as `Sailbox.list`, but returns a [`SailboxPage`](#sailboxpage) with
the Sailboxes plus the `limit`/`offset`/`total`/`has_more` pagination envelope.
(In Rust, `list_sailboxes` always returns the page.)
***
## exec
```python Python theme={null}
def exec(
command: str | Sequence[str] | SailFunction,
*function_args,
timeout: int | None = None,
background: bool = False,
cwd: str | None = None,
open_stdin: bool = False,
pty: bool | PtyConfig = False,
env: Mapping[str, str] | None = None,
user: str | int | None = None,
idempotency_key: str | None = None,
output_mode: OutputMode | Literal["auto", "pipe", "tail"] = "auto",
output_buffer_bytes: int = 1048576,
kwargs: Mapping | None = None,
) -> ExecProcess | Any
```
```typescript TypeScript theme={null}
exec(command: string | readonly string[], options?: {
timeoutSeconds?: number;
background?: boolean;
cwd?: string;
openStdin?: boolean;
pty?: boolean | PtyConfig;
env?: Record;
user?: string;
idempotencyKey?: string;
outputMode?: "auto" | "pipe" | "tail";
outputBufferBytes?: number;
}): Promise
```
```rust Rust theme={null}
pub async fn exec_shell(&self, command: &str, options: ExecOptions)
-> Result;
pub async fn exec(&self, argv: impl IntoIterator>, options: ExecOptions)
-> Result;
```
Runs a command in the Sailbox and returns a process handle. By default a stream
you are reading pauses the command when you fall behind, so nothing is lost
until a cancel or the exec `timeout` ends the pauses,
and a stream you are not reading keeps only its most recent 1 MiB. To get every
byte, start reading right after `exec` returns. `output_mode` and
`output_buffer_bytes` (`outputMode` and `outputBufferBytes` in TypeScript)
change that; what counts as reading a stream differs by
language. See [Exec process](#sailboxexecprocess).
A string command runs via `/bin/sh -lc`, so shell syntax (pipes, redirects,
`&&`) works. Python and TypeScript also accept an argv list, which execs the
program directly with no shell interpretation, and Rust separates the two as
`exec_shell` (string) and `exec` (argv).
Python's `exec` additionally accepts a `@sail.function`-decorated Python
callable; it then blocks and returns the function's return value directly. For
a function, `output_mode` must stay `auto`, and the function's complete encoded
response (its serialized return value, captured stdout and stderr, and any
error details, as encoded on the wire) must fit `output_buffer_bytes`; a larger
response raises `SailboxFunctionSerializationError`. A second call with the same `idempotency_key` while the function runs takes over its output, and the earlier call may then fail to decode its result. See
[Images & Functions](/sailbox-sdk-images).
| Parameter | Default | Description |
| --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `command` | required | The command to run. |
| `timeout` | `None` | Command runtime budget in seconds. Omit for no SDK-imposed limit. Must be `> 0` when set. |
| `background` | `False` | Launch through a detached shell that returns immediately (string commands only). The command's output is discarded, so live output stays empty and `wait()` only confirms the launcher started it. |
| `cwd` | `None` | Working directory to run the command from (string commands only). |
| `open_stdin` | `False` | Open the command's stdin for writing. When `False`, stdin reads as instant EOF. |
| `pty` | `False` | Run the command under a pseudo-terminal: `True` for the default terminal, or a `PtyConfig(term=..., cols=..., rows=...)` (TS `{ term, cols, rows }`) to set `$TERM` and the initial window, which default to `xterm-256color` and 80x24. `isatty()` is true, control bytes written to stdin become signals, and `resize(cols, rows)` adjusts the window. stdout and stderr merge onto one output stream. Implies `open_stdin`. |
| `env` | `None` | Extra environment variables for the command. Entries override the guest defaults (including `LANG` and the `IS_SANDBOX=1` sandbox marker) and the image environment. A few reserved variables that identify the Sailbox (such as `SAILBOX_ID`) cannot be overridden. For `pty` commands the local `COLORTERM`, `LANG`, `LC_*`, and `TERM_PROGRAM` are forwarded automatically for keys not set here. |
| `user` | `None` | Run the command as this user: a user name or numeric uid, optionally with a group after a colon (`"alice"`, `1000`, `"alice:staff"`, the Docker `USER` syntax). A named user or group must exist in the Sailbox's `/etc/passwd` / `/etc/group`; a numeric uid or gid need not. `HOME` (plus `USER` and `LOGNAME` when a name resolves) default to the account, with `env` entries still winning. The command starts in the image's working directory when this user can enter it, and in the filesystem root otherwise. When `user` is not given, commands run as the image's `USER` if the image sets one, root otherwise; pass `user="0:0"` to force root (`"root"` is a user name like any other, resolved through the Sailbox's `/etc/passwd`). Sailboxes created before user support shipped must call [`upgrade`](#upgrade) once first; the exact spelling `user="0:0"` needs no upgrade. |
| `idempotency_key` | `None` | Defaults to a generated key, so retrying the initial submission won't double-launch the command. An exec has one live handle at a time: a second handle started with the same key takes over the stream, and the first stops receiving live output and resolves from a bounded recorded result. A first handle reconnecting after a dropped connection can race a handle that attached meanwhile, and either handle's result may come back incomplete; avoid overlapping same-key handles. |
| `output_mode` | `"auto"` | What happens when a stream's buffer fills: `auto`, `pipe`, or `tail`. See **Output limits** under [Exec process](#sailboxexecprocess). `pipe` is not available with `pty`. |
| `output_buffer_bytes` | `1048576` | Size of each stream's buffer in bytes, from 64 KiB to 64 MiB. It is what `wait()` returns per stream and how far a reader can fall behind before the command pauses (`auto`, `pipe`) or output is dropped (`tail`). |
```python Python theme={null}
result = sb.exec("echo hi", timeout=5).wait()
print(result.stdout, result.exit_code)
# Stream output as it arrives (a chunk can be a partial line); the loop
# ends when the command finishes:
proc = sb.exec("for i in 1 2 3; do echo $i; sleep 1; done")
for chunk in proc.stdout:
print(chunk, end="")
# Piping stdin:
proc = sb.exec("wc -l", open_stdin=True)
proc.stdin.write("one\ntwo\n")
proc.stdin.close()
print(proc.wait().stdout)
```
```typescript TypeScript theme={null}
const result = await (await sb.exec("echo hi", { timeoutSeconds: 5 })).wait();
console.log(result.stdout, result.exitCode);
// Stream output as it arrives (a chunk can be a partial line); the loop
// ends when the command finishes:
const proc = await sb.exec("for i in 1 2 3; do echo $i; sleep 1; done");
for await (const chunk of proc.stdout) {
process.stdout.write(chunk);
}
// Piping stdin:
const wc = await sb.exec(["wc", "-l"], { openStdin: true });
await wc.writeStdin("one\ntwo\n");
await wc.closeStdin();
console.log((await wc.wait()).stdout);
```
```rust Rust theme={null}
use sail::ExecOptions;
use std::time::Duration;
let result = sb
.exec_shell(
"echo hi",
ExecOptions {
timeout: Some(Duration::from_secs(5)),
..Default::default()
},
)
.await?
.wait()
.await?;
print!("{} {}", result.stdout, result.exit_code);
// Piping stdin:
let wc = sb
.exec(
["wc", "-l"],
ExecOptions {
open_stdin: true,
..Default::default()
},
)
.await?;
wc.write_stdin(b"one\ntwo\n").await?;
wc.close_stdin().await?;
print!("{}", wc.wait().await?.stdout);
```
Multiple execs can run on the same Sailbox concurrently; coordinate access to
shared files and ports in your own commands. Running a command on a sleeping
Sailbox wakes it. A paused Sailbox rejects commands until you call `resume`.
***
## run
```python Python theme={null}
def run(
command: str | Sequence[str],
*,
timeout: int | None = None,
cwd: str | None = None,
env: Mapping[str, str] | None = None,
user: str | int | None = None,
check: bool = False,
idempotency_key: str | None = None,
output_buffer_bytes: int = 1048576,
) -> ExecResult
```
```typescript TypeScript theme={null}
run(command: string | readonly string[], options?: {
timeoutSeconds?: number;
cwd?: string;
env?: Record;
user?: string;
check?: boolean;
idempotencyKey?: string;
outputBufferBytes?: number;
signal?: AbortSignal;
}): Promise
```
```rust Rust theme={null}
pub async fn run(&self, argv: impl IntoIterator>, options: RunOptions)
-> Result
pub async fn run_shell(&self, command: &str, options: RunOptions)
-> Result
```
Runs a command to completion and returns its buffered result: a one-shot
convenience over [`exec`](#exec) followed by `wait()`. A string command runs
via `/bin/sh -lc`; a list is exec'd directly. `cwd` sets the working directory
for string commands only (like `exec`, an argv command with `cwd` raises).
`user` picks the user the command runs as (see [`exec`](#exec)).
By default a nonzero exit code returns normally on the result; check
`exit_code`. With `check` set, a nonzero exit or a timeout raises
`CommandFailedError` carrying the completed result instead. (Rust reports
everything through the returned `ExecResult`.) A command that exceeds
`timeout` is killed and reports `timed_out` on the result; without `check`,
a timeout alone does not raise.
`idempotency_key` makes a retried `run` wait on the original command instead
of launching it again, so a control-loop retry cannot double-execute. While
the first call is still running, a second call with the same key takes over
its output stream, and the earlier call's result may come back truncated.
In TypeScript, aborting `signal` force-cancels the remote command and rejects.
The result's `stdout` and `stderr` hold only the most recent
`output_buffer_bytes` (`outputBufferBytes` in TypeScript) of each stream
(1 MiB by default, up to 64 MiB), with
`stdout_truncated` and `stderr_truncated` set when older output was dropped;
the command never pauses for unread output. To get every byte, use
[`exec`](#exec) and read the
stream (see [Exec process](#sailboxexecprocess)). The interactive and detached
`exec` options (`open_stdin`, `pty`, `background`) and the `pipe` output mode
are not available on `run`.
```python Python theme={null}
result = sb.run("echo hello")
print(result.exit_code, result.stdout)
```
```typescript TypeScript theme={null}
const result = await sb.run("echo hello");
console.log(result.exitCode, result.stdout);
```
```rust Rust theme={null}
use sail::RunOptions;
let result = sb.run_shell("echo hello", RunOptions::default()).await?;
println!("{} {}", result.exit_code, result.stdout);
```
***
## shell
```python Python theme={null}
def shell(
command: str | None = None,
*,
shell: str | None = None,
term: str | None = None,
cwd: str | None = None,
user: str | int | None = None,
timeout: int | None = None,
env: Mapping[str, str] | None = None,
no_forward: bool = False,
) -> int
```
```typescript TypeScript theme={null}
shell(command?: string, options?: {
shell?: string;
term?: string;
cwd?: string;
user?: string;
timeoutSeconds?: number;
env?: Record;
noForward?: boolean;
}): Promise
```
```rust Rust theme={null}
pub async fn shell(
&self,
command: Option<&str>,
options: ShellOptions,
) -> Result
```
Opens an interactive pty session on the Sailbox and bridges it to your local
terminal. With no `command`, runs a login shell; pass `command` to run that
under a pty instead (e.g. a REPL or `vim`). Keystrokes (including Ctrl-C,
Ctrl-Z, and Ctrl-D) reach the remote process, its output renders locally, and
terminal resizes propagate. Blocks until the remote process exits and returns
its exit code. Requires an interactive local terminal (stdin and stdout must
be TTYs) on a Unix machine, so it suits CLIs and dev tools rather than
server-side harnesses.
This is the equivalent of `ssh`-ing into the Sailbox, without running an SSH
server. `shell` overrides the login shell (default `$SHELL`, else
`/bin/bash`); it is ignored when `command` is given. `env` adds environment
variables to the session, with the same precedence and reserved names as for
[`exec`](#exec).
The session runs as the image's `USER` when the image sets one, root
otherwise: the same identity [`exec`](#exec) uses. `user` runs it as someone
else instead (a user name or numeric uid, optionally with a group after a
colon, like `"alice"`, `1000`, `"alice:staff"`); `user="0:0"` is always root.
A `user` other than `"0:0"` requires a Sailbox whose guest honors requested
users; on older Sailboxes the session fails until [`upgrade`](#upgrade) is
called.
While the session is open, browser opens and localhost servers in the Sailbox are
forwarded to your machine. When a program in the Sailbox opens a browser (a login
like `claude login` or `gh auth login`), the page opens in your local browser,
and a login that redirects to a `localhost` callback completes end to end. A
server the Sailbox starts on `localhost` keeps serving inside the Sailbox the whole
time (code and agents there reach it as usual); while the shell is open it is
also mirrored to the same port on your machine, unless that port is already in
use locally. The mirror lasts only for the session. Files dragged onto the terminal upload
into the Sailbox and paste as their guest paths, and Ctrl+V forwards your
clipboard. On devbox images the clipboard is two-way: a pasted image or text
lands on the Sailbox's clipboard, and text copied inside the Sailbox comes back to
yours. Other images upload a pasted image as a file and paste its path. Pass
`no_forward=True` (TS `noForward`) to turn all of it off, for example for an
untrusted or automated session. Plain
`exec` forwards nothing; for the same forwarding on `sail box exec --tty`, see
the [CLI reference](/reference/cli#sail-box-shell).
```python Python theme={null}
sb.shell()
```
```typescript TypeScript theme={null}
await sb.shell();
```
```rust Rust theme={null}
use sail::shell::ShellOptions;
sb.shell(/* command */ None, ShellOptions::default()).await?;
```
From the CLI:
```bash theme={null}
sail box shell
```
***
## The fs namespace
File and directory operations live under the `fs` namespace: `sb.fs` in Python
and TypeScript, `sb.fs()` in Rust. Reads and writes stream bytes to and from
the guest, and Python paths accept `str` or `PurePosixPath`. The directory
helpers `mkdir` (creates missing parents), `remove` (deletes recursively),
and `exists` behave like `mkdir -p`, `rm -rf`, and `test -e`, and
`upload_dir` and `download_dir` transfer whole directories.
Writes give what they create to the image's `USER` by default, or to root
when the image sets none. That is the same identity [`exec`](#exec) runs
commands as, so an uploaded file is usable by the code in the Sailbox. Reads
and the directory helpers act as root by default, so they work on any path.
Each operation except the reads and the directory download takes an
optional `user` in the Docker
`USER` syntax: a user name or numeric uid, optionally with a group after a
colon (`"alice"`, `1000`, `"alice:staff"`). The other directory helpers
then run as that user, with its permissions enforced. The writes give what
they create that owner (like `COPY --chown`) while the write itself always
runs as root, so it works even where the owner cannot write. `"0:0"` is
always root, and the directory upload gives what it creates the same way.
Reads and the download take no `user`: a `user` only decides which paths an
operation may touch and who owns what it creates, a read creates nothing in
the Sailbox, and a download reads any path as root the way the reads do.
A `user` other than `"0:0"` requires a Sailbox whose guest honors requested
users; on older Sailboxes the operation fails until [`upgrade`](#upgrade)
is called.
***
## fs.read
```python Python theme={null}
def read(path: str | PurePosixPath) -> bytes
```
```typescript TypeScript theme={null}
read(path: string): Promise
```
```rust Rust theme={null}
pub async fn read(&self, path: &str) -> Result, SailError>
```
Reads a regular file from the Sailbox as bytes. Loads the whole file into
memory; for very large files (checkpoints, datasets) prefer
[`read_stream`](#read-stream). Raises a file-not-found error if the path does
not exist.
```python Python theme={null}
data = sb.fs.read("/workspace/output.txt")
print(data.decode())
```
```typescript TypeScript theme={null}
const data = await sb.fs.read("/workspace/output.txt");
console.log(data.toString());
```
```rust Rust theme={null}
let data = sb.fs().read("/workspace/output.txt").await?;
println!("{}", String::from_utf8_lossy(&data));
```
***
## fs.read\_stream
```python Python theme={null}
def read_stream(path: str | PurePosixPath) -> FileStream # iterable, sync and async
```
```typescript TypeScript theme={null}
readStream(path: string): Promise // async-iterable
```
```rust Rust theme={null}
pub async fn read_stream(&self, path: &str) -> Result
```
Yields a regular file's contents in chunks without buffering the whole file in
memory. Iterate to completion (or close the stream) so it is released. The
Python iterator supports both `for` and `async for`.
```python Python theme={null}
with open("local.bin", "wb") as f:
for chunk in sb.fs.read_stream("/workspace/large.bin"):
f.write(chunk)
```
```typescript TypeScript theme={null}
import { createWriteStream } from "node:fs";
const out = createWriteStream("local.bin");
for await (const chunk of await sb.fs.readStream("/workspace/large.bin")) {
out.write(chunk);
}
out.end();
```
```rust Rust theme={null}
use std::io::Write;
let mut out = std::fs::File::create("local.bin")?;
let reader = sb.fs().read_stream("/workspace/large.bin").await?;
while let Some(chunk) = reader.next().await {
out.write_all(&chunk?)?;
}
```
***
## fs.write
```python Python theme={null}
def write(
path: str | PurePosixPath,
data: str | bytes | bytearray | memoryview | IOBase,
*,
create_parents: bool = True,
mode: int = 0o644,
user: str | int | None = None,
) -> None
```
```typescript TypeScript theme={null}
write(path: string, data: Buffer | Uint8Array | string, options?: {
createParents?: boolean;
mode?: number;
user?: string;
}): Promise
```
```rust Rust theme={null}
pub async fn write(
&self,
path: &str,
data: &[u8],
options: WriteOptions,
) -> Result<(), SailError>
```
Writes data to a regular file in the Sailbox. Missing parent directories are
created by default in every language. `path` must be absolute. To write
several files in one call, see [`fs.write_files`](#write-files).
| Parameter | Default | Description |
| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `path` | required | Absolute destination path. |
| `data` | required | Bytes or a string. Python also accepts a file-like object, streamed from the source. |
| `create_parents` | `True` | Create missing parent directories. |
| `mode` | `0o644` | POSIX permission bits (0–`0o777`). |
| `user` | `None` | Owner for the written file and any directories the write creates (`"alice"`, `1000`, `"alice:staff"`). The write itself always runs as root, like `COPY --chown`. Omitted follows the image's `USER`, else root; `"0:0"` forces root. Requires a guest that honors requested users; see [the fs namespace](#fs). |
```python Python theme={null}
sb.fs.write("/workspace/input.txt", "hello\n")
```
```typescript TypeScript theme={null}
await sb.fs.write("/workspace/input.txt", "hello\n");
```
```rust Rust theme={null}
use sail::WriteOptions;
sb.fs()
.write("/workspace/input.txt", b"hello\n", WriteOptions::default())
.await?;
```
***
## fs.write\_files
```python Python theme={null}
def write_files(
files: Mapping[str | PurePosixPath, str | bytes | bytearray | memoryview | IOBase],
*,
create_parents: bool = True,
mode: int = 0o644,
user: str | int | None = None,
) -> None
```
```typescript TypeScript theme={null}
writeFiles(files: Record, options?: {
createParents?: boolean;
mode?: number;
user?: string;
}): Promise
```
```rust Rust theme={null}
pub async fn write_files, D: AsRef<[u8]>>(
&self,
files: impl IntoIterator,
options: WriteOptions,
) -> Result<(), SailError>
```
Writes several complete files in one call. `files` maps each absolute guest
path to its contents; every file gets the same `create_parents`, `mode`, and
`user` as [`fs.write`](#write). In Rust, `files` is any iterator of path and
contents pairs, borrowed or owned: each entry is streamed from the buffer you
pass, not copied first. How a batch runs, what happens on a failure,
and when to stream instead are covered in
[Filesystem](/sailboxes-filesystem#write-files).
```python Python theme={null}
sb.fs.write_files({"/workspace/a.txt": "first\n", "/workspace/b.txt": b"second\n"})
```
```typescript TypeScript theme={null}
await sb.fs.writeFiles({
"/workspace/a.txt": "first\n",
"/workspace/b.txt": "second\n",
});
```
```rust Rust theme={null}
use sail::WriteOptions;
sb.fs()
.write_files(
[("/workspace/a.txt", &b"first\n"[..]), ("/workspace/b.txt", &b"second\n"[..])],
WriteOptions::default(),
)
.await?;
```
***
## fs.write\_stream
```python Python theme={null}
def write_stream(
path: str | PurePosixPath,
*,
create_parents: bool = True,
mode: int = 0o644,
user: str | int | None = None,
) -> FileWriter
```
```typescript TypeScript theme={null}
writeStream(path: string, options?: {
createParents?: boolean;
mode?: number;
user?: string;
}): Promise
```
```rust Rust theme={null}
pub async fn write_stream(
&self,
path: &str,
options: WriteOptions,
) -> Result
```
Opens a streaming write and returns a writer: push chunks with `write` and
confirm with `finish`. Only `finish` commits the write; a writer that goes
away without finishing (an explicit `abort`, an exception, or dropping it)
cancels the transfer instead, and the guest file state is then unspecified.
Use it when your data arrives incrementally (streaming logs, assembling an
archive on the fly) rather than from a source [`write`](#write) can consume
whole. The options match [`write`](#write), including `user` for the written
file's owner. Python makes the file mode explicit and defaults it to `0o644`.
```python Python theme={null}
with sb.fs.write_stream("/logs/run.log") as writer:
for line in ["step 1 ok\n", "step 2 ok\n"]:
writer.write(line)
# A clean exit finishes (commits); an exception aborts and propagates.
```
```typescript TypeScript theme={null}
const writer = await sb.fs.writeStream("/logs/run.log");
try {
for (const line of ["step 1 ok\n", "step 2 ok\n"]) {
await writer.write(line);
}
await writer.finish();
} catch (err) {
await writer.abort();
throw err;
}
```
```rust Rust theme={null}
use sail::WriteOptions;
let mut writer = sb
.fs()
.write_stream("/logs/run.log", WriteOptions::default())
.await?;
for line in ["step 1 ok\n", "step 2 ok\n"] {
writer.write(line.as_bytes()).await?;
}
writer.finish().await?;
// Dropping an unfinished writer aborts the transfer.
```
***
## fs.mkdir / fs.remove / fs.exists
```python Python theme={null}
def mkdir(path: str | PurePosixPath, *, user: str | int | None = None) -> None
def remove(path: str | PurePosixPath, *, user: str | int | None = None) -> None
def exists(path: str | PurePosixPath, *, user: str | int | None = None) -> bool
```
```typescript TypeScript theme={null}
mkdir(path: string, options?: { user?: string }): Promise
remove(path: string, options?: { user?: string }): Promise
exists(path: string, options?: { user?: string }): Promise
```
```rust Rust theme={null}
pub async fn mkdir(&self, path: &str, user: Option<&str>) -> Result<(), SailError>
pub async fn remove(&self, path: &str, user: Option<&str>) -> Result<(), SailError>
pub async fn exists(&self, path: &str, user: Option<&str>) -> Result
```
`mkdir` creates a directory and any missing parents (like `mkdir -p`) and is a
no-op if it already exists. `remove` deletes a file or a whole directory tree
(like `rm -rf`) and is a no-op if the path is already absent. `exists` reports
whether a path exists; it follows symlinks (like `test -e`), so a dangling
symlink reports false even though [`fs.ls`](#ls) lists it. A failure (for
example a permission error) raises with the guest's stderr in the message.
With `user`, the helper runs as that user with its permissions enforced:
`mkdir` leaves the created directories owned by it, `remove` can only delete
what that user may delete, and `exists` reports what that user can observe (a
path it lacks permission to reach reports false).
```python Python theme={null}
sb.fs.mkdir("/workspace/results")
if not sb.fs.exists("/workspace/results/run.lock"):
sb.fs.remove("/workspace/results/stale")
```
```typescript TypeScript theme={null}
await sb.fs.mkdir("/workspace/results");
if (!(await sb.fs.exists("/workspace/results/run.lock"))) {
await sb.fs.remove("/workspace/results/stale");
}
```
```rust Rust theme={null}
sb.fs().mkdir("/workspace/results").await?;
if !sb.fs().exists("/workspace/results/run.lock").await? {
sb.fs().remove("/workspace/results/stale").await?;
}
```
***
## fs.ls
```python Python theme={null}
def ls(path: str | PurePosixPath, *, user: str | int | None = None) -> list[DirEntry]
```
```typescript TypeScript theme={null}
ls(path: string, options?: { user?: string }): Promise
```
```rust Rust theme={null}
pub async fn ls(&self, path: &str, user: Option<&str>) -> Result, SailError>
```
Lists a directory's immediate entries (no recursion) as
[`DirEntry`](#direntry) records. A missing path raises, as does a path that is not
a directory and a listing too large for the exec output cap. An entry whose
name is not valid UTF-8 fails the listing, since the path API cannot address
it. With `user`, the listing runs as that user, so a directory it cannot read
raises a permission error.
```python Python theme={null}
for entry in sb.fs.ls("/workspace"):
print(f"{entry.type:9} {entry.size:>8} {entry.name}")
```
```typescript TypeScript theme={null}
for (const entry of await sb.fs.ls("/workspace")) {
console.log(entry.type, entry.size, entry.name);
}
```
```rust Rust theme={null}
for entry in sb.fs().ls("/workspace").await? {
println!("{:?} {} {}", entry.entry_type, entry.size, entry.name);
}
```
***
## fs.upload\_dir
```python Python theme={null}
def upload_dir(local_dir: str | Path, guest_dir: str | PurePosixPath, *, user: str | int | None = None) -> None
```
```typescript TypeScript theme={null}
uploadDir(dirs: { localDir: string; guestDir: string; user?: string }): Promise
```
```rust Rust theme={null}
pub async fn upload_dir(&self, local_dir: &Path, guest_dir: &str, user: Option<&str>) -> Result<(), SailError>
```
Uploads a local directory's contents into a Sailbox directory. The local
directory's entries land inside the Sailbox directory, which is created if
needed. Entries the upload does not name are left in place; a same-named
file is replaced. Uploaded files keep their permission bits (the setuid,
setgid, and sticky bits are cleared) and belong to
the image's `USER` (root when the image sets none or its identity cannot be
read). A `user` (the same syntax the other operations take) gives the
entries to that user instead, like `COPY --chown`; it must exist in the
Sailbox, and like the other operations' `user` it requires a Sailbox whose
guest honors requested users. The Sailbox's image must provide `tar` and
`gzip`, which the transfer uses to ship the directory as one compressed
archive; the default images do.
```python Python theme={null}
sb.fs.upload_dir("./local_data", "/workspace/data")
```
```typescript TypeScript theme={null}
await sb.fs.uploadDir({
localDir: "./local_data",
guestDir: "/workspace/data",
});
```
```rust Rust theme={null}
sb.fs().upload_dir(Path::new("./local_data"), "/workspace/data").await?;
```
***
## fs.download\_dir
```python Python theme={null}
def download_dir(guest_dir: str | PurePosixPath, local_dir: str | Path) -> None
```
```typescript TypeScript theme={null}
downloadDir(dirs: { guestDir: string; localDir: string }): Promise
```
```rust Rust theme={null}
pub async fn download_dir(&self, guest_dir: &str, local_dir: &Path) -> Result<(), SailError>
```
Downloads a Sailbox directory's contents to a local directory. The Sailbox
directory's entries land inside the local directory, which is created if
needed. Entries the download does not name are left in place; a same-named
file is replaced. The transfer reads every file in the tree, so download
directories of ordinary files: system trees like `/proc` or `/sys` hold files that cannot
be read as plain data, and downloading them fails. A file that is being
written while the download runs is captured as it is at that moment, the
way copying a live file would; download after writers finish for a
consistent copy. On Windows, a directory that contains symbolic links
cannot be downloaded, since Windows restricts creating them. The Sailbox's
image must provide `tar` and `gzip`, which the transfer uses to ship the
directory as one compressed archive; the default images do.
```python Python theme={null}
sb.fs.download_dir("/workspace/results", "./local_results")
```
```typescript TypeScript theme={null}
await sb.fs.downloadDir({
guestDir: "/workspace/results",
localDir: "./local_results",
});
```
```rust Rust theme={null}
sb.fs().download_dir("/workspace/results", Path::new("./local_results")).await?;
```
***
## listener / listeners
```python Python theme={null}
def listener(guest_port: int) -> Listener
def listeners() -> list[Listener]
def wait_for_listener(
guest_port: int,
*,
timeout: float = 60.0,
) -> Listener
```
```typescript TypeScript theme={null}
listener(guestPort: number): Promise
listeners(): Promise
waitForListener(guestPort: number, options?: {
timeoutSeconds?: number; // 60
signal?: AbortSignal;
}): Promise
```
```rust Rust theme={null}
pub async fn listener(&self, guest_port: u32) -> Result;
pub async fn listeners(&self) -> Result, SailError>;
pub async fn wait_for_listener(
&self,
guest_port: u32,
options: WaitForListenerOptions, // timeout (60s)
) -> Result;
```
Look up the exposed guest ports and how to reach them. Waiting blocks until
the route is active and the endpoint is reachable (an HTTP probe to the URL,
or a TCP connectivity check to the host/port), then returns the ready
listener; it raises a timeout error otherwise.
```python Python theme={null}
listener = sb.wait_for_listener(3000, timeout=60)
print(listener.endpoint.url)
```
```typescript TypeScript theme={null}
const listener = await sb.waitForListener(3000, { timeoutSeconds: 60 });
if (listener.endpoint?.kind === "http") {
console.log(listener.endpoint.url);
}
```
```rust Rust theme={null}
use sail::{ListenerEndpoint, WaitForListenerOptions};
let listener = sb
.wait_for_listener(3000, WaitForListenerOptions::default())
.await?;
if let Some(ListenerEndpoint::Http { url }) = listener.endpoint() {
println!("{url}");
}
```
Ports are exposed at create time via `ingress_ports`, or at runtime with
`expose`/`unexpose` (see [Networking](/sailboxes-networking)).
***
## enable\_ssh
```python Python theme={null}
def enable_ssh(
*,
allowlist: list[str] | None = None,
wait: bool = True,
timeout: float = 60.0,
) -> TcpEndpoint | None
```
```typescript TypeScript theme={null}
enableSsh(options?: {
allowlist?: string[];
wait?: boolean; // true
timeoutSeconds?: number; // 60
}): Promise
```
```rust Rust theme={null}
pub async fn enable_ssh(
&self,
options: EnableSshOptions, // { allowlist, wait, timeout }
) -> Result
Prepares this Sailbox for SSH (idempotent): installs your org's SSH certificate
authority as trusted, starts `sshd`, and exposes guest port `22` as raw TCP
once the CA-only server owns it. See the
[SSH access guide](/sailboxes-networking#ssh-access) for the access model and
how to connect. By default it blocks until SSH is reachable, up to `timeout`
seconds, and returns the endpoint to dial; pass `wait=False` to skip the probe
and return nothing.
`allowlist` restricts which source addresses or ranges may connect to port
`22`, replacing any existing restriction. Left empty, a first enable opens the
port to any source, and a re-enable keeps the existing restriction. Disabling
SSH removes the port-22 listener along with its restriction, so enabling again
starts fresh.
***
## checkpoint
```python Python theme={null}
def checkpoint(
*,
name: str | None = None,
ttl_seconds: int | None = None,
) -> SailboxCheckpoint
```
```typescript TypeScript theme={null}
checkpoint(options?: {
name?: string;
ttlSeconds?: number;
}): Promise
```
```rust Rust theme={null}
pub async fn checkpoint(
&self,
options: CheckpointOptions, // { name, ttl }
) -> Result
```
Creates a durable checkpoint handle for this Sailbox. Running Sailboxes are
snapshotted first. Paused and sleeping Sailboxes return a handle to their
existing checkpoint without waking. Upgrade a Sailbox that uses an older guest
payload before you create a checkpoint handle.
`name` sets a display name for the handle. `ttl_seconds` (`ttl` in Rust),
when set, must be `> 0` and overrides the server's default retention window. Set it when you
keep a checkpoint to reuse as a template, so the handle does not expire while
you still need it.
The returned handle carries `expires_at`, a timestamp (`datetime` in Python,
`Date` in TypeScript, `OffsetDateTime` in Rust) for when the checkpoint expires:
seven days out unless you asked for a different window. Starting a Sailbox from
it after that fails.
***
## from\_checkpoint
```python Python theme={null}
@classmethod
def from_checkpoint(
checkpoint_id: str,
*,
name: str,
timeout: int | None = None,
) -> Sailbox
```
```typescript TypeScript theme={null}
static fromCheckpoint(options: {
checkpointId: string;
name: string;
timeoutSeconds?: number;
}): Promise
```
```rust Rust theme={null}
pub async fn create_from_checkpoint(
&self, // Client
checkpoint_id: &str,
name: &str,
timeout: Option,
) -> Result
```
Creates a new running Sailbox, called `name`, from a durable checkpoint
handle returned by [`checkpoint`](#checkpoint). The new Sailbox gets a fresh network identity;
existing TCP connections do not carry over, and ingress ports are not
inherited. `timeout` (seconds, `> 0` when set) bounds the call, since a
restore can block for many minutes while the new Sailbox queues for
capacity. A call that times out fails, and the restore may still finish
in the background; the new Sailbox then shows up in
[`Sailbox.list`](#sailbox-list). Omit `timeout` to wait without a client-side
bound.
The new Sailbox restores the memory saved in the checkpoint as well as the
writable disk, so processes the original was running carry on there. Commands
you started with `exec` stop in the new Sailbox, though their writes up to the
checkpoint are kept, and one you started with the background option keeps
running there. Start the other commands you need again.
Sometimes the new Sailbox comes up cold instead, with the disk intact and
nothing running. Write code that expects a cold start.
`checkpoint()` does not support a Sailbox that has volume mounts. Create a
separate Sailbox without volume mounts before you create a checkpoint handle.
***
## upgrade
```python Python theme={null}
def upgrade() -> UpgradeResult
```
```typescript TypeScript theme={null}
upgrade(): Promise
```
```rust Rust theme={null}
pub async fn upgrade(&self) -> Result
```
Upgrades this Sailbox's runtime to the latest version, picking up new
Sailbox features, fixes, and performance improvements without recreating the
Sailbox. A running Sailbox reboots in place on its current disk: all filesystem
state is preserved, but processes restart as they would after a machine reboot
(any application state not yet written to disk is lost, as after a sudden
power loss). A paused or sleeping Sailbox is upgraded without waking; the
upgrade is recorded and applied at the next wake.
Returns an `UpgradeResult`: `applied` is true when the upgrade happened
immediately (the Sailbox was running) and false when it will apply at the next
wake, and `status` is the Sailbox's lifecycle status after the call.
Already-up-to-date Sailboxes report immediate success without rebooting.
***
## pause / sleep / resume / terminate
```python Python theme={null}
def pause() -> None
def sleep(wake_at: Optional[datetime] = None) -> Optional[datetime]
def resume() -> Sailbox
def terminate() -> None
```
```typescript TypeScript theme={null}
pause(): Promise
sleep(wakeAt?: Date): Promise
resume(): Promise
terminate(): Promise
```
```rust Rust theme={null}
pub async fn pause(&self) -> Result<(), SailError>;
pub async fn sleep(&self, wake_at: Option) -> Result
* **`pause`** checkpoints and pauses the Sailbox in memory until you
explicitly resume it. Commands and network traffic do not wake a paused
Sailbox.
* **`sleep`** checkpoints the Sailbox to disk; inbound traffic, an operation,
or an explicit `resume` wakes it. An optional wake time schedules a
wall-clock wake (see [Lifecycle](/sailboxes-lifecycle)).
* **`resume`** wakes a paused or sleeping Sailbox. Raises a not-found error if
the Sailbox is terminated.
* **`terminate`** permanently ends the Sailbox. Idempotent: terminating an
already-terminated Sailbox succeeds.
Sail may also sleep a Sailbox on its own, but only when nothing would notice:
no CPU or network activity, no process waiting on a timer, and no open
connections a sleep would break. A slept Sailbox wakes transparently on
traffic or the next operation.
See [Lifecycle](/sailboxes-lifecycle) for how these interact with
checkpoints, and [Pricing](/sailboxes-pricing) for billing.
***
## Volumes
A `Volume` is an org-scoped shared filesystem (NFS) that can be mounted into
one or more Sailboxes. Resolve one by name, then pass it (or its id) in the
`volumes` mapping of [`Sailbox.create`](#sailbox-create), keyed by the
absolute guest path to mount it at:
```python Python theme={null}
import sail
vol = sail.Volume.find("shared-cache", mint_if_missing=True)
sb = sail.Sailbox.create(
app=app,
name="worker-1",
volumes={"/mnt/cache": vol},
)
```
```typescript TypeScript theme={null}
import { Sailbox, Volume } from "@sailresearch/sdk";
const vol = await Volume.find("shared-cache", { mintIfMissing: true });
const sb = await Sailbox.create({
app,
name: "worker-1",
volumes: { "/mnt/cache": vol },
});
```
```rust Rust theme={null}
use sail::{CreateSailboxRequest, VolumeMount};
let vol = client.get_volume("shared-cache", /* mint_if_missing */ true).await?;
let sb = client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id,
name: "worker-1".into(),
volume_mounts: vec![VolumeMount {
volume_id: vol.volume_id,
mount_path: "/mnt/cache".into(),
}],
..Default::default()
},
/* timeout */ None,
)
.await?;
```
The management surface:
```python Python theme={null}
@staticmethod
def find(name: str, *, mint_if_missing: bool = False) -> Volume
@staticmethod
def list(*, max_objects: int | None = None) -> list[Volume]
def delete(*, allow_missing: bool = False) -> Volume | None
@staticmethod
def delete_by_name(name: str, *, allow_missing: bool = False) -> Volume | None
```
```typescript TypeScript theme={null}
static find(
name: string,
options?: ClientOptions & { mintIfMissing?: boolean }
): Promise
static list(options?: ClientOptions & { maxObjects?: number }): Promise
delete(options?: { allowMissing?: boolean }): Promise
```
```rust Rust theme={null}
pub async fn get_volume(&self, name: &str, mint_if_missing: bool) -> Result
pub async fn list_volumes(&self, max_objects: Option) -> Result, SailError>
pub async fn delete_volume(&self, volume_id: &str, allow_missing: bool) -> Result
* **`find`** looks up a volume by name; `mint_if_missing` creates it when no
volume with that name exists.
* **`list`** returns the org's active volumes, newest first; `max_objects`
caps the count.
* **`delete`** deletes the volume. With `allow_missing`, deleting an
already-deleted volume succeeds instead of raising a not-found error.
Each handle carries `volume_id`, `name`, `backend`, `status`, and
`created_at` / `updated_at` timestamps.
***
## HTTP policies
An [HTTP policy](/sailboxes-credentials#policies) shapes the HTTPS requests a Sailbox
sends; [credential injection](/sailboxes-credentials) is its main use.
Policies belong to your organization, and each Sailbox has at most one
attached at a time:
```python Python theme={null}
def set_http_policy(policy: HttpPolicy | HttpPolicySummary | str) -> None
def http_policy() -> HttpPolicy | None
def clear_http_policy() -> None
```
```typescript TypeScript theme={null}
setHttpPolicy(policy: HttpPolicy | HttpPolicySummary | string): Promise
httpPolicy(): Promise
clearHttpPolicy(): Promise
```
```rust Rust theme={null}
pub async fn set_http_policy(&self, policy: &HttpPolicy) -> Result<(), SailError>;
pub async fn http_policy(&self) -> Result
* **`set_http_policy`** attaches a policy, replacing any policy already
attached. In Python and TypeScript, pass a policy handle, a summary row
from a listing, or a policy id string.
* **`http_policy`** returns the attached policy, or `None` / `null` when no
policy is attached.
* **`clear_http_policy`** detaches the policy. It also succeeds when no policy
is attached.
A policy change applies to HTTPS connections the Sailbox opens after the
call; connections already open keep the previous policy until they close.
Create policies and store the secrets they reference with the org-scoped
`HttpPolicy` and `Secret` surfaces (`Client` methods in Rust). The
[credential injection guide](/sailboxes-credentials) walks through the full
flow in every language.
***
## Supporting types
Field names below use the Python spelling; TypeScript exposes the same fields
in camelCase.
### DirEntry
One entry in a directory listing from [`fs.ls`](#ls). Reported for the entry
itself, so a symlink's `type` is `"symlink"` regardless of what it points at.
| Field | Description |
| --------------- | ------------------------------------------------------------------------------- |
| `name` | The entry's base name, with no directory prefix. |
| `type` | `"file"`, `"directory"`, `"symlink"`, or `"other"` (device, FIFO, socket, ...). |
| `size` | Size in bytes as reported by the guest. |
| `modified_time` | Last-modified time as a Unix timestamp in seconds, with a fractional part. |
| `mode` | Unix permission bits, e.g. `0o644`. The file-type bits are not included. |
### Network policy
The `network_policy` create argument (TypeScript `networkPolicy`) is one of:
* omit it (or `None`) for public networking, the default.
* `NetworkPolicy.NO_NETWORK` (TypeScript `"no_network"`, Rust
`NetworkPolicy::NoNetwork`) for no network access at all. It cannot be
combined with `ingress_ports`.
* a `NetworkAllowlist(hosts)` (TypeScript `{ mode: "allowlist", allowedHosts }`,
Rust `NetworkPolicy::Allowlist(hosts)`) to allow only the listed
destinations: hostnames, `*.` wildcard hostnames, IPv4 addresses, and IPv4
ranges in CIDR form, at least one and at most 128. Only connections the
Sailbox opens are limited, so it combines with `ingress_ports` and SSH. A
list that breaks the [entry rules](/sailboxes-network-policy#entries) fails
the call with `InvalidArgumentError` before a Sailbox is created.
The policy is fixed for the Sailbox's life, and `from_checkpoint` keeps the
policy of the source. `get` and `list` read it back as a `NetworkPolicyInfo`:
`mode` and, for an allowlist, the stored `allowed_hosts`. It is absent when
the Sailbox is public. The [network policy guide](/sailboxes-network-policy)
covers what each entry allows.
### IngressPort
A guest port to expose for ingress. In Python, `Sailbox.create`
also accepts a bare `int` as shorthand for `IngressPort(port)` (an HTTP port).
| Field | Default | Description |
| ------------ | -------- | ------------------------------------------------------------------------------------- |
| `guest_port` | required | Guest port to expose (1–65535). |
| `protocol` | `"http"` | `"http"` for a stable HTTPS URL, or `"tcp"` for a byte-transparent raw-TCP host/port. |
| `allowlist` | `None` | Addresses, ranges, or Sail app names allowed to connect. Empty means public. |
Address and range entries work for HTTP and TCP listeners. App-name entries
match authenticated traffic from another Sailbox and work on HTTP listeners
only, because raw-TCP connections carry no source app identity (so a `"tcp"`
allowlist must contain only addresses and ranges). An entry that reads as an
address or a range is taken as one, so an app name cannot read as either, and
cannot contain a `/`. An address must not carry an IPv6 zone, such as
`fe80::1%eth0`, which names an interface on one machine rather than a source.
To send that authenticated traffic, pass `headers=sail.ingress_auth_headers()`
on the request when calling from inside a Sailbox. From the host, fetch a
specific Sailbox's headers with `sb.ingress_auth_headers()` (this needs an
organization-scoped API key).
Reserved ports: guest port `22` cannot be an HTTP port (expose it as `tcp`
for SSH) and `10000`/`10001`/`15001`/`15002` are reserved for Sail's
in-guest services. Leaving `allowlist` empty normally makes the port publicly
reachable, with one exception: for well-known database, cache, and search
ports (e.g. `5432`, `6379`), a raw-TCP expose with no allowlist is rejected,
so you can't accidentally publish an unprotected Postgres or Redis to the
whole internet. To make one of these ports public on purpose, say so
explicitly with `allowlist=["0.0.0.0/0", "::/0"]`.
```python Python theme={null}
sb = sail.Sailbox.create(
app=app,
name="db-box",
ingress_ports=[80, 443, sail.IngressPort(5432, "tcp", allowlist=["203.0.113.0/24"])],
)
```
```typescript TypeScript theme={null}
const sb = await Sailbox.create({
app,
name: "db-box",
ingressPorts: [
{ guestPort: 80, protocol: "http" },
{ guestPort: 443, protocol: "http" },
{ guestPort: 5432, protocol: "tcp", allowlist: ["203.0.113.0/24"] },
],
});
```
```rust Rust theme={null}
use sail::{IngressPort, IngressProtocol};
let sb = client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id,
name: "db-box".into(),
ingress_ports: vec![
IngressPort {
guest_port: 80,
protocol: IngressProtocol::Http,
allowlist: Vec::new(),
},
IngressPort {
guest_port: 5432,
protocol: IngressProtocol::Tcp,
allowlist: vec!["203.0.113.0/24".to_string()],
},
],
..Default::default()
},
/* timeout */ None,
)
.await?;
```
### Exec process
A handle to a command running in the Sailbox (`ExecProcess` in Python,
`ExecProcess` in TypeScript and Rust). The command runs inside the Sailbox, and
a temporary network interruption does not kill it. Sail may reattach after an
interruption, but reattachment does not guarantee exact output replay. Closing
the handle abandons the live attachment without killing the command.
**Live output.** `stdout` and `stderr` are iterators (`for` in Python,
`for await` in TypeScript, `next().await` in Rust). The Python and TypeScript
iterators yield text, incrementally decoded from the raw stream (a multibyte
character split across chunks arrives whole); the raw byte stream is available
as `stdout_bytes` / `stderr_bytes` in Python, `.raw()` on the stream in
TypeScript, and is what the Rust reader yields directly. Bytes travel exactly
as the command wrote them (escape sequences and binary payloads included).
**Output limits.** Each stream has a buffer, 1 MiB by default.
`output_buffer_bytes` (`outputBufferBytes` in TypeScript) sets its size, from
64 KiB to 64 MiB. The `output_mode` option (`outputMode` in TypeScript) sets what happens when
a buffer fills.
This applies to commands started without a `pty`; a `pty` command always
behaves like `tail`.
* `auto`, the default. If you are reading a stream and fall behind, the
command pauses when the buffer fills and resumes as you read, like a pipe.
If you are not reading a stream, the command never pauses and the stream
keeps only its most recent bytes. Reading a stream is how you get every
byte, and it slows the command when you cannot keep up.
* `pipe`. The command pauses when either buffer fills and stays paused until
you read that stream, so nothing is lost while you are late to start
reading. Read both streams, or the command stays paused on the one you
ignore; `2>&1` or `2>/dev/null` in a shell command is the easy way out.
Once you release a reader, its stream goes back to keeping only its most
recent bytes. `wait()` without a reader waits for as long as the command
stays paused. Not available with a `pty`.
* `tail`. The command never pauses for you. Each stream keeps only its most
recent bytes, even while you are reading it, so a slow reader skips output
without notice (a Rust `StreamReader` can check `took_drop`).
`stdout_truncated` and `stderr_truncated` say only that the result holds
less than the command wrote, which is also the case after a reader consumed
everything.
Stdout and stderr are handled separately. Sending `cancel()`, and the exec
`timeout`, end every pause: from then on each stream keeps only its most recent
bytes, so a reader more than a buffer behind skips ahead. A command that ignores
the cancel signal keeps running that way; cancel with `force` to stop it. The
command keeps its original timeout. If a handle attaches to a command launched
earlier under the same `idempotency_key`, that handle's pause deadline starts
when the attachment succeeds, so it can release the pauses one full timeout
after that; `cancel()` and `close()` release them at once. A command that
never pauses for you still runs no faster than your connection carries its
output; when output outruns the connection, the command pauses on the Sailbox
until the backlog drains.
With `auto`, start reading right after `exec()` returns to get every byte
(what starts a read differs by language; see below). You can read stdout
without holding stderr, or the reverse. The stream you are not holding keeps
its most recent bytes and never pauses the command when it fills, and a reader
that starts on it late begins with whatever is still held; with `pipe`, that
is everything. If you hold both readers, read them at the same time, each from
its own thread or task
(`asyncio.gather` in Python, `Promise.all` in TypeScript, two tasks in Rust).
The exit code is available from `poll()` (Python also has `exit_code`) as soon
as the streams end.
**Claiming and releasing a stream.** Each stream can be read once; a second
attempt fails (`InvalidArgumentError` in Python and TypeScript,
`SailError::InvalidArgument` in Rust), before or after the first reader is
released. After release, Sail again keeps only the stream's most recent bytes,
in every mode.
* **Python.** Accessing `proc.stdout` or `proc.stdout_bytes` (and the `stderr`
twins) claims the stream and returns a generator; from that access on the
command pauses rather than lose output (see **Output limits** for the
exceptions). The stream is released when the
generator ends, when you call `close()` on it (`await` its `aclose()` for an
async generator), or when nothing references it any more (a `for` loop over
`proc.stdout` drops it when the loop ends, including by `break`). To stop
early on purpose, keep the generator in a variable and call `close()`.
* **TypeScript.** Accessing `proc.stdout` or `proc.stderr` claims nothing. The
stream is claimed when iteration starts (`for await`, `.raw()`, `.text()`,
`.bytes()`) or when you call `.toReadable()`. It is released when the
iteration finishes or you leave it (`break`, `return`, or a thrown error
inside `for await`), when `.text()` or `.bytes()` reaches the end, or when
the `Readable` is destroyed (released at once, even while a read is waiting
for output).
* **Rust.** `reader(OutputStream::Stdout)` or `reader_async(...)` claims the
stream. It is held while the reader value exists and released when the
reader is dropped.
In every language, `close()` on the handle, or your process exiting, releases
both streams. The command keeps running and never pauses. `wait()` fails after
`close()` unless it already resolved a result. If the connection to the
Sailbox is interrupted, the command keeps
running and does not pause while Sail reconnects; output produced in the
meantime can be missing, and `stdout_truncated` / `stderr_truncated` report
when that happened. Reattachment is best-effort, not an exact replay.
A `pty` command never pauses: Sail keeps only its most recent output, up to
the buffer size, so a reader further behind than that misses older output. Use
`resync()` to
ask the command to repaint its current screen.
**stdin.** With `open_stdin=True`, write to the command's stdin and deliver EOF
by closing it (Python `proc.stdin.write`/`close`, TypeScript
`writeStdin`/`closeStdin`, Rust `write_stdin`/`close_stdin`). Writes block
(like a pipe write) while the command is not reading. Writing to a completed
command, or after the command closed its stdin, raises a broken-pipe error.
**`wait()`** waits for the command to finish and returns its
[result](#sailboxexecresult), holding each stream's buffer, its most recent
output (see **Output limits** above). It never pauses the command itself and
can be called while a reader is still open; with `pipe`, it waits for as long
as an unread stream keeps the command paused. After `close()` it fails,
unless a result was already resolved; a repeat call returns that result. For
foreground execs it waits
for the command to finish; for background execs it waits only for the detached
launcher. An exec that ended without a real exit code (the
machine hosting the Sailbox was lost before the command finished) raises a
host-lost error instead of returning a result.
In Python and the `sail` CLI, `Ctrl-C` during a wait additionally sends
`SIGINT` to the remote command and resumes waiting, and a second `Ctrl-C`
escalates to `SIGKILL`; terminal-facing surfaces forward the interrupt like
a local foreground job. The TypeScript and Rust libraries leave process
signal handling to your application; wire the same behavior with
`cancel` if you want it.
**`poll()`** (Rust: `try_wait`) returns the exit code once the output stream
has ended, else nothing. It never blocks and never drops output, so it is the
way to get the exit code after reading the streams yourself. If the connection
was lost for good mid-command, the stream ends early with the outcome still
unknown: `poll()` stays empty and `wait()` fetches the result Sail recorded.
**`cancel()`** signals the guest command: `SIGINT` by default, `SIGKILL` with
`force`. Idempotent on the server. If the Sailbox is sleeping, cancel wakes it
to deliver the signal; if you paused the Sailbox, resume it first (cancel
raises rather than waiting, since the guest cannot receive the signal while
paused).
**`close()`** abandons the handle without killing the command. It releases both
streams. The command keeps running and never pauses, and Sail keeps only the
most recent output of each stream. Call `cancel()` instead if the command
should stop. `wait()` fails after `close()` unless it already resolved a
result.
**`resize(cols, rows)`** adjusts a `pty` command's window.
**`resync()`** asks a `pty` command to repaint its current screen on the output
stream. A command runs at full speed and never waits for a slow reader, so if
you render its output yourself and fall far behind, the oldest output is dropped
and the screen can end up garbled. Call `resync()` to receive the current screen
instead of a broken, partial one. It does nothing for a command with no `pty`.
The interactive `shell` helper calls it for you.
### Exec result
The output of a completed exec (`ExecResult` in Python, `ExecResult` in
TypeScript and Rust).
| Field | Type | Description |
| ------------------ | ------ | --------------------------------------------------------------------------------- |
| `stdout` | `str` | The most recent standard output, up to the exec's buffer size (1 MiB by default). |
| `stderr` | `str` | The most recent standard error, up to the exec's buffer size (1 MiB by default). |
| `exit_code` | `int` | Process exit code. |
| `timed_out` | `bool` | The command hit its `timeout` budget and was killed. |
| `stdout_truncated` | `bool` | The command wrote more stdout than `stdout` holds, so older bytes are missing. |
| `stderr_truncated` | `bool` | The command wrote more stderr than `stderr` holds, so older bytes are missing. |
### Listener
An exposed guest port and how to reach it.
Every listener carries its guest port, `protocol`, route status, and a typed
`endpoint`: an [`HttpEndpoint`](#httpendpoint) (with `url`) or a
[`TcpEndpoint`](#tcpendpoint) (with `host`/`port`), absent until routable. In
TypeScript the endpoint is a union discriminated on `kind`; in Rust it is the
`ListenerEndpoint` enum returned by `listener.endpoint()`. Listeners are
snapshots; re-fetch with `sb.listener(guest_port)`.
### HttpEndpoint
The routable HTTPS address of an `"http"` listener.
| Field | Type | Description |
| ----- | ----- | ----------------------------------------- |
| `url` | `str` | The HTTPS URL to reach the guest service. |
### TcpEndpoint
The host and port to connect to for a `"tcp"` listener.
| Field | Type | Description |
| ------ | ----- | ----------------- |
| `host` | `str` | Hostname to dial. |
| `port` | `int` | Port to dial. |
### Sailbox snapshot fields
The monitoring snapshot carried by every `Sailbox` returned from
[`Sailbox.get`](#sailbox-get) and [`Sailbox.list`](#sailbox-list).
| Field | Type | Description |
| --------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sailbox_id` | `str` | Sailbox id. |
| `app_id` / `app_name` | `str` | Owning app. |
| `image_id` | `str` | Image the Sailbox runs. |
| `name` | `str` | Sailbox name. |
| `status` | `str` | Lifecycle status. |
| `memory_mib` / `vcpu_count` / `state_disk_size_gib` | `int` | Configured maximums. |
| `cpu_requested_vcpu` | `int` | Configured vCPU maximum. |
| `cpu_used_vcpu` | `float` | Latest observed vCPU usage. |
| `memory_requested_bytes` / `memory_used_bytes` | `int` | Configured max vs observed memory. |
| `disk_requested_bytes` / `disk_used_bytes` | `int` | Configured max vs observed disk. |
| `architecture` | `str` | CPU architecture. |
| `guest_schema_version` | `int \| None` | Version of the managed runtime the Sailbox last booted with. |
| `deprecation` | `SailboxDeprecation \| None` | Set when this Sailbox's managed runtime should be upgraded and the caller can act on it: a `deadline` date and a `message` with upgrade instructions. `None` otherwise. |
| `error_message` | `str \| None` | Failure detail, when applicable. |
| `checkpoint_generation` | `int` | Monotonic checkpoint counter. |
| `started_at` / `last_checkpointed_at` | `datetime \| None` | Timestamps (`Date` in TypeScript, `OffsetDateTime` in Rust). |
| `created_at` / `updated_at` | `datetime` | Timestamps (`Date` in TypeScript, `OffsetDateTime` in Rust). |
| `created_by_user_id` | `str \| None` | The user whose credential created the Sailbox; `None` for service-key creates. |
| `visibility` | `str \| None` | `"private"` for creator-restricted Sailboxes; `None`/`"org"` otherwise. |
| `network_policy` | `NetworkPolicyInfo \| None` | The Sailbox's network policy: `None` means public; otherwise `mode` (`"no_network"` or `"allowlist"`) and, for an allowlist, the `allowed_hosts` in effect. Read-only. |
Observed-usage fields reflect the latest live sample within roughly the last
two minutes, falling back to zero when no recent sample is available.
### SailboxPage
One page of [`Sailbox.list_page`](#sailbox-list-page) results.
| Field | Type | Description |
| ---------- | --------------- | ---------------------------------------------- |
| `items` | `list[Sailbox]` | The Sailboxes on this page. |
| `limit` | `int` | Page size used. |
| `offset` | `int` | Page offset used. |
| `total` | `int` | Total matching Sailboxes. |
| `has_more` | `bool` | Whether more Sailboxes exist beyond this page. |
# Apps
Source: https://docs.sailresearch.com/sailbox-sdk-apps
The org-owned application a Sailbox belongs to
An `App` is the application that owns your Sailboxes. Every
[`Sailbox.create`](/sailbox-sdk#sailbox-create) call needs an app, usually
resolved by name with `App.find()`.
```python Python theme={null}
import sail
app = sail.App.find(name="example-app", mint_if_missing=True)
print(app.id, app.name)
```
```typescript TypeScript theme={null}
import { App } from "@sailresearch/sdk";
const app = await App.find("example-app", { mintIfMissing: true });
console.log(app.id, app.name);
```
```rust Rust theme={null}
use sail::Client;
let client = Client::from_env()?;
let app = client.find_app("example-app", /* mint_if_missing */ true).await?;
println!("{} {}", app.id, app.name);
```
## App.find
```python Python theme={null}
@classmethod
def find(name: str, *, mint_if_missing: bool = False) -> App
```
```typescript TypeScript theme={null}
static find(
name: string,
options?: ClientOptions & { mintIfMissing?: boolean }
): Promise
```
```rust Rust theme={null}
pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result
```
Finds an app by name, optionally creating it if it does not exist.
| Parameter | Default | Description |
| ----------------- | -------- | ----------------------------------------------- |
| `name` | required | The app name to look up. |
| `mint_if_missing` | `False` | Create the app if no app with that name exists. |
**Returns** the `App`.
**Raises** a not-found error if the app does not exist and `mint_if_missing`
is `False`, and a permission error on auth failures.
## App.list
```python Python theme={null}
@classmethod
def list() -> list[App]
```
```typescript TypeScript theme={null}
static list(options?: ClientOptions): Promise
```
```rust Rust theme={null}
pub async fn list_apps(&self) -> Result, SailError>
```
Returns every app the current org owns, newest first. Apps with no Sailboxes
yet are included. The response is not paginated.
## Attributes
| Attribute | Type | Description |
| ------------ | ---------- | -------------------------------------------------------------------- |
| `id` | `str` | Stable app identifier. |
| `name` | `str` | App name. |
| `created_at` | `datetime` | Creation timestamp (`Date` in TypeScript, `OffsetDateTime` in Rust). |
# Errors
Source: https://docs.sailresearch.com/sailbox-sdk-errors
Sailbox and image error taxonomy
Every SDK failure carries the same taxonomy: catch the base `SailError`
for everything, or match a specific failure.
```python Python theme={null}
import sail
try:
sb = sail.Sailbox.create(app=app, name="box")
result = sb.exec("false", timeout=5).wait()
except sail.SailboxCreationError:
... # creation failed
except sail.SailboxError:
... # any other Sailbox failure
```
```typescript TypeScript theme={null}
import { Sailbox, SailboxCreationError, SailError } from "@sailresearch/sdk";
try {
const sb = await Sailbox.create({ app, name: "box" });
await (await sb.exec("false", { timeoutSeconds: 5 })).wait();
} catch (err) {
if (err instanceof SailboxCreationError) {
// creation failed
} else if (err instanceof SailError) {
// any other SDK failure
}
}
```
```rust Rust theme={null}
use sail::{CreateSailboxRequest, SailError};
match client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id.clone(),
name: "box".into(),
..Default::default()
},
/* timeout */ None,
)
.await
{
Ok(sb) => {
let _ = sb.exec_shell("false", Default::default()).await?.wait().await?;
}
Err(SailError::Creation { message, .. }) => eprintln!("creation failed: {message}"),
Err(err) => eprintln!("{err}"),
}
```
## Command failure is not an exception
A command that runs to completion and exits nonzero is not an SDK failure.
`run` and `exec(...).wait()` return normally with the exit code on the
result; check it yourself:
```python Python theme={null}
result = sb.run("make test")
if result.exit_code != 0:
print(result.stderr)
```
```typescript TypeScript theme={null}
const result = await sb.run("make test");
if (result.exitCode !== 0) {
console.error(result.stderr);
}
```
```rust Rust theme={null}
let result = sb.run_shell("make test", RunOptions::default()).await?;
if result.exit_code != 0 {
eprint!("{}", result.stderr);
}
```
To treat command failure as an error, pass `check` to `run`: a nonzero exit
or a timeout then raises `CommandFailedError`, which carries the completed
result. Rust has no `check`; it reports a nonzero exit through the returned
`ExecResult`, so use the exit-code check above.
```python Python theme={null}
try:
sb.run("make test", check=True)
except sail.CommandFailedError as err:
print(err.result.stderr)
```
```typescript TypeScript theme={null}
try {
await sb.run("make test", { check: true });
} catch (err) {
if (err instanceof CommandFailedError) console.error(err.result.stderr);
}
```
Exec errors in the taxonomy below cover the SDK failing to run the command at
all, not the command's own exit status.
## The taxonomy
| Failure | Python | TypeScript | Rust `SailError::` |
| --------------------------- | --------------------------------- | --------------------------------- | --------------------- |
| Creation failed | `SailboxCreationError` | `SailboxCreationError` | `Creation` |
| Custom image build failed | `ImageBuildError` | `ImageBuildError` | `ImageBuild` |
| Exec failed | `SailboxExecutionError` | `SailboxExecutionError` | `Execution` |
| Sailbox gone (terminated) | `SailboxTerminatedError` | `SailboxTerminatedError` | `Terminated` |
| Unknown exec request | `SailboxExecRequestNotFoundError` | `SailboxExecRequestNotFoundError` | `ExecRequestNotFound` |
| Host machine lost mid-run | `SailboxHostLostError` | `SailboxHostLostError` | `HostLost` |
| Unknown id / unexposed port | `NotFoundError` | `NotFoundError` | `NotFound` |
| Auth failure | `PermissionDeniedError` | `PermissionDeniedError` | `PermissionDenied` |
| Invalid argument | `InvalidArgumentError` | `InvalidArgumentError` | `InvalidArgument` |
| Missing guest file | `FileNotFoundError` | `FileNotFoundError` | `FileNotFound` |
| Readiness or build timeout | `TimeoutError` | `TimeoutError` | `Transport` (timeout) |
| Writing to a closed stdin | `BrokenPipeError` | `BrokenPipeError` | `BrokenPipe` |
| Network/transport failure | `TransportError` | `TransportError` | `Transport` |
| Deleting an in-use secret | `SecretInUseError` | `SecretInUseError` | `Api` |
| Deleting an attached policy | `HttpPolicyInUseError` | `HttpPolicyInUseError` | `Api` |
| Unexpected API response | `ApiError` | `ApiError` | `Api` |
Every class derives from `sail.SailError`. The Python classes that match a
Python builtin also inherit it (`NotFoundError` is a `LookupError`,
`TimeoutError` the builtin `TimeoutError`, `ApiError` a `RuntimeError`, and
so on), so handlers written against the builtins keep working.
`SecretInUseError` and `HttpPolicyInUseError` subclass `ApiError`, so
handlers that catch `ApiError` also catch them. API and
creation failures carry `status_code` (`status` in TypeScript) and the parsed
response `body`. Every error carries a `retryable` flag: `True` means
retrying the same call may succeed (the failure was transient, like a
network drop or a busy service), `False` means it is deterministic and a
retry would just fail the same way.
## Notable errors
### Creation failed
Raised when [`Sailbox.create`](/sailbox-sdk#sailbox-create) fails. When
creation succeeded but SSH setup failed (with `ssh=True`), the message carries
the new Sailbox's id so you can fetch it to retry `enable_ssh` or terminate
it.
### Host machine lost mid-run
The machine hosting your Sailbox failed before the command finished. The
command may have run only partially, and its output is gone. The run cannot be
resumed: calling [`exec`](/sailbox-sdk#exec) again starts it over from the
beginning, so any side effects the partial run applied will happen again. The
Sailbox itself recovers automatically; you do not need to resume it.
### Function errors (Python only)
`SailboxFunctionError` is raised when a
[`@sail.function`](/sailbox-sdk-images#sail-function) call fails while running
in the Sailbox. It carries the remote failure context:
| Attribute | Type | Description |
| ------------ | ----- | ---------------------------- |
| `error_type` | `str` | Remote exception class name. |
| `traceback` | `str` | Remote traceback text. |
| `stdout` | `str` | Captured remote stdout. |
| `stderr` | `str` | Captured remote stderr. |
`SailboxFunctionSerializationError` is raised when a function payload or
result cannot be serialized, or the remote function runtime cannot be prepared
(including a Python major.minor version mismatch between your local
interpreter and the Sailbox's `python3`). Both are subclasses of
`SailboxExecutionError`.
# Images & Functions
Source: https://docs.sailresearch.com/sailbox-sdk-images
Base images, the custom-image builder, and running Python functions in a Sailbox
Images define the root filesystem a Sailbox boots from: start from a base
image, optionally chain build steps, and pass the result to
[`Sailbox.create`](/sailbox-sdk#sailbox-create). `@sail.function`
additionally lets Python ship a local function into a Sailbox and run it as if
it were local. See the [Images guide](/sailboxes-images) for a task-oriented
walkthrough.
## Base images
```python Python theme={null}
arm = sail.Image.debian_arm64
amd = sail.Image.debian_amd64
# Devtools preinstalled, for use as a remote developer workstation:
dev = sail.Image.devbox("arm64")
```
```typescript TypeScript theme={null}
import { Image } from "@sailresearch/sdk";
const arm = Image.debian("arm64");
const amd = Image.debian("amd64");
// Devtools preinstalled, for use as a remote developer workstation:
const dev = Image.devbox("arm64");
```
```rust Rust theme={null}
use sail::{BaseImage, ImageArchitecture};
use sail::imagebuild::ImageDefinition;
let arm = ImageDefinition {
base: Some(BaseImage::Debian),
architecture: ImageArchitecture::Arm64,
..Default::default()
};
```
The `devbox` images are the Debian base plus a baked development layer: Node
LTS with `npm`, `build-essential` compilers, the OS libraries editor remote
servers need, the `claude` and `codex` CLIs, Docker with the `docker compose`
and `docker buildx` plugins, and common developer tools (`jq`, `gh`, `fd`,
`fzf`, `uv`, `mise`, `tmux`, `git-lfs`, and more). Devbox images boot fast
because the whole layer ships prebuilt, and `uv`/`mise` lazy-install further
language toolchains on demand. The trade-off is that they are prebuilt only:
builder methods such as `apt_install` and `pip_install` are rejected on a
devbox base. Use a `debian` base when you need custom build steps.
The Docker daemon starts automatically when a devbox Sailbox boots and keeps
running across sleeps. Right after a fresh boot, the daemon can take a few
seconds to accept commands. If it ever stops, start `dockerd` again as root.
If it complains about a stale pid file, delete `/var/run/docker.pid` and
retry.
Devbox images also have a working clipboard. During `sail box shell`,
Ctrl+V puts your local clipboard on the guest's clipboard. Pasting a
screenshot into `claude` or `codex` works exactly as it does on your own
machine, and text copied inside the guest is copied back to your local
clipboard.
In Python, `sail.Image.debian_arm64` and `debian_amd64` are shorthand for
`sail.Image.debian("arm64")` and `debian("amd64")`, and pin the image to your
local Python version so `@sail.function` can deserialize local bytecode. Pass
`install_python=False`, as in `sail.Image.debian("arm64", install_python=False)`,
to keep the base's stock `python3` instead.
### from\_registry
Use your own image as the root filesystem. Sail pulls it and layers the
Sailbox runtime on top, so every builder method works the same as on a
Debian base.
```python Python theme={null}
image = sail.Image.from_registry("python:3.13")
```
```typescript TypeScript theme={null}
const image = Image.fromRegistry("python:3.13");
```
```rust Rust theme={null}
let image = ImageDefinition {
oci_ref: Some("python:3.13".to_string()),
..Default::default()
};
```
Reference a Debian- or Ubuntu-based image on a supported public registry
(`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`), written as you
would for `docker pull`: `python:3.13` means `docker.io/library/python:3.13`,
`acme/tool` means `docker.io/acme/tool`, and the other registries are named
in full, as in `ghcr.io/acme/tool`. You can pass a tag, a digest (`name@sha256:...`), or just the name,
which means the `latest` tag. A tag is pinned for your organization once an
image has been built from it: later builds keep getting that version, even
if the tag moves upstream. Use `force_build`
(`forceBuild` in TypeScript, `BuildMode::ForceBuild` in Rust) to look the
tag up again and move the pin for your whole organization. If forced
builds of the same tag overlap, the last-requested one that succeeds
decides what the tag means, no matter which build finishes first. A
digest names
exactly one image, so it never moves. Your Sailbox runs on the CPU architecture the image was built for;
an image published for both amd64 and arm64 runs on amd64. Pass
`architecture` to require one, and the build fails if the image was not
built for it. Its environment variables, working directory, and `USER`
become the defaults for commands you run; its `ENTRYPOINT` and `CMD` are
not run. See
[Bring your own base image](/sailboxes-images#bring-your-own-base-image)
for the full requirements.
### from\_dockerfile
Build your own Dockerfile into the image. Sail builds it and layers the
Sailbox runtime on top, so every builder method composes on the result.
```python Python theme={null}
from pathlib import Path
env_dir = Path("./envs/task1")
image = sail.Image.from_dockerfile(env_dir / "Dockerfile", context_dir=env_dir)
```
```typescript TypeScript theme={null}
const image = Image.fromDockerfile("./envs/task1/Dockerfile", {
contextDir: "./envs/task1",
});
```
```rust Rust theme={null}
use std::collections::HashMap;
use sail::imagebuild::{DockerfileInput, DockerfileSource, ImageDefinition};
let image = ImageDefinition {
dockerfile: Some(DockerfileSource {
dockerfile: DockerfileInput::Path("./envs/task1/Dockerfile".into()),
context_dir: Some("./envs/task1".into()),
build_args: HashMap::new(),
ignore: Vec::new(),
}),
..Default::default()
};
```
Pass the path to a Dockerfile, or its literal text with `contents=`
(`{ contents }` in TypeScript, `DockerfileInput::Contents` in Rust).
`context_dir` is the directory `COPY` and `ADD` read from, with
`.dockerignore` honored (a `.dockerignore` file named after your
Dockerfile, for example `Dockerfile.dockerignore`, is used instead when
present, as with Docker) and `ignore` patterns applied on top. Every
image a `FROM` or `COPY --from` names must live on a supported public
registry, and a short name works as you would expect: `FROM python:3.12`
means `docker.io/library/python:3.12`. The image the Dockerfile produces
must be Debian- or Ubuntu-based. The build runs for amd64 unless you
pass `architecture`, and `build_args` values fill the Dockerfile's `ARG`
instructions. Tags a `FROM` or `COPY --from` names are pinned on your
organization's first use and reused after that; `force_build`
(`forceBuild` in TypeScript, `BuildMode::ForceBuild` in Rust) looks them
up again. See
[Build from a Dockerfile](/sailboxes-images#build-from-a-dockerfile) for
the full behavior.
## The image builder
An image definition is an immutable value. Builder methods return a new
definition, so you chain them and either pass the result straight to
`Sailbox.create` (which builds it for you) or call `build()` to build
eagerly.
```python Python theme={null}
image = (
sail.Image.debian_arm64
.apt_install("git", "curl")
.pip_install("httpx")
.env({"LOG_LEVEL": "info"})
.build()
)
```
```typescript TypeScript theme={null}
const spec = await Image.debian("arm64")
.aptInstall("git", "curl")
.pipInstall("httpx")
.env({ LOG_LEVEL: "info" })
.build();
```
```rust Rust theme={null}
use std::collections::HashMap;
use sail::{BaseImage, ImageArchitecture};
use sail::imagebuild::{BuildMode, ImageDefinition, ImageDefinitionStep};
use std::time::Duration;
let image = ImageDefinition {
base: Some(BaseImage::Debian),
architecture: ImageArchitecture::Arm64,
env: HashMap::from([("LOG_LEVEL".to_string(), "info".to_string())]),
steps: vec![
ImageDefinitionStep::AptInstall(vec!["git".into(), "curl".into()]),
ImageDefinitionStep::PipInstall(vec!["httpx".into()]),
],
..Default::default()
};
let spec = client
.build_image_definition(&image, Duration::from_secs(1800), BuildMode::ReuseExisting)
.await?;
```
### apt\_install
```python Python theme={null}
def apt_install(*packages: str) -> ImageDefinition
```
```typescript TypeScript theme={null}
aptInstall(...packages: string[]): Image
```
```rust Rust theme={null}
ImageDefinitionStep::AptInstall(packages: Vec)
```
Adds a step that installs Debian packages with `apt`. Requires at least one
non-empty package name.
### pip\_install
```python Python theme={null}
def pip_install(*packages: str) -> ImageDefinition
```
```typescript TypeScript theme={null}
pipInstall(...packages: string[]): Image
```
```rust Rust theme={null}
ImageDefinitionStep::PipInstall(packages: Vec)
```
Adds a step that installs Python packages with `pip`. Requires at least one
non-empty package name.
### run\_commands
```python Python theme={null}
def run_commands(*cmd: str) -> ImageDefinition
```
```typescript TypeScript theme={null}
runCommand(command: string): Image
```
```rust Rust theme={null}
ImageDefinitionStep::RunCommand(command: String)
```
Adds one build step per shell command, in order. Each command must be
non-empty.
### add\_local\_file
```python Python theme={null}
def add_local_file(
local_path: str | Path,
remote_path: str,
*,
mode: int | None = None,
) -> ImageDefinition
```
```typescript TypeScript theme={null}
addLocalFile(localPath: string, remotePath: string, options?: {
mode?: number;
}): Image
```
```rust Rust theme={null}
ImageDefinitionStep::AddLocalFile {
local_path: PathBuf,
remote_path: String,
mode: Option,
}
```
Bakes the contents of one local file into the image at `remote_path`. Only
the file's content hash, target path, and mode identify the image, so a
one-byte change forces a rebuild.
| Parameter | Default | Description |
| ------------- | -------- | ------------------------------------------------------------------------ |
| `local_path` | required | Path to the local file. |
| `remote_path` | required | Absolute POSIX destination. A trailing slash appends the local basename. |
| `mode` | `None` | POSIX permission bits (low 9 bits, max `0o777`). Defaults to `0o644`. |
Raises an invalid-argument error if the source is missing, the path is
invalid, or the file exceeds the 5 GiB single-file limit.
### add\_local\_dir
```python Python theme={null}
def add_local_dir(
local_path: str | Path,
remote_path: str,
*,
ignore: Sequence[str] | Path | str | None = None,
) -> ImageDefinition
```
```typescript TypeScript theme={null}
addLocalDir(localPath: string, remotePath: string, options?: {
ignore?: string[];
ignoreFile?: string;
}): Image
```
```rust Rust theme={null}
ImageDefinitionStep::AddLocalDir {
local_path: PathBuf,
remote_path: String,
ignore: Vec,
ignore_file: Option,
}
```
Bakes a local directory into the image at `remote_path`. Each regular file is
hashed and uploaded; per-file modes come from the local stat. Symlinks are
skipped. `ignore` takes gitignore-style patterns, or point at an existing
ignore file (such as `.gitignore`) instead. `remote_path` must be absolute.
### env
```python Python theme={null}
def env(env: dict[str, str]) -> ImageDefinition
```
```typescript TypeScript theme={null}
env(env: Record): Image
```
```rust Rust theme={null}
// ImageDefinition field:
env: HashMap
```
Sets environment variables baked into the image. Requires at least one
non-empty key.
### build
```python Python theme={null}
def build(*, timeout: int = 1800, force_build: bool = False) -> ImageDefinition
```
```typescript TypeScript theme={null}
build(options?: {
timeoutSeconds?: number;
forceBuild?: boolean;
}): Promise
```
```rust Rust theme={null}
pub async fn build_image_definition(
&self, // Client
def: &ImageDefinition,
timeout: Duration,
mode: BuildMode,
) -> Result
```
Builds the image and blocks until it is ready, returning a built definition
you can create Sailboxes from. `timeout` bounds the whole pipeline (local
file uploads and the build) and must be `> 0`.
Everything you create from the result needs no further build. By default,
Sail may reuse an existing ready build for the definition
(`BuildMode::ReuseExisting` in Rust). Use `force_build=True`,
`forceBuild: true`, or `BuildMode::ForceBuild` to build it again: new
Sailboxes use the fresh image once it is ready, Sailboxes that already
exist keep the filesystem they were created with, and a forced build that
fails changes nothing.
For an image imported with [`from_registry`](#from_registry) through a tag,
a forced build also asks the registry what the tag points at now and builds
that version. The tag then means that version for your whole organization,
while the result of an earlier build keeps its pinned version. For an image
built with [`from_dockerfile`](#from_dockerfile), a forced build looks up
the tags its `FROM` and `COPY --from` instructions name and moves those
pins for your whole organization, while the result of an earlier build
keeps the versions its build used. If forced builds overlap, the last-requested
one that succeeds decides which image new Sailboxes use and, for a tag,
what the tag means.
Raises an image-build error if the build fails and a timeout error if it does
not finish within `timeout`.
You rarely need to call `build()` yourself: passing an unbuilt definition to
[`Sailbox.create`](/sailbox-sdk#sailbox-create) builds it first (bounded by
`image_build_timeout`).
***
## @sail.function
Python only.
```python theme={null}
@sail.function
def fn(...): ...
# or
@sail.function()
def fn(...): ...
```
Decorates a Python function so it can run inside a Sailbox via
[`Sailbox.exec`](/sailbox-sdk#exec). The decorator returns a
[`SailFunction`](#sailfunction); calling it locally still invokes the original
function unchanged.
```python theme={null}
@sail.function
def add(x: int, y: int) -> int:
return x + y
value = sb.exec(add, 2, 3, timeout=30)
print(value) # 5
```
When you pass a `SailFunction` to `exec`, the call blocks and returns the
function's return value directly (not a `ExecProcess`). The SDK
serializes the function plus its arguments, runs it with the image's
`python3`, and returns the deserialized result.
**Constraints:**
* Function execution is synchronous; `background=True` is not supported.
* The Sailbox's `python3` must match your local Python major.minor, because
the serialized bytecode is version-sensitive. This is why the `debian`
bases pin the local version.
* Imported third-party packages are referenced by name, so they must exist in
the Sailbox environment.
* Keep arguments and return values small; write large artifacts from inside
the Sailbox and return a small reference instead. The function's complete
encoded response (its serialized return value, captured stdout and stderr,
and any error details, as encoded on the wire) must fit the exec's
`output_buffer_bytes` (1 MiB by default, up to 64 MiB); a larger response
raises `sail.SailboxFunctionSerializationError`. A second call with the same `idempotency_key` while the function runs takes over its output, and the earlier call may then fail to decode its result. `output_mode` must stay `auto`
for a function.
**Raises** `sail.SailboxFunctionError` (with the remote `error_type`,
`traceback`, `stdout`, `stderr` attached) when the function raises remotely,
and `sail.SailboxFunctionSerializationError` if the payload or result cannot
be serialized or the runtime cannot be prepared. See
[Errors](/sailbox-sdk-errors).
### SailFunction
The wrapper returned by `@sail.function`. You normally don't construct it
directly. Calling a `SailFunction` locally is identical to calling the
wrapped function. Async functions, async generators, and generator functions
are rejected at decoration time with `TypeError`.
| Member | Description |
| --------------------------- | ------------------------------------- |
| `func` | The wrapped callable. |
| `__call__(*args, **kwargs)` | Invokes the wrapped function locally. |
# What are Sailboxes?
Source: https://docs.sailresearch.com/sailboxes
Efficient cloud environments for long-horizon agents
This page is a high-level guide to Sailboxes, efficient cloud environments
designed for rollouts and long-horizon agents.
See the [Sailbox reference](/sailbox-sdk) for reference documentation on
Sailboxes.
## What are Sailboxes and why should I use them?
Sailboxes are persistent Linux VMs designed for long-horizon agents. They can be
dynamically provisioned in seconds and are the perfect cloud environment for any
agent.
They provide a number of advantages over other sandboxing providers:
* Cost efficiency: we are both >70% cheaper than other providers and
only charge you for the exact portion of CPU, memory, and disk you
use. Since agents spend most of their time blocked on I/O, we are
significantly more cost-efficient than anyone else.
* Elastic scaling: use as much (or as little) compute as you need, up to your
Sailbox size's CPU, memory, and disk ceilings. Your agent will never OOM, and
because billing follows actual usage, you will never be charged for unused capacity.
* Pause and resume all sandbox state. In-flight work survives the pause
with no save/restore code on your side, and open network connections survive
for up to 10 minutes.
## Provider comparison
| Provider | vCPU | Memory | Sleep during inference | Max runtime | Memory snapshots | Local NVMe disks | Docker-in-Docker | Start/resume time |
| ------------- | ----------------------------- | ---------------------------- | ---------------------- | -------------- | ---------------- | ---------------- | ---------------- | ----------------- |
| Sailboxes | \$0.015 active vCPU·h | \$0.008 active GB·h | Yes | No fixed limit | Yes | Yes | Yes | \<2s |
| Modal | \$0.071 active vCPU·h | \$0.008 reserved\* GB·h | No | 24 hours | Alpha | No | Beta | \<500ms |
| E2B | \$0.0504 reserved vCPU·h | \$0.0162 reserved GB·h | No | 24 hours | Yes | No | Yes | \<1s |
| Vercel | \$0.128 active vCPU·h | \$0.0212 reserved GB·h | No | 5 hours | No | Yes | Yes | \<1s |
| Daytona | \$0.0504 reserved vCPU·h | \$0.0162 reserved GB·h | No | No fixed limit | Experimental | Yes | Yes | \<500ms |
| Cloudflare | \$0.072 active vCPU·h | \$0.009 reserved GB·h | No | No fixed limit | No | Yes | Yes | \<3s |
| AWS AgentCore | \$0.0895 active vCPU·h | \$0.00945 active GB·h | No | 8 hours | No | No | No | 1s |
\*Modal allows bursting beyond reserved capacity, but this is best-effort
(e.g. sandboxes OOM if no memory is available).
## What is the tradeoff?
Sailboxes achieve their efficiency through live migrations: we are able to
achieve much higher utilization on our underlying hardware than other providers
by migrating VMs based on their live resource usage. These migrations only occur
a few times a day, take several seconds, and happen completely without the
knowledge of the agent.
While these migrations mean that the occasional command incurs a few seconds of
additional latency, the tradeoff is massive gains in cost-efficiency. Unlike
human-in-the-loop workflows like chatbots where patience is limited,
long-horizon agents can tolerate the occasional latency blip without issue.
Sailboxes directly trade off this p99 latency for better economics and we
believe this makes them the ideal platform for any long-horizon agent.
## Start here
* [Quickstart](/sailboxes-quickstart): create a Sailbox, run commands, expose a
service, and clean up.
* [Migrate to Sailboxes](/sailboxes-migrating): move an app, an agent, or a
dev environment onto Sailboxes, with your coding agent doing the work.
* [Pricing](/sailboxes-pricing): understand observed-usage billing dimensions and
per-hour rates.
* [Network policy](/sailboxes-network-policy): limit what a Sailbox can reach,
or cut it off from the network entirely.
* [Credential injection](/sailboxes-credentials): let a Sailbox call
authenticated HTTPS APIs without storing credentials inside it.
* [Harbor](/harbor): run Harbor evaluation tasks on Sailboxes with one flag.
# Access Control
Source: https://docs.sailresearch.com/sailboxes-access-control
Who can operate a Sailbox, and who can reach what it serves
There are two things to control: who can operate a Sailbox, and who can reach
the services it exposes.
## Choose who can operate the Sailbox
Every Sailbox has a visibility, fixed for its life:
* **Org** (the default) lets anyone in your organization run commands, copy
files, SSH in, and pause, sleep, checkpoint, or terminate it.
* **Private** restricts all of that to you. Your organization can still see
the Sailbox in listings, but cannot act on it; an org admin can override that
for commands and lifecycle operations by giving a reason, which is recorded
in the audit log. Creating one requires an API
key minted by you, so that Sail knows who the creator is. A service key,
which belongs to the organization rather than to a member, cannot create or
operate one.
## Make a web server public
A Sailbox accepts no inbound traffic until you expose a port. Exposing an HTTP
port gives it a public HTTPS URL that anyone can reach, with TLS handled for
you. Nothing inside the Sailbox needs to know about certificates or hostnames.
In the SDKs, `expose` and `unexpose` add and remove ports on a running
Sailbox: `sb.expose(8080)` in Python, `await sb.expose(8080)` in TypeScript,
`sb.expose(8080, IngressProtocol::Http, &[]).await?` in Rust.
Add or remove ports on a running Sailbox with `sail box expose `
and `sail box unexpose `. `sail box listeners ` shows what is
exposed. HTTP and WebSocket traffic both work, and a sleeping Sailbox wakes
when a request arrives. To serve on your own hostname, see
[Custom Domains](/sailboxes-custom-domains).
## Restrict who can reach it
Pass an allowlist when you expose a port. An entry is an address or range, or
the name of a Sail app, which admits authenticated requests from Sailboxes in
that app. Anything else fails before it reaches the Sailbox.
At create time, pass the same allowlist on the port:
`ingress_ports=[sail.IngressPort(8080, allowlist=[...])]` in Python,
`ingressPorts: [{ guestPort: 8080, protocol: "http", allowlist: [...] }]` in
TypeScript, and the `allowlist` field of `IngressPort` in Rust.
Re-exposing a port replaces its whole allowlist, so the same command tightens
or relaxes access. An app name does not have to exist yet, and names from
other organizations never match.
For a Sailbox to pass an app-name allowlist, its request must carry the
identity headers the SDK provides. Inside the calling Sailbox:
```python theme={null}
import requests
import sail
requests.get(url, headers=sail.ingress_auth_headers())
```
From outside, such as a test driving several Sailboxes, fetch the headers for
a Sailbox you own with `source_sb.ingress_auth_headers()`
(`sourceSb.ingressAuthHeaders()` in TypeScript).
## Raw TCP ports
Expose a port as raw TCP for protocols other than HTTP, such as Postgres or a
custom server. You get a public host and port.
A raw TCP port has no platform-side authentication. Whatever is listening
inside the Sailbox is the only access control, so make sure it requires
credentials.
Raw TCP connections carry no app identity, so a TCP allowlist holds addresses
and ranges only. Exposing a well-known unauthenticated port such as Postgres,
MySQL, or Redis without an allowlist is rejected; pass `--allowlist 0.0.0.0/0 --allowlist ::/0` to confirm you want it open to everyone.
## Connect with a shell
The quickest way into a Sailbox is `sail box shell`. It opens an interactive
terminal over the same channel the CLI uses to run commands.
While the shell is open, servers the Sailbox runs on localhost and links it
opens are forwarded to your machine, so you can develop against it without
exposing anything. Pass `--no-forward` to turn that off.
## SSH access
Reach for SSH when you need a real SSH endpoint rather than a terminal: `scp`
and `rsync`, an editor's remote mode, or port forwarding you control. Enabling
it exposes port 22, which counts against your organization's raw TCP limit.
SSH is organization-scoped. Enabling it exposes port 22, and the Sailbox
trusts your organization's certificate authority, so anyone in the org can
connect with a short-lived certificate for their own key. There are no
per-Sailbox keys to hand out. A private Sailbox is the exception: its SSH
server accepts only its creator's certificates.
The SDK call enables SSH on the Sailbox. To connect from a machine, run
`sail box ssh alias ` there once; the CLI's `enable` does that for you.
# Autosleep
Source: https://docs.sailresearch.com/sailboxes-autosleep
Automatically sleep idle Sailboxes and wake them on demand
Autosleep is optional and on by default. When it is on, Sail sleeps a Sailbox
that has sat idle and wakes it the moment anything needs it. This also lets a
Sailbox sleep while it is blocked waiting on an inference request, and wake
when the reply arrives. You are not
charged while a Sailbox sleeps, and waking takes a couple of seconds. Turn it
off for a Sailbox that must stay up, or change how long it must be idle first.
## What counts as idle
Sail sleeps a Sailbox on its own only when nothing inside would notice. All of
these must hold, and Sail checks them again just before the sleep:
* **No process has used CPU** since the last check. Even slow background work
keeps the Sailbox awake.
* **No process is waiting on a timer.** The one exception is an alarm set for
a wall-clock time, such as a job scheduled for 9:00. Sail lets the Sailbox
sleep and wakes it shortly before the alarm is due, unless the alarm is only
a couple of minutes away, in which case it stays up.
* **No TCP or UDP connection is open** from inside the Sailbox, in either
direction, other than a request that is waiting on its reply.
A Sailbox has to stay in that state for the idle window before Sail sleeps
it. The default window is about five minutes; you can set your own, from one
second to an hour. Sail checks periodically, so the actual sleep can land a
little after the window ends.
## What survives
Sleeping checkpoints the whole machine. Disk, memory, and running processes
come back exactly as they were, and a process that was blocked, waiting for
input or a wall-clock alarm, carries on from where it was.
Any wake can come back cold instead, with the disk intact and nothing
running. Write code that expects a cold start.
## What wakes it
* A request to one of its exposed ports, HTTP or raw TCP. The connection is
held while the Sailbox wakes, then forwarded.
* A command: `sail box exec`, `sail box shell`, a file transfer, or an SSH
connection. Binding an existing Sailbox by id and running a command wakes
it in every SDK, with no explicit resume.
* A wall-clock alarm a process inside set, as above.
* An explicit resume, or a scheduled wake.
## Turn it off, or change the window
`never` keeps the Sailbox running until you sleep, pause, or terminate it
yourself. A number of seconds from 1 through 3600 replaces the default idle
window. `auto` restores the default. The setting can be changed at any point
in a Sailbox's life, and your own `sleep`, `pause`, `resume`, and scheduled
wakes work the same whatever it is.
The window only says when Sail may consider sleeping the Sailbox. It still
has to be idle by the rules above.
## Sleep it yourself
`sleep` checkpoints the Sailbox and powers it down right away; it wakes on the
same triggers as an automatic sleep. `pause` does the same but ignores traffic
and commands: only an explicit `resume` brings it back.
## Wake at a time
Give `sleep` a wake time and Sail restores the Sailbox when it arrives. Use it
for agents and services that sleep between runs and need to be up at a known
moment, such as a daily job or a follow-up an agent scheduled for itself.
A Sailbox holds one scheduled wake. An earlier request replaces it; a later
one leaves the sooner wake in place, and the call returns whichever won.
Calling `sleep` with a time on a Sailbox that is already asleep just updates
the wake. The CLI takes a delay like `30m` or `2h`, or an RFC 3339 timestamp.
The wake can land a little late, so give it a minute or two of headroom.
A paused Sailbox rejects a scheduled wake; only `resume` brings it back.
# Billing Overview
Source: https://docs.sailresearch.com/sailboxes-billing
How Sailbox usage turns into a bill
When you create a Sailbox, you specify the size of the VM. You are eligible to use all the
resources in your VM and you are only charged for observed usage, not capacity. For example, if a Sailbox with a 4 vCPU ceiling
is using 0.01 vCPU, you are charged for 0.01 vCPU. You are not charged at all
while a Sailbox is sleeping, paused, checkpointing, or cold-starting.
## What each size includes
You pick a size at create time: `s`, `m`, or `l`. A size sets the vCPU count
and the default memory and disk ceilings, and it sets the creation charge.
Ceilings cap what a workload may spend; they do not reserve anything, so a
bigger size costs nothing extra while the Sailbox sits idle.
| Size | vCPU | Memory ceiling | Disk ceiling |
| ---- | ---- | ---------------------- | ----------------------- |
| `s` | 1 | 16 GiB (up to 64 GiB) | 32 GiB (up to 128 GiB) |
| `m` | 4 | 32 GiB (up to 128 GiB) | 128 GiB (up to 512 GiB) |
| `l` | 8 | 64 GiB (up to 256 GiB) | 256 GiB (up to 1 TiB) |
Pass `memory_limit_gib` or `disk_limit_gib` at create time to set a ceiling,
within the range shown. The default size is `m`. Choose `s` for the fastest
cold starts and resumes; its lower ceilings also cap what a runaway workload
can consume.
## Where to see usage
* **Dashboard.** The Sailboxes page at [app.sailresearch.com](https://app.sailresearch.com)
shows spend for the period broken down by app and by Sailbox, alongside live
CPU, memory, and disk utilization. The Billing page holds your credit
balance and invoices.
* **CLI.** `sail box list` shows each Sailbox's current CPU, memory, and disk
usage against its ceilings, and `sail box top` is a live view of the same.
## FAQ
Only at creation, and only slightly: the creation charge differs by size.
Ongoing charges follow what the Sailbox uses, so an `l` Sailbox that idles
at 0.05 vCPU costs the same per hour as an `s` doing the same.
Nothing. Sleeping, paused, checkpointing, and cold-starting time is not
billed. Charges resume when the Sailbox is running again.
Yes. A Sailbox started from a checkpoint is a full Sailbox: it pays the
creation charge for its size and bills for its own usage until it sleeps or
you terminate it. Clean up a fleet when the work is done.
Sail pauses your running and sleeping Sailboxes and blocks anything that
would add spend: creating, resuming, and running commands. Nothing is
terminated, and you can still pause and terminate from the dashboard. Add
credits on the Billing page, then resume the Sailboxes you need.
# Credential injection
Source: https://docs.sailresearch.com/sailboxes-credentials
Let a Sailbox use an API key it can never read
Credential injection puts secrets on the network instead of in the Sailbox.
You store an API key with Sail, write a policy that says which HTTPS host gets
it, and attach the policy to a Sailbox. Code inside sends a normal request with
no credential, and Sail adds it on the way out. The Sailbox, and any agent
running on it, can use the API but can never read the key.
## Try it
This stores a secret, injects it as a bearer token on requests to
`httpbin.org`, and asks httpbin to echo the request back:
Save the policy as `demo.json`:
```json demo.json theme={null}
{
"httpbin.org": {
"rules": [
{
"request": {
"set": {
"headers": {
"authorization": "Bearer ${secrets.DEMO_TOKEN}"
}
}
}
}
]
}
}
```
Then store the secret, create and attach the policy, and make a request from
inside the Sailbox:
```json Output theme={null}
{
"headers": {
"Accept": "*/*",
"Authorization": "Bearer me",
"Foo": "bar",
"Host": "httpbin.org",
"User-Agent": "curl/7.88.1"
},
"method": "GET",
"url": "https://httpbin.org/anything"
}
```
The `curl` inside the Sailbox never saw the token. Only the request that
reached httpbin carried it.
The same flow from the SDKs, with a GitHub token:
```python Python theme={null}
import os
import sail
sail.Secret.set("GITHUB_TOKEN", os.environ["GITHUB_TOKEN"])
policy = sail.HttpPolicy.create(
"github",
{
"api.github.com": {
"rules": [
{
"request": {
"set": {
"headers": {
"authorization": "Bearer ${secrets.GITHUB_TOKEN}",
},
},
},
},
],
},
},
)
sailbox.set_http_policy(policy)
```
```typescript TypeScript theme={null}
import { HttpPolicy, Secret } from "@sailresearch/sdk";
await Secret.set("GITHUB_TOKEN", process.env.GITHUB_TOKEN!);
const policy = await HttpPolicy.create("github", {
"api.github.com": {
rules: [
{
request: {
set: {
headers: {
authorization: "Bearer ${secrets.GITHUB_TOKEN}",
},
},
},
},
],
},
});
await sailbox.setHttpPolicy(policy);
```
```rust Rust theme={null}
use sail::Client;
use serde_json::json;
let client = Client::from_env()?;
client
.set_secret("GITHUB_TOKEN", &std::env::var("GITHUB_TOKEN")?)
.await?;
let policy = client
.create_http_policy(
"github",
&json!({
"api.github.com": {
"rules": [{
"request": {
"set": {
"headers": {
"authorization": "Bearer ${secrets.GITHUB_TOKEN}",
},
},
},
}],
},
}),
)
.await?;
sailbox.set_http_policy(&policy).await?;
```
## Secrets
A secret is a named value that belongs to your organization.
```bash theme={null}
sail secret set OPENAI_API_KEY # type the value at a hidden prompt
sail secret set OPENAI_API_KEY --from-env OPENAI_API_KEY
op read "op://vault/openai/key" | sail secret set OPENAI_API_KEY # or pipe it in
sail secret list
sail secret delete OPENAI_API_KEY
```
Setting a name that already exists replaces its value. The next matching
request from any Sailbox whose policy uses it gets the new value. Names start
with a letter or digit and may contain letters, digits, `_`, and `-`, up to
128 characters. A value is one non-empty line of text up to 64 KiB, with no tabs, line
breaks, or other control characters.
## Policies
A policy is a JSON document that belongs to your organization. Each top-level
key is a host, and each host has an ordered list of rules. Sail picks the most
specific host entry for a request, then the first rule under it that matches,
and applies that rule. If nothing matches, the request goes out unchanged.
```json theme={null}
{
"api.example.com": {
"rules": [
{
"match": { "method": "POST", "path": { "prefix": "/v1/" } },
"request": {
"set": {
"headers": { "authorization": "Bearer ${secrets.EXAMPLE_KEY}" }
}
}
},
{
"request": { "set": { "headers": { "x-api-version": "2" } } }
}
]
}
}
```
**Hosts.** Write a bare hostname: no `https://`, port, or path.
`api.example.com` matches exactly that host. `*.example.com` matches any
direct subdomain, and `*` matches everything not named elsewhere. Use an exact
host whenever a rule adds a credential: a wildcard sends the credential to
every host it matches.
**Match.** Leave `match` out to cover every request to the host; such a rule
must be last in its list. Otherwise narrow by `method` (upper case, one or a
list), `path`, `headers`, or `query`. Values accept a plain string for an
exact match, `{"prefix": "..."}`, or `{"one_of": [...]}`; a header or query
condition can also assert `"present": false`.
**What a rule does.**
| Key | What it does |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `request` | `set`, `add`, or `remove` headers and query parameters, or `set` the path. `${secrets.NAME}` works inside `set.headers` and `set.query`. |
| `forward` | Send the request to another HTTPS `host` (and optional `port`). Combine with `request` to add a credential for that host. |
| `respond` | Answer with `status`, `headers`, and `body` without contacting the destination. Cannot be combined with the other two. |
* A `forward` host must be exact. If Sail cannot reach it, the request fails;
it is not sent to the original host instead.
* `respond` stops that HTTP request only. It is not a network access control;
other traffic to the same host may still be possible.
* `request.set.headers` and `request.set.query` are templates: every `$` in
them must be part of `${secrets.NAME}` or `$$` (a literal dollar sign). In
every other string `$` is plain text, except that an unescaped `${` is
rejected because it looks like an unresolved reference.
* The secret a policy names must exist before the policy is created.
* An invalid document fails at create time with an error naming the part to
fix. Sail stores a normalized form, so reading a policy back can return
lowercased hosts and filled-in defaults; behavior is the same.
* A policy's rules cannot change after creation. Create and attach a new one.
Renaming is allowed.
## Attaching
A Sailbox holds at most one policy, and one policy can serve many Sailboxes.
```bash theme={null}
sail box http-policy set # attach, replacing any current policy
sail box http-policy show
sail box http-policy clear
```
A set or clear applies to HTTPS connections the Sailbox opens after the call
succeeds. A connection that is already open keeps the previous policy until it
closes, so close long-lived connections if the change must apply to the next
request.
## Where secrets live
Secrets are stored by Sail and added at the network edge as the request leaves
the Sailbox. Nothing inside the Sailbox ever holds the value, and no Sail API
returns it: `sail secret show` and `sail secret list` print names and
timestamps only.
```bash theme={null}
sail secret show DEMO_TOKEN
```
```text Output theme={null}
name: DEMO_TOKEN
created_at: 2026-09-03T01:55:06.18426Z
updated_at: 2026-09-03T01:55:06.18426Z
```
To remove a secret, clear or replace the policy on every Sailbox that uses it,
delete every policy that names it, then delete the secret. Sail refuses the
other orders. `sail http-policy list` shows how many Sailboxes use each policy
and which secrets it names.
## Limitations
* Policies apply to HTTPS only. Plain HTTP and raw TCP are unchanged.
* A policy applies only when the client announces its HTTP version during the
TLS handshake (ALPN), which almost every client does. For one that does
not, add `"missing_alpn": "http/1.1"` next to `rules` on an exact host entry
to treat its connections as HTTP/1.1.
* Request bodies cannot be matched or changed, and responses cannot be
changed.
# Custom Domains
Source: https://docs.sailresearch.com/sailboxes-custom-domains
Serve a Sailbox HTTP listener on your own domain
Custom domains make it possible to serve an HTTP listener running in a
Sailbox on a hostname you own, in addition to the generated
`https://sb-<...>.sail.box` URL every listener gets.
Sail obtains and renews the TLS certificate for you.
## Add a custom domain
### 1. Expose an HTTP listener for a Sailbox
To expose port 3000 for HTTP traffic from the CLI:
```bash theme={null}
sail box expose $SAILBOX_ID 3000
```
See [Access Control](/sailboxes-access-control) for details on what this command does.
### 2. Point your domain at your target address
Every organization has its own target address under
`sailboxes.sailresearch.com`. Pointing your domain at it is what proves your
organization controls the domain. Find your target with:
```bash Command theme={null}
sail box domain target
# Output:
# CNAME target: 1a2b3c4d5e6f7890.sailboxes.sailresearch.com
# Wildcard certificate target: 1a2b3c4d5e6f7890._acme-challenge.sailboxes.sailresearch.com
```
Create a `CNAME` record with your DNS provider:
```
app.example.com CNAME 1a2b3c4d5e6f7890.sailboxes.sailresearch.com
```
Cloudflare (and some other DNS providers) proxy/accelerate traffic.
Turn that off for this record. On Cloudflare, set the record to "DNS only".
#### Optional: Wildcard records
If you anticipate attaching many subdomains to Sailboxes, you should use wildcard DNS records.
```
*.example.com CNAME 1a2b3c4d5e6f7890.sailboxes.sailresearch.com
# optional, but recommended
_acme-challenge.example.com CNAME 1a2b3c4d5e6f7890._acme-challenge.sailboxes.sailresearch.com
```
* The first record sends every direct subdomain to Sail (`app.example.com` would work, but `hello.app.example.com`
would not)
* The second record lets Sail create one wildcard certificate for them. This is optional: if it is not set, we
issue one certificate per subdomain, which risks hitting certificate issuance rate limits (see [Notes](#notes))
* Attach each hostname to a Sailbox. Do not attach `*.example.com`.
#### Root/apex domains like `example.com`
Standard CNAME records are not allowed for apex domains.
Different DNS providers provide different solutions: look for an `ALIAS`,
`ANAME`, or `CNAME` flattening option.
In addition to an apex record, Sail needs a TXT record for verification. Overall, the setup looks like:
```
# Apex record
example.com .sailboxes.sailresearch.com
# Additional TXT verification record
_sail-domains.example.com TXT .sailboxes.sailresearch.com
```
| DNS provider | Apex option |
| ---------------- | ----------------------------------------------------------------------------------------------------------------- |
| Cloudflare | CNAME, flattened at the apex automatically |
| Namecheap | ALIAS |
| DNSimple | ALIAS |
| DNS Made Easy | ANAME |
| Porkbun | ALIAS |
| Amazon Route 53 | None for external targets (ALIAS records reach AWS resources only). Serve on a subdomain such as `www` instead. |
| Azure DNS | None for external targets (alias records reach Azure resources only). Serve on a subdomain such as `www` instead. |
| Google Cloud DNS | None. Serve on a subdomain such as `www` instead. |
### 3. Add the domain to your Sailbox
Attach the domain to the Sailbox and port. Sail checks that the DNS
record is in place, then starts serving the hostname:
```bash theme={null}
sail box domain attach $SAILBOX_ID app.example.com --port 3000
# Output:
# Attached app.example.com to sb_... on guest port 3000
# https://app.example.com
```
`--port` is required and must identify an exposed HTTP listener.
You can also do this in the [Sailbox dashboard](https://app.sailresearch.com/sailboxes) under "Network Listeners."
## List, remove, and replace domains
```bash theme={null}
# List the domains attached to a Sailbox.
sail box domain list $SAILBOX_ID
# Output:
# DOMAIN GUEST_PORT URL CREATED_AT
# app.example.com 3000 https://app.example.com 2026-08-01T00:00:00Z
# Attach a domain to a different Sailbox (it is automatically detached from its current Sailbox)
sail box domain attach $OTHER_SAILBOX_ID app.example.com --port 3000
# Detach a domain from a Sailbox.
sail box domain detach $OTHER_SAILBOX_ID app.example.com
```
Detaching a domain stops routing it to the Sailbox.
Attaching a domain to a different Sailbox detaches it from the old Sailbox automatically.
## Custom domains for TCP listeners
All the setup listed on this page is for HTTP listeners. If you have a raw TCP endpoint
that you wish to point a custom domain at, you do not need to register the domain with Sail.
Just add a DNS record:
```text theme={null}
foo.example.com CNAME t1.sail.box
```
Then dial `foo.example.com:`.
## Notes
* An organization can attach up to 200 total domains. If your use case needs more than that, [reach out to us](/sailboxes-getting-help).
* The certificate provider we use, [Let's Encrypt](https://letsencrypt.org/docs/rate-limits/), allows 50 new certificates per apex domain every 7 days.
That limit is global for your domain, not specific to Sail. It is highly recommended that you use the wildcard `_acme-challenge` verification listed in
[Optional: Wildcard records](#optional-wildcard-records), and that you
contact us if your use case requires large numbers of subdomains.
* A domain serves one Sailbox listener at a time, but one listener can have multiple domains
(for example, `foo.example.com` and `bar.example.com` can both point at the same listener)
* Removing a listener also detaches all of the domains registered to it. Terminating a Sailbox stops serving its domains, but they stay attached and count toward the domain limit until you detach them or attach them to another Sailbox.
* DNS records prove your organization owns a domain. If that is no longer the case, you should delete these records.
* It takes up to 1 minute for Sail to obtain a valid certificate for your domain. If you make a
request in the first minute after you attach a domain, you may see higher latency.
* All the features of Sailbox networking still work under a custom domain:
* A request to a sleeping
Sailbox wakes it.
* Plain HTTP requests to the domain redirect to HTTPS.
* Listener allowlists keep working.
# Forking
Source: https://docs.sailresearch.com/sailboxes-forking
Copy a Sailbox, or start a fleet of identical ones
Copy an existing Sailbox. The copy gets the original's disk and its memory, so
whatever was running carries on in the copy.
## Usage
Take a checkpoint, then start as many Sailboxes from it as you like:
A sleeping or paused Sailbox can be copied without waking it: `checkpoint`
returns its existing checkpoint. Starting several copies from one checkpoint
reuses the same checkpoint data, so only the first copy pays for it.
## Fan out to many
Set up one Sailbox (install dependencies, warm caches, start servers),
checkpoint it, and start every worker from that checkpoint instead of
repeating the setup in each one. This is the fast path to a fleet for agent
rollouts, parallel test shards, or grading many submissions at once.
```python Python theme={null}
import asyncio
checkpoint = sb.checkpoint()
async def fan_out(n: int) -> list[sail.Sailbox]:
results = await asyncio.gather(
*(
sail.Sailbox.from_checkpoint.aio(
checkpoint.checkpoint_id,
name=f"worker-{i}",
timeout=600,
)
for i in range(n)
),
return_exceptions=True, # keep the copies that came up
)
return [r for r in results if isinstance(r, sail.Sailbox)]
children = asyncio.run(fan_out(32))
```
```typescript TypeScript theme={null}
const checkpoint = await sb.checkpoint();
const results = await Promise.allSettled(
Array.from({ length: 32 }, (_, i) =>
Sailbox.fromCheckpoint({
checkpointId: checkpoint.checkpointId,
name: `worker-${i}`,
timeoutSeconds: 600,
}),
),
);
// Keep the copies that came up even if some restores fail.
const children = results.flatMap((r) =>
r.status === "fulfilled" ? [r.value] : [],
);
```
```rust Rust theme={null}
use std::time::Duration;
use sail::CheckpointOptions;
let checkpoint = sb.checkpoint(CheckpointOptions::default()).await?;
let names: Vec = (0..32).map(|i| format!("worker-{i}")).collect();
let results = futures::future::join_all(names.iter().map(|name| {
client.create_from_checkpoint(
&checkpoint.checkpoint_id,
name.as_str(),
Some(Duration::from_secs(600)),
)
}))
.await;
// Keep the copies that came up even if some restores fail.
let children: Vec = results.into_iter().flatten().collect();
```
Start the copies concurrently, as above, so the restores overlap. Collect
results per copy so one failed restore does not cost you the rest, give each a
distinct name, and pass a timeout so a stuck restore fails that copy instead
of stalling the batch. Each copy is a full Sailbox: it bills like one and runs
until it sleeps or you terminate it, so clean up the fleet when the work is
done.
## What a copy gets
* **The disk and the memory.** Processes the original was running carry on.
A command started with `exec` stops in the copy, though its writes up to the
checkpoint are kept; one started in the background keeps running. Start
anything else the copy needs again.
* **A new identity and new networking.** Open TCP connections are reset, and
the copy inherits no exposed ports; expose the ones it should serve.
## Checkpoints
A checkpoint is a durable snapshot with a name, an id, and an expiry. It lasts
seven days unless you set a TTL; set one when a checkpoint is a template you
will keep using, so it does not expire underneath you. Starting a copy from an
expired checkpoint fails.
Checkpoints also protect the original. Take one after important setup, such
as installing packages or fetching data: if the machine under a Sailbox fails,
Sail restores it from the most recent completed checkpoint and does not replay
commands that ran before it.
# Getting Help
Source: https://docs.sailresearch.com/sailboxes-getting-help
Where to ask questions and report problems with Sailboxes
The fastest way to get help is the Sail community Slack, where you can contact the Sail team directly.
Ask questions, report problems, and share what you are building.
## What to include
When something goes wrong, a message with these details gets a faster answer:
* The Sailbox id, and the app it belongs to.
* What you ran: the SDK call or CLI command, with the language and SDK version.
* What happened: the full error message, or what you expected and what you
saw instead.
* Roughly when it happened, with a timezone.
# HTTPS API
Source: https://docs.sailresearch.com/sailboxes-http-api
Create and operate Sailboxes over plain HTTPS
Sailboxes have a public HTTPS API. The SDKs and the CLI are built on it, and
you can call it directly from any language, or from `curl`. Every endpoint is
listed under [Reference → Sailbox → HTTP API](/api-reference/lifecycle/create-a-sailbox);
this page covers what applies to all of them.
```text theme={null}
https://sailbox-api.sailresearch.com/v1
```
The apps endpoints live at `https://api.sailresearch.com/v1`. Your key works
on both.
## Authentication
Send your API key as a bearer token on every request. Create keys in the
[dashboard](https://app.sailresearch.com); a key belongs to one organization
and only ever sees that organization's Sailboxes.
```bash theme={null}
curl https://sailbox-api.sailresearch.com/v1/whoami \
-H "Authorization: Bearer $SAIL_API_KEY"
```
```json theme={null}
{ "org_id": "org_1a2b3c", "user_id": "user_9z8y" }
```
`user_id` is the member the key belongs to, or `null` for a key that belongs to
the organization rather than a person. Compare it with a Sailbox's
`created_by_user_id` to tell your Sailboxes from a teammate's. A private
Sailbox can only be operated by the user whose key created it; an org admin
can override some operations by sending an `X-Sail-Owner-Override-Reason`
header, which is recorded in the audit log. See
[Access Control](/sailboxes-access-control).
## Example
Every Sailbox belongs to an app. Get an app id, then create a Sailbox in it:
```bash theme={null}
export SAIL_API_KEY="sk_..."
curl -X POST https://api.sailresearch.com/v1/apps/find \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "batch-jobs", "mint_if_missing": true}'
```
```json theme={null}
{
"id": "app_0f6a2c31-8b4d-4e7a-9c15-2d8e6f4a1b03",
"name": "batch-jobs",
"created_at": 1753027200
}
```
```bash theme={null}
curl -X POST https://sailbox-api.sailresearch.com/v1/sailboxes \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Idempotency-Key: create-worker-1-attempt-1" \
-H "Content-Type: application/json" \
-d '{
"app_id": "app_0f6a2c31-8b4d-4e7a-9c15-2d8e6f4a1b03",
"name": "worker-1",
"size": "m",
"image": { "base": "BASE_IMAGE_DEBIAN" }
}'
```
```json theme={null}
{ "sailbox_id": "sb_9c8f1e2a-3b4d-4f5a-8c7e-1d2f3a4b5c6d", "status": "running" }
```
Three things to know about that create:
* **It blocks until the Sailbox is up**, which can take a few minutes while it
waits for a machine. Set a generous client timeout.
* **Read `status`.** A create that is accepted and then cannot bring the
machine up still returns 200, with `status` set to `failed` and
`error_message` saying why.
* **The `Idempotency-Key` makes it safe to retry.** Send the same key and body
again and you get the first answer back instead of a second Sailbox. Use a
fresh key for every Sailbox you mean to create. See
[Retrying safely](#retrying-safely).
Terminate it when you are done:
```bash theme={null}
curl -X POST https://sailbox-api.sailresearch.com/v1/sailboxes/$SAILBOX_ID/terminate \
-H "Authorization: Bearer $SAIL_API_KEY"
```
## Run commands on a Sailbox
Send `command` as a shell string or an argument array. A string supports
`cwd` and `background`; an array runs the program directly.
```bash theme={null}
curl --no-buffer -X POST \
"https://sailbox-api.sailresearch.com/v1/sailboxes/$SAILBOX_ID/exec" \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command":["sh","-c","printf output; printf error >&2"],"timeout":30}'
```
The response streams newline-delimited JSON. Output bytes are base64 so every
byte value is safe in JSON, and the last event carries the exit code:
```jsonl theme={null}
{"type":"started","exec_request_id":"exec_123"}
{"type":"stdout","data":"b3V0cHV0","seq":1}
{"type":"stderr","data":"ZXJyb3I=","seq":1}
{"type":"exit","status":"succeeded","return_code":0}
```
A `heartbeat` event arrives every 30 seconds while the command runs. If the connection drops
before `exit`, call `POST .../exec/$EXEC_ID/wait` with the id from the
`started` event to get the result and a bounded tail of the output. A failure
after `started` ends the stream with an `error` event whose `error_code` is a
lowercase category such as `unavailable` or `permission_denied`.
* To write standard input, set `open_stdin` or `pty` when you start the exec,
then `PUT .../exec/$EXEC_ID/stdin?offset=N&eof=true` with the raw bytes.
Writes carry their byte offset, so an overlapping retry does not duplicate
input; the response reports `accepted_through`.
* To reconnect to a live exec, send the same `idempotency_key` with the
highest `seq` you received for stdout and stderr. Reconnect is best-effort
and does not guarantee exact replay. An exec id that does not fit a URL
segment goes in the `exec_request_id` query parameter with `-` in the path.
* The idempotency key can be up to 256 KiB of UTF-8, trimmed. Environment
variable names match `[A-Za-z_][A-Za-z0-9_]*`, and names and values cannot
contain NUL. The encoded request body can be up to 25 MiB; after decoding,
4 MiB.
* The reference also lists cancel, PTY resize, and PTY resync.
## Move files
Files stream in both directions without being buffered whole.
```bash theme={null}
# Upload
curl -X PUT --data-binary @local.bin \
"https://sailbox-api.sailresearch.com/v1/sailboxes/$SAILBOX_ID/files?path=/workspace/input.bin&mode=420" \
-H "Authorization: Bearer $SAIL_API_KEY" \
-H "Content-Type: application/octet-stream"
# Download
curl --fail-with-body \
"https://sailbox-api.sailresearch.com/v1/sailboxes/$SAILBOX_ID/files?path=/workspace/input.bin" \
-H "Authorization: Bearer $SAIL_API_KEY" \
--output local.bin
```
`mode` is decimal, 0 through 511, and `create_parents` defaults to true. A
complete retry of an upload replaces the file safely; an interrupted one
leaves the target unconfirmed. A download's `X-Sail-File-Mode` header carries
the mode, and `Content-Length` may be absent, so read until the response
ends. For directories, run `mkdir`, `find`, `tar`, and `rm` through the
command endpoint, which is what the SDKs do.
## What needs an SDK
Two things happen outside this API:
* **Building an image** with your own packages or files. Creating a Sailbox
over HTTPS needs an image that is already built: a base image, or one an SDK
built earlier from the same `image` block. Asking for an unbuilt image
returns 409.
* **Turning on SSH** inside a Sailbox for the first time. After that the rest
is HTTPS.
Everything else, from the whole lifecycle to ports, custom domains, secrets,
policies, metrics, and spend, is available over HTTPS. Volumes are in alpha,
so those endpoints can still change.
## Troubleshooting
Failures come back as an HTTP status and one JSON shape:
```json theme={null}
{
"error": {
"message": "app_id is required",
"type": "invalid_request_error",
"param": null,
"code": null
}
}
```
Match on the status and `type`; `message` is for people and can change.
| Status | `type` | What to do |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
| 400 | `invalid_request_error` | Fix the field it names. Custom-domain registration is the exception: it answers 400 until your DNS resolves. |
| 401 | `authentication_error` | The key is missing or invalid. |
| 402 | `billing_error` | Add credits, then retry. |
| 403 | `permission_error` | The key cannot do this, or the Sailbox is private and someone else's. |
| 404 | `not_found_error` | No such Sailbox, volume, or checkpoint in your organization. |
| 409 | `conflict_error` | Conflicts with current state. Some clear on their own, such as a volume a terminating Sailbox still mounts. |
| 413 | `invalid_request_error` | The body is too large. Most endpoints cap at 64 KiB; `POST /sailboxes` and `POST /apps/find` allow 256 MiB. |
| 429 | `rate_limit_error` | With `Retry-After`, the request never ran: wait and retry. Without it, the request ran and hit a limit. |
| 500, 503, 504 | `server_error` | Retry with backoff. A proxy can also return a 502 or 504 with no body; treat those the same. |
## Retrying safely
Every `POST` that creates or changes a Sailbox, a listener, or a volume takes an
`Idempotency-Key` header. Generate one key per logical operation, any unique
string up to 255 bytes, and send it on the first attempt and every retry. A
retry with the same key, method, path, and byte-identical body gets the first
response back, marked `Idempotent-Replayed: true`, instead of running again.
* A key is remembered for at least 24 hours and is scoped to the API key that
sent it.
* Sail remembers 400 and 409 answers too, so after fixing a request send it
under a fresh key. Reusing a key for a different request returns 409.
* If the original is still running when the retry arrives, the retry waits
for it. After 30 seconds it gets a 504; retry again with the same key.
* A 500, 503, or 504 usually means nothing happened and the same key runs the
request again. Creating a Sailbox is the case to watch: the error can arrive
after the Sailbox exists, and a retry can leave you with two. List your
Sailboxes, then continue under a fresh key.
* Terminating a terminated Sailbox, creating a volume that already exists, and
registering a domain the same way twice are safe without a key. Domain
registration ignores the header.
* `GET /sailboxes` pages with `limit` (up to 100) and `offset`; stop when
`has_more` is false. `app`, `status`, and `search` filter, and
`manageable_by_caller=true` hides private Sailboxes you cannot operate.
* Resume returns 200 either way and reports `resume_state`: `running`,
`already_running`, or `terminal_unavailable`, in which case `error_message`
says why and you should create a new Sailbox.
* `status` and `resume_state` are open sets and responses grow new fields.
Match the values you care about and ignore the rest.
* For its first ten minutes a new organization is capped on requests in
flight; over it you get a 429 with `Retry-After`.
# Custom images
Source: https://docs.sailresearch.com/sailboxes-images
Customize what a Sailbox boots with
## Just use the Sailbox
Create a Sailbox and set it up the way you would any Linux machine: copy files
in with `sail box cp`, run commands with `sail box exec`, or
[enable SSH](/sailboxes-access-control) and use `scp` and `rsync`.
When it looks right, checkpoint it. Every Sailbox you start from that
checkpoint boots with the same disk, and the same memory, already in place:
This is the fastest way to get many identical environments, and it needs no
image at all. See [Forking](/sailboxes-forking).
## Use a container image you already have
Point `Sailbox.create` at an image on a public registry. Sail pulls it and
layers what a Sailbox needs on top.
```python Python theme={null}
import sail
image = sail.Image.from_registry("myorg/my-custom-image:latest")
sb = sail.Sailbox.create(app=app, name="custom", image=image)
```
```typescript TypeScript theme={null}
import { Image, Sailbox } from "@sailresearch/sdk";
const image = Image.fromRegistry("myorg/my-custom-image:latest");
const sb = await Sailbox.create({ app, name: "custom", image });
```
```rust Rust theme={null}
use sail::imagebuild::ImageDefinition;
let image = ImageDefinition {
oci_ref: Some("myorg/my-custom-image:latest".to_string()),
..Default::default()
};
```
Write the reference as you would for `docker pull`. The image must be
Debian- or Ubuntu-based and publicly pullable from `docker.io`, `ghcr.io`,
`public.ecr.aws`, or `quay.io`. Private registries are not supported.
### How Sail treats your image
* **`ENV`, `WORKDIR`, and `USER` become the defaults** for every command you
run in the Sailbox. Commands run as the image's `USER` when it sets one and
as root otherwise. Pass `user="0:0"` on a call to run as root anyway.
* **`ENTRYPOINT` and `CMD` are not run.** A Sailbox manages its own
processes; your commands say what to execute.
* **The image keeps its own `python3`.** Sail never installs another Python
over it, because a pinned interpreter would shadow the one the image was
built around.
* **A few paths are Sail's.** The build replaces `/init` and some Sail-owned
files under `/usr/local/bin`, and writes configuration under `/etc/sailbox`
and at `/etc/profile.d/sailbox-env.sh`. Everything else is left alone.
* **The Sailbox runs on the architecture the image was built for.** An image
published for both amd64 and arm64 runs on amd64; pass `architecture` to
require one.
## Build one from a Dockerfile
Already have a Dockerfile? Sail builds it for you.
```python Python theme={null}
image = sail.Image.from_dockerfile("./Dockerfile", context_dir=".")
```
```typescript TypeScript theme={null}
const image = Image.fromDockerfile("./Dockerfile", { contextDir: "." });
```
```rust Rust theme={null}
use std::collections::HashMap;
use sail::imagebuild::{DockerfileInput, DockerfileSource, ImageDefinition};
let image = ImageDefinition {
dockerfile: Some(DockerfileSource {
dockerfile: DockerfileInput::Path("./Dockerfile".into()),
context_dir: Some(".".into()),
build_args: HashMap::new(),
ignore: Vec::new(),
}),
..Default::default()
};
```
`context_dir` is where `COPY` and `ADD` read from, with `.dockerignore`
honored. Every `FROM` and `COPY --from` must name a public image on one of
the registries above, and the result must be Debian- or Ubuntu-based. When a
step fails, the error includes that step's output.
* Pass a path or the Dockerfile text itself (`contents=` in Python,
`{ contents }` in TypeScript, `DockerfileInput::Contents` in Rust).
* The build runs for amd64 unless you pass `architecture`. `build_args`
fill `ARG` instructions like `--build-arg`. Names starting with
`BUILDKIT_` and Docker's proxy variables (`HTTP_PROXY` and friends) are
rejected; a `RUN` step can set a proxy for itself.
* A `Dockerfile.dockerignore` next to the Dockerfile replaces the context's
`.dockerignore`, and `ignore` patterns you pass win over both. Python
snapshots the context when you call `from_dockerfile`; TypeScript and
Rust do it when the image is built. Edits after that point do not reach
the build.
* The context keeps file modes, empty directories, and symlinks. Hard links
arrive as separate files. Setuid, setgid, and sticky bits, named pipes,
device nodes, and mode `000` entries are rejected; sockets are skipped.
* Up to 25 different images per Dockerfile across `FROM` and `COPY --from`.
* Multi-stage builds, `tmpfs` mounts, and `bind` mounts from the context or
another stage work. `RUN --mount` of type `cache`, `secret`, or `ssh`, a
`bind` mount whose `from` names another image, mount options that are
variable references, and `ONBUILD` (in your file or a base image) are
rejected.
* A `# syntax=` line may declare `docker/dockerfile:1` or a release from
1.4 through 1.22.0. Anything else is rejected. The line does not change
how the file is built.
## Build one in code
No Dockerfile? Start from Sail's Debian base and chain the steps you need.
Each step returns a new definition, so one base can serve several variants.
```python Python theme={null}
image = (
sail.Image.debian_amd64
.apt_install("git", "curl")
.pip_install("requests")
.add_local_dir("./app", "/opt/app", ignore=["*.pyc", "__pycache__/"])
.add_local_file("./config.json", "/etc/app/config.json", mode=0o600)
.run_commands("python3 -m pip show requests >/tmp/requests.txt")
.env({"APP_ENV": "production"})
)
```
```typescript TypeScript theme={null}
const image = Image.debian("amd64")
.aptInstall("git", "curl")
.pipInstall("requests")
.addLocalDir("./app", "/opt/app", { ignore: ["*.pyc", "__pycache__/"] })
.addLocalFile("./config.json", "/etc/app/config.json", { mode: 0o600 })
.runCommand("python3 -m pip show requests >/tmp/requests.txt")
.env({ APP_ENV: "production" });
```
```rust Rust theme={null}
use std::collections::HashMap;
use sail::{BaseImage, ImageArchitecture};
use sail::imagebuild::{ImageDefinition, ImageDefinitionStep};
let image = ImageDefinition {
base: Some(BaseImage::Debian),
architecture: ImageArchitecture::Amd64,
env: HashMap::from([("APP_ENV".to_string(), "production".to_string())]),
steps: vec![
ImageDefinitionStep::AptInstall(vec!["git".into(), "curl".into()]),
ImageDefinitionStep::PipInstall(vec!["requests".into()]),
ImageDefinitionStep::AddLocalDir {
local_path: "./app".into(),
remote_path: "/opt/app".into(),
ignore: vec!["*.pyc".into(), "__pycache__/".into()],
ignore_file: None,
},
ImageDefinitionStep::AddLocalFile {
local_path: "./config.json".into(),
remote_path: "/etc/app/config.json".into(),
mode: Some(0o600),
},
ImageDefinitionStep::RunCommand(
"python3 -m pip show requests >/tmp/requests.txt".to_string(),
),
],
..Default::default()
};
```
| Step | What it does |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apt_install(...)` | Installs Debian packages. |
| `pip_install(...)` | Installs Python packages into the image's `python3`. |
| `add_local_file(src, dst, mode=)` | Copies one file. `mode` sets its permissions. |
| `add_local_dir(src, dst, ignore=)` | Copies a directory tree, keeping file modes and skipping symlinks. `ignore` takes gitignore-style patterns or the path of a file like `.gitignore`. |
| `run_commands(...)` | Runs a shell command once, during the build. |
| `env({...})` | Sets environment variables for every command run from the image. |
The same steps chain onto a registry image or a Dockerfile image too. Remote
paths must be absolute and cannot contain a space, `$`, `"`, or `\`. Variable
names must start with a letter or `_` and contain only letters, digits, and
`_`.
`sail.Image.debian_amd64` and `debian_arm64` also install a Python matching
your local interpreter, so that
[`@sail.function`](/sailbox-sdk-images#sail-function) can run your Python
functions inside the Sailbox. Use `sail.Image.debian("amd64",
install_python=False)` to keep the base's stock `python3`.
## Building and caching
Pass an image definition to `Sailbox.create` and Sail uploads any local files,
builds the image if it has not been built before, and starts the Sailbox from
it. `image_build_timeout` (`imageBuildTimeoutSeconds` in TypeScript) bounds
the build, retries included. In Rust the timeout is the duration passed to
`build_image_definition`. To build ahead of time instead, call `build` on the
definition and pass the result to `Sailbox.create`. In Rust,
`build_image_definition` is the ahead-of-time build.
* **Builds are cached by content, per organization.** The same base, steps,
variables, and file contents reuse the existing image.
* **Tags are pinned, per organization.** The first build from a tag such as
`python:3.13`, or from a `FROM` line, pins the version the tag pointed at,
and later builds keep it even after the tag moves upstream. Pass
`force_build` (`forceBuild` in TypeScript, `BuildMode::ForceBuild` in Rust)
to look the tag up again. That moves the pin for the whole organization;
Sailboxes that already exist keep the version they started on. A digest
(`name@sha256:...`) never moves.
* **The first build of a large image downloads all of it.** Later builds
usually reuse its layers.
After a build, and periodically while an image is in use, Sail boots it
outside any Sailbox to capture a start snapshot. Sailboxes created from the
image resume from that snapshot instead of cold-booting, which keeps starts
fast at every size.
This makes two things part of the image contract:
* **Boot-time initialization runs during every hidden boot.** systemd units
and init scripts must be safe to run repeatedly, outside any Sailbox. Your
entrypoint and the commands you run in a Sailbox never run during a hidden
boot.
* **State written at boot is shared.** Whatever boot leaves on disk or in
memory is in the snapshot every Sailbox resumes from. Generate per-instance
identity (machine IDs, nonces, cached credentials) at runtime, for example
in your application's entrypoint, not at boot. Environment variables,
networking, and Sail-managed credentials are applied per Sailbox after the
resume, so they behave the same either way.
# Migrate to Sailboxes
Source: https://docs.sailresearch.com/sailboxes-migrating
Move an app, an agent, or a dev environment onto Sailboxes
Sailboxes are just Linux VMs. Anything that runs on a Linux machine runs in a
Sailbox, so most migrations are a matter of getting your code and your data
onto one and exposing the ports you need.
## Let your agent do it
The fastest way to migrate is to hand the job to your coding agent. Give it
three things:
1. **A way in.** Install the [Sail CLI](/reference/cli) on the machine the
agent runs on and sign in with `sail auth login`. The agent can then create
Sailboxes, run commands with `sail box exec`, and copy files with
`sail box cp`. If the agent prefers SSH, run `sail box ssh enable ` and
it can use `ssh .sail`, `scp`, and `rsync` as usual.
2. **The docs.** Point it at
[https://docs.sailresearch.com/llms.txt](https://docs.sailresearch.com/llms.txt),
or connect it to the docs MCP server at `https://docs.sailresearch.com/mcp`.
See the [AI Quickstart](/ai-quickstart) for the setup in Claude Code,
Codex, Cursor, and other tools.
3. **A goal.** Say what should be true when it is done.
A prompt like this is enough to start:
```text theme={null}
Migrate this project to run in a Sailbox on Sail (https://sailresearch.com).
Read https://docs.sailresearch.com/llms.txt first. Use the sail CLI to create
a Sailbox, get the code and dependencies onto it, run the app, and expose the
port it listens on. Report the public URL and anything you could not move.
```
Using Claude Code or Codex? Install the [Sail skills](/ai-quickstart) and ask
your agent to "Migrate this app to Sail". The `sail-migrate` skill walks
through the whole migration, including moving sandboxed execution onto
Sailboxes.
## Migrating a web app
Expose the port your app listens on when you create the Sailbox, and Sail gives
it a public HTTPS URL. Nothing inside the Sailbox needs to know about TLS or
hostnames.
If your app is containerized, `docker` and `docker compose` work inside a
Sailbox. To bake your container into the Sailbox itself instead of running
Docker inside it, build the Dockerfile into a Sailbox image with
[`Image.from_dockerfile`](/sailboxes-images#build-one-from-a-dockerfile). The
Sailbox then boots straight into your environment.
To serve the app on your own hostname, see [Custom Domains](/sailboxes-custom-domains).
## Migrating a dev environment
Enable SSH on the Sailbox and use it like any remote machine: your editor's
remote mode, `scp`, `rsync`, and port forwarding all work.
SSH is organization-scoped. Anyone in your org can connect to an org-visible
Sailbox with a short-lived certificate for their own key, so there are no
per-machine keys to hand out. Create the Sailbox with `--visibility private`
when only you should be able to reach it.
For a quick shell without SSH, `sail box shell` opens a terminal over the same
channel the CLI uses for commands and needs no open port.
## Things that work differently
* **You pay for what the Sailbox uses, not what it could use.** Billing follows
actual CPU, memory, and disk usage. An idle Sailbox sleeps on its own and
wakes the moment something needs it, with its processes and memory intact. See [Autosleep](/sailboxes-autosleep) and
[Billing](/sailboxes-billing). There is no need to tear environments down to
save money.
* **Secrets can stay outside the Sailbox.** Instead of copying API keys into
the environment, [Credential injection](/sailboxes-credentials) adds them to
outbound HTTPS requests on the way out, so code running in the Sailbox never
sees them.
* **A working environment can be cloned.** Once the migration is done, take a
checkpoint and start as many copies as you need from it. See
[Forking](/sailboxes-forking).
* **Sailboxes are persistent.** There is no fixed runtime limit. A Sailbox keeps
its disk for its whole life, so long-running agents and stateful services do
not need to be rebuilt between sessions.
Stuck on something? See [Getting Help](/sailboxes-getting-help).
# Network policy
Source: https://docs.sailresearch.com/sailboxes-network-policy
Choose what a Sailbox can reach: the internet, nothing, or only the destinations you list
A network policy controls the connections a Sailbox opens. It is one of:
* **Public**, the default. The Sailbox can reach the internet.
* **No network.** The Sailbox cannot reach anything, and nothing can reach it.
* **Allowlist.** The Sailbox can reach only the destinations you list.
The policy is chosen when the Sailbox is created and lasts for its whole life.
A Sailbox created from a checkpoint keeps the policy of the one it came from,
and a Sailbox that already exists keeps the policy it was created with.
Whatever its policy, a Sailbox reaches the internet over TCP and IPv4 only.
Running commands is unaffected by any policy: `exec` and the shell reach the
Sailbox over a Sail-internal path, not its network. Platform features the
Sailbox was created with, such as a mounted volume, reach their storage the
same way and keep working.
For the HTTPS requests a policy allows, [HTTP policies](/sailboxes-credentials#policies)
can change or forward them, and [credential injection](/sailboxes-credentials)
can add a credential. Connections into a Sailbox are covered in
[Networking](/sailboxes-networking).
## No network
A no-network Sailbox is cut off from other hosts and the internet:
* It cannot open outbound connections, and name resolution does not work.
* It cannot expose inbound services. Exposing a port or enabling SSH is
rejected.
```python Python theme={null}
import sail
app = sail.App.find(name="web-demo", mint_if_missing=True)
box = sail.Sailbox.create(
app=app,
name="offline-job",
network_policy=sail.NetworkPolicy.NO_NETWORK,
)
```
```typescript TypeScript theme={null}
import { App, Sailbox } from "@sailresearch/sdk";
const app = await App.find("web-demo", { mintIfMissing: true });
const box = await Sailbox.create({
app,
name: "offline-job",
networkPolicy: "no_network",
});
```
```rust Rust theme={null}
use sail::{CreateSailboxRequest, NetworkPolicy};
let app = client.find_app("web-demo", /* mint_if_missing */ true).await?;
let sb = client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id,
name: "offline-job".into(),
network_policy: NetworkPolicy::NoNetwork,
..Default::default()
},
/* timeout */ None,
)
.await?;
```
## Allowlist
An allowlist lets a Sailbox reach some destinations but not the rest of the
internet:
```python Python theme={null}
import sail
app = sail.App.find(name="web-demo", mint_if_missing=True)
box = sail.Sailbox.create(
app=app,
name="limited-job",
network_policy=sail.NetworkAllowlist(
[
"api.example.com",
"*.internal.example.com",
"203.0.113.0/24",
]
),
)
```
```typescript TypeScript theme={null}
import { App, Sailbox } from "@sailresearch/sdk";
const app = await App.find("web-demo", { mintIfMissing: true });
const box = await Sailbox.create({
app,
name: "limited-job",
networkPolicy: {
mode: "allowlist",
allowedHosts: [
"api.example.com",
"*.internal.example.com",
"203.0.113.0/24",
],
},
});
```
```rust Rust theme={null}
use sail::{CreateSailboxRequest, NetworkPolicy};
let app = client.find_app("web-demo", /* mint_if_missing */ true).await?;
let sb = client
.create_sailbox(
&CreateSailboxRequest {
app_id: app.id,
name: "limited-job".into(),
network_policy: NetworkPolicy::Allowlist(vec![
"api.example.com".into(),
"*.internal.example.com".into(),
"203.0.113.0/24".into(),
]),
..Default::default()
},
/* timeout */ None,
)
.await?;
```
### Entries
Each entry is one of:
* A hostname, such as `api.example.com`.
* A `*.` wildcard hostname. `*.example.com` matches `api.example.com`. It does
not match `example.com` or `a.b.example.com`.
* An IPv4 address, such as `203.0.113.7`.
* An IPv4 range in CIDR form, such as `203.0.113.0/24`. The range must start
at its first address (`1.2.3.0/24`, not `1.2.3.5/24`).
The list follows these rules:
* At least one entry, and at most 128.
* An entry names a destination, not a port. An allowed destination is
reachable on every port, and an entry with a port is rejected.
* IPv6 entries are rejected, because a Sailbox does not reach the internet
over IPv6.
* An address no Sailbox could ever reach, such as `127.0.0.1` or
`169.254.0.0/16`, is rejected.
* A private range such as `10.0.0.0/8` is accepted but allows nothing,
because a Sailbox cannot reach private addresses.
* A list that breaks these rules fails the create call with
`InvalidArgumentError` (`InvalidArgument` in Rust) before a Sailbox is
created.
* Entries are stored lowercased, without a trailing dot, and without
duplicates. `get` returns that stored list.
### What an entry allows
An allowlist limits only the connections the Sailbox opens. Connections into
it are unaffected, so an allowlist Sailbox can still expose ports and enable
SSH.
| To reach | List |
| ------------------------------------------------------------------- | --------------------------------------------------------------- |
| A web site or HTTP API over HTTPS or plain HTTP/1, by name | Its hostname, or a wildcard that matches it |
| Any other server the connection opens with a TLS handshake, by name | Its hostname, or a wildcard that matches it |
| An SSH server, a database, or any other server, by name | Its IP address or range, plus its hostname so the name resolves |
| Any server, by IP address | Its IP address or range |
A hostname entry allows only a connection whose first bytes announce the name:
a plain HTTP/1 request, or a TLS handshake that carries the server name. A
connection to a listed host that starts any other way, such as SSH or a
database connection, is closed. List the server's address or range for those.
Name resolution follows the list. An allowlist Sailbox can resolve only names
that a hostname or wildcard entry covers; any other lookup fails immediately,
so a list of only addresses and ranges resolves nothing.
An [HTTP policy](/sailboxes-credentials#policies) that forwards a request to another
host is checked against the allowlist too. The forward is allowed when a
hostname or wildcard entry covers that host, or an address or range entry
covers the address it resolves to.
### How Sail checks a name
When a hostname entry allows a connection, Sail resolves that name itself and
connects to the result, not to an address the Sailbox chose, so an override in
the Sailbox's `/etc/hosts` does not redirect the connection. Sail checks the
name a connection announces, not what the server does with it. If an allowed
server also serves other sites, as a shared CDN does, a request can reach those
sites through it.
### Blocked connections
A connection the list does not allow is not refused when it opens. It opens,
and Sail then closes it, so a program sees the failure on its first read or
write rather than as a refused connection.
## Limitations
An address or range entry allows a connection from its destination address
alone, before Sail reads anything from it. These limits apply to a connection
that depends on a hostname or wildcard entry instead.
* A hostname entry covers only a connection whose first bytes name the
server: a plain HTTP/1 request with a `Host` header, or a TLS handshake that
carries the server name. A protocol that switches to TLS after it starts
(STARTTLS), plaintext HTTP/2, and a TLS client that omits the server name or
hides it with Encrypted Client Hello (ECH) are closed.
* Sail reads at most the first 8 KiB of a plain HTTP request to find the
`Host` header. A request whose headers run past that is closed even when
`Host` came first.
* Sail waits about five seconds for a connection to announce its destination.
A connection that sends nothing in that time is closed, including a client
that waits for the server to speak first.
# Sailbox Pricing
Source: https://docs.sailresearch.com/sailboxes-pricing
Observed usage billing dimensions and rates for Sailboxes
Sailboxes bill for observed usage: the CPU, memory, and disk a Sailbox
actually uses while running, plus a one-time charge when it is created. How
that works, what each size includes, and where to see your spend are on the
[Billing overview](/sailboxes-billing). These are the rates:
| Dimension | Price |
| ------------------------------------------------------------------------------------- | -------- |
| Used vCPU/hour | \$0.015 |
| Used RAM (GiB)/hour | \$0.008 |
| Used NVMe disk (GiB)/hour | \$0.0007 |
| S Sailbox creation | \$0.005 |
| M Sailbox creation | \$0.01 |
| L Sailbox creation | \$0.012 |
Usage accrues only while a Sailbox is running. Sleeping, paused,
checkpointing, and cold-starting time is not sampled and not billed. Usage is
sampled about every 15 seconds.
# Tinker
Source: https://docs.sailresearch.com/sdk-tinker
Drive Sail inference from Tinker loops with sail.SailTokenCompleter, and run rollout commands on Sailboxes with sail.TinkerSandbox
The `sail.tinker` helpers let you drive Sail inference from a
[Tinker](https://tinker-docs.thinkingmachines.ai/) RL or training loop. They
bridge Tinker's token-level sampling interface to Sail's raw-token Responses
path, so rollouts run against Sail-hosted models (optionally with a LoRA
adapter) while logprobs flow back into your training code.
These helpers require `tinker-cookbook` installed alongside `sail`.
Constructing a `SailTokenCompleter` without `tinker-cookbook` available raises
[`sail.InferenceError`](/voyages-sdk-errors).
## sail.SailTokenCompleter
A Tinker `TokenCompleter` backed by Sail's raw-token Responses API: each call
sends the prompt token ids via the `raw_prompt_tokens` request parameter, which
skips server-side chat templating and tokenization and forwards the ids
verbatim to the model. Construct one with a model and sampling settings, then
`await` it on tokenized prompts to get sampled tokens and their logprobs.
```python theme={null}
import asyncio
import sail
from tinker import types
async def main() -> None:
completer = sail.SailTokenCompleter(
model="moonshotai/Kimi-K2.6",
max_tokens=256,
temperature=0.7,
completion_window="balanced",
)
# Prompt token ids from your tokenizer, wrapped in Tinker's ModelInput.
model_input = types.ModelInput.from_ints(tokens=[9906, 11, 1917, 0])
result = await completer(model_input)
print(result.tokens) # list[int] of sampled token ids
print(result.maybe_logprobs) # list[float] | None
print(result.stop_reason) # e.g. "stop", "length"
asyncio.run(main())
```
### Constructor
```python theme={null}
SailTokenCompleter(
*,
model: str,
max_tokens: int,
temperature: float = 1.0,
top_p: float = 1.0,
completion_window: str = "balanced",
lora: str | None = None,
tinker_lora_signed_url: str | None = None,
adapter_config: Mapping[str, Any] | str | None = None,
tinker_lora_name: str | None = None,
metadata: Mapping[str, str] | None = None,
timeout: float | None = None,
poll_timeout: float | None = 1200.0,
voyage: sail.Voyage | None = None,
request_logprobs: bool = True,
)
```
| Parameter | Description |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `model` | **Required.** Model id to sample from. Raises `ValueError` if empty. |
| `max_tokens` | **Required.** Max tokens to generate; must be `> 0` (`ValueError` otherwise). |
| `temperature` | Sampling temperature. Default `1.0`. |
| `top_p` | Nucleus sampling cutoff. Default `1.0`. |
| `completion_window` | Completion window for each request. Default `"balanced"`. K2.6 LoRA requests use `balanced` by default; `flex` is available for background work and `priority` remains accepted for existing clients. `"asap"` is not available for LoRA requests. |
| `lora` | Name of a Sail-registered LoRA adapter to apply. Mutually exclusive with `tinker_lora_signed_url`. |
| `tinker_lora_signed_url` | Signed URL to a Tinker checkpoint archive (see [`get_tinker_checkpoint_signed_url_async`](#get-tinker-checkpoint-signed-url-async)). Requires `adapter_config`. Mutually exclusive with `lora`. |
| `adapter_config` | LoRA adapter config as a mapping or JSON string. **Required** when `tinker_lora_signed_url` is set. |
| `tinker_lora_name` | Optional human-readable name attached to the Tinker LoRA. |
| `metadata` | Extra string metadata forwarded on each request. |
| `timeout` | Per-request timeout in seconds. |
| `poll_timeout` | Seconds to wait for a sampled result before giving up. Default `1200` (20 minutes); `None` waits indefinitely. |
| `voyage` | A [`sail.Voyage`](/voyages-sdk) to attribute requests to a voyage. |
| `request_logprobs` | Whether to request logprobs from the server. Default `True`. |
Passing both `lora` and `tinker_lora_signed_url`, or setting
`tinker_lora_signed_url` without `adapter_config`, raises `ValueError`.
### `async __call__(model_input, stop=None)`
```python theme={null}
async def __call__(model_input, stop=None) -> TokensWithLogprobs
```
* `model_input`: must expose a callable `.to_ints()` returning the prompt
token ids (this is Tinker's `ModelInput`). A non-callable `to_ints`, a
non-integer token, or an empty prompt raises `TypeError`/`ValueError`.
* `stop`: optional stop condition. An `int` is wrapped as a single-element
list; a tuple is converted to a list; other values pass through unchanged.
Returns a Tinker `TokensWithLogprobs`:
| Field | Description |
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
| `tokens` | `list[int]`: the sampled token ids. |
| `maybe_logprobs` | `list[float]` or `None`: per-token logprobs, or `None` when the response carried none. |
| `stop_reason` | The model's stop reason (e.g. `"stop"`, `"length"`), falling back to the response `status` or `"unknown"`. |
If the Sail response is malformed (missing or non-integer token data, or
mismatched token and logprob lengths), a
[`sail.InferenceError`](/voyages-sdk-errors) is raised with the offending
response attached as `exc.response`.
## get\_tinker\_checkpoint\_signed\_url\_async
```python theme={null}
await get_tinker_checkpoint_signed_url_async(
service_client,
tinker_path: str,
*,
ttl_seconds: int | None = None,
) -> str
```
Resolves a Tinker checkpoint path to a signed archive URL, suitable for passing
as `tinker_lora_signed_url` to `SailTokenCompleter`. It resolves the path against
the Tinker service client and returns the signed URL.
This helper is async-only and requires a Tinker service client with async
checkpoint-URL support.
| Parameter | Description |
| ---------------- | ------------------------------------------------------------------------- |
| `service_client` | A Tinker `ServiceClient` (exposes `create_rest_client()`). |
| `tinker_path` | The Tinker checkpoint path to resolve. |
| `ttl_seconds` | Optional checkpoint TTL to set or extend before resolving the signed URL. |
Raises [`sail.InferenceError`](/voyages-sdk-errors) if the Tinker client does
not provide async checkpoint URL methods, or if the response does not contain a
URL.
```python theme={null}
import sail
signed_url = await sail.get_tinker_checkpoint_signed_url_async(
service_client,
tinker_path,
ttl_seconds=3600,
)
completer = sail.SailTokenCompleter(
model="moonshotai/Kimi-K2.6",
max_tokens=256,
completion_window="balanced",
tinker_lora_signed_url=signed_url,
adapter_config={"r": 16, "alpha": 32},
)
```
## sail.TinkerSandbox
Runs tinker-cookbook sandbox workloads on a Sailbox. It implements the
cookbook's sandbox interface, so recipes that take a sandbox execute their
rollout commands in an isolated Sailbox instead of on the training machine.
Each instance wraps one Sailbox for its whole life.
```python theme={null}
import sail
sandbox = sail.TinkerSandbox(sailbox, timeout_seconds=1800)
result = await sandbox.run_command("python solve.py", workdir="/task")
print(result.exit_code, result.stdout)
await sandbox.cleanup() # terminates the Sailbox
```
### Constructor
```python theme={null}
TinkerSandbox(
sailbox: Sailbox,
*,
timeout_seconds: float | None = None,
)
```
| Parameter | Description |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sailbox` | **Required.** The Sailbox the sandbox runs in, normally created by `tinker_sandbox_factory`. The sandbox owns it: `cleanup` and lifetime expiry terminate it. Give each sandbox its own Sailbox; two sandboxes sharing one would share its filesystem and processes, and the first cleanup would terminate it for both. |
| `timeout_seconds` | The sandbox's lifetime budget. Once it has elapsed, the next operation terminates the Sailbox and raises the cookbook's `SandboxTerminatedError`. Default no budget. |
Requires tinker-cookbook; constructing without it installed raises
`ImportError`.
### `async run_command(command, workdir=None, timeout=60, max_output_bytes=None)`
Runs a shell command and returns the cookbook's `SandboxResult`.
* `command`: the shell command string, run through bash.
* `workdir`: directory to run in.
* `timeout`: seconds before the command is killed; a killed command is
reported with `metrics["timed_out"]` set.
* `max_output_bytes`: keeps the first bytes of each output stream, the part
the cookbook's parsers read. Without it the full output is kept, up to a
large safety ceiling.
A terminated or lost Sailbox raises the cookbook's
`SandboxTerminatedError`; other errors from the Sailbox are reported as a
result with exit code `-1`.
### `async read_file(path, max_bytes=None, timeout=60)`
Reads a file into the result's `stdout`, within `timeout` seconds; without
`max_bytes` the whole file is kept, up to a large safety ceiling. A
missing or unreadable file is reported as a result with exit code `1`
rather than raised, and so is a read that runs out of time.
### `async write_file(path, content, executable=False, timeout=60)`
Writes a file into the Sailbox, within `timeout` seconds; `executable=True`
marks it executable. A write that runs out of time is reported as a result
with exit code `1`.
### `async send_heartbeat(timeout=30)`
Checks the sandbox's lifetime budget. A Sailbox stays alive without
keep-alives, so the heartbeat sends nothing; it only terminates the Sailbox
and raises the cookbook's `SandboxTerminatedError` once `timeout_seconds`
has elapsed. `timeout` is part of the cookbook's heartbeat signature and is
unused here, since there is no request for it to bound.
### `async cleanup()`
Terminates the backing Sailbox. Safe to call more than once.
## tinker\_sandbox\_factory
```python theme={null}
await tinker_sandbox_factory(
env_dir: str | Path,
timeout_seconds: float | None = None,
*,
app: str | None = None,
size: str = "s",
image_ref: str | None = None,
name_prefix: str = "tinker",
) -> TinkerSandbox
```
Creates a `TinkerSandbox` for a tinker-cookbook environment. Pass this
function, or a `functools.partial` of it to preset the keyword arguments,
wherever the cookbook accepts a sandbox factory. Being a module-level
function, it pickles by reference, so it survives the cookbook's process
boundaries.
| Parameter | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `env_dir` | **Required.** The environment directory. Sail uses its parent's `[environment].docker_image` when present; otherwise it builds `env_dir / "Dockerfile"` with `env_dir` as the build context. |
| `timeout_seconds` | The sandbox's lifetime budget, measured from when the sandbox exists. |
| `app` | The [app](/sailbox-sdk-apps) that groups the created Sailboxes. Default `$SAIL_APP` or `"tinker"`, created on first use. |
| `size` | Sailbox size (`"s"`, `"m"`, or `"l"`; see [pricing](/sailboxes-pricing)). Default `"s"`. |
| `image_ref` | Registry reference overriding the `task.toml` image. Docker-style short references work (`python:3.11`). |
| `name_prefix` | Prefix for the Sailbox's name. Default `"tinker"`. |
The registry image or Dockerfile must produce a Debian- or Ubuntu-based filesystem.
Raises `ImportError` without tinker-cookbook installed, and a `ValueError`
when `image_ref`, the `task.toml` image, and the environment's `Dockerfile` are all absent.
# Security & compliance
Source: https://docs.sailresearch.com/security
Sail's SOC 2, HIPAA, data processing, privacy, and enterprise security commitments.
## SOC 2 Type I
Sail is SOC 2 Type I compliant. You can request the independent auditor report through our [Trust Center](https://trust.sailresearch.com/).
## SOC 2 Type II
Sail's SOC 2 Type II compliance takes effect August 25, 2026.
## HIPAA and BAAs
Enterprise customers can sign a business associate agreement (BAA) with Sail for HIPAA-regulated workloads.
## Master services agreements
Enterprise contracts can include a signed master services agreement (MSA).
## Data processing
Retention depends on the Sail product and the data involved. The inference retention policy below does not apply to Sailbox state and checkpoints or to security and audit records.
### Inference: Zero Data Retention by default
Sail defaults to zero long-term retention of inference request and response data:
* We use inference request and response data only to provide the services you request and follow your documented instructions.
* We do not use inference request or response data to train, fine-tune, or improve models without your written consent.
* We do not sell or share customer personal data from inference requests or responses.
* Inference processing is transient and in memory, apart from temporary storage needed to run a job.
* We automatically delete temporary inference data after processing and do not retain it for longer than 48 hours, subject to the exceptions in the DPA.
* Our public DPA forms part of the self-service terms and written customer agreements.
Read our [Data Processing Agreement](/dpa) for the complete terms.
### Sailboxes
Sailboxes are stateful by design:
* A Sailbox's writable disk persists for the life of the Sailbox, including across pause, sleep, resume, migration, and recovery.
* Temporary data used to migrate a Sailbox is retained for no more than 24 hours.
* An explicit checkpoint handle expires after seven days by default, or after the TTL set when the checkpoint is created. Expiry controls how long the handle can start a new Sailbox; it does not set a deletion deadline for data still used by a Sailbox.
See [Sailbox lifecycle](/sailboxes-lifecycle) for checkpoint, pause, sleep, resume, and termination behavior.
## Data residency and regional processing
By default, Sail uses service providers in multiple regions, so customer data may be processed or stored outside the United States. Enterprise customers can pin traffic to a specific geographic region; contact [support@sailresearch.com](mailto:support@sailresearch.com) to confirm the processing and storage locations available for your workload.
## Signed DPAs
Enterprise customers can sign a DPA with Sail. Self-service customers are covered by our public [Data Processing Agreement](/dpa).
## Security controls
Our published controls include:
* Encryption of customer data at rest and in transit
* Multi-factor authentication for critical services and regular employee access reviews
* Automated infrastructure security scanning and vulnerability management
* Audit logging, monitoring, and an incident response process
* Tested business continuity and disaster recovery plans
## Trust Center
Visit Sail's [Trust Center](https://trust.sailresearch.com/) to review our security controls, request compliance documents, and see our current subprocessors.
## Support
Contact [support@sailresearch.com](mailto:support@sailresearch.com) with any questions.
# Supercache
Source: https://docs.sailresearch.com/supercache
Store reusable prompt prefixes for ultra low-cost reads
Supercache lets you reuse a prompt prefix for 24 hours. Write it once, then get
ultra low-cost reads for later requests with the same prefix.
Supercache is available with the Responses API and Chat Completions API.
## Pricing
Supercache uses separate read and write prices:
| Token group | Price |
| ---------------- | ------------------------------------- |
| Supercache read | 10% of the regular cached-input price |
| Supercache write | 100 times the normal input price |
Apply these multipliers to the cached-input and input prices on the
[Pricing](/pricing) page.
If regular cache and Supercache contain the same token, Sail uses the lower
Supercache read price.
Supercache writes have a high one-time cost. Use them for large prefixes that
you expect to read many times during the next 24 hours.
## Write a prefix
Set `metadata.supercache_write` to `"24h"`. This is the only accepted value.
Replace the placeholder below with at least 1,025 tokens of reusable content.
```bash Responses API theme={null}
curl -X POST https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer YOUR_SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "\n\nThe first question.",
"background": true,
"max_output_tokens": 16,
"metadata": {
"completion_window": "balanced",
"supercache_write": "24h"
}
}'
```
```bash Chat Completions API theme={null}
curl -X POST https://api.sailresearch.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"messages": [
{"role": "system", "content": ""},
{"role": "user", "content": "The first question."}
],
"max_completion_tokens": 16,
"metadata": {
"completion_window": "balanced",
"supercache_write": "24h"
}
}'
```
An explicit write takes priority over a read. If the prefix is already stored,
the request writes it again and starts a new 24-hour lifetime.
## Read a prefix
Sail automatically uses the longest matching stored prefix. Replace the
placeholder with the exact reusable content from the write request.
```bash Responses API theme={null}
curl -X POST https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer YOUR_SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "\n\nA different question.",
"background": true,
"max_output_tokens": 16,
"metadata": {
"completion_window": "balanced"
}
}'
```
```bash Chat Completions API theme={null}
curl -X POST https://api.sailresearch.com/v1/chat/completions \
-H "Authorization: Bearer YOUR_SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"messages": [
{"role": "system", "content": ""},
{"role": "user", "content": "A different question."}
],
"max_completion_tokens": 16,
"metadata": {
"completion_window": "balanced"
}
}'
```
A read does not extend the 24-hour lifetime. Only another explicit write starts
a new 24-hour lifetime.
## Usage fields
Completed Responses include two decimal-string metadata fields:
```json theme={null}
{
"usage": {
"input_tokens": 4609,
"input_tokens_details": {
"cached_tokens": 3072
}
},
"metadata": {
"supercached_input_tokens": "2048",
"supercache_write_input_tokens": "0"
}
}
```
* `metadata.supercached_input_tokens` is the number of tokens read from
Supercache.
* `metadata.supercache_write_input_tokens` is the number of tokens written to
Supercache.
* `usage.input_tokens_details.cached_tokens` includes both regular cached input
and Supercache reads.
In this example, 2,048 tokens came from Supercache and 1,024 tokens came only
from regular cache.
Chat Completions reports the aggregate cached count in
`usage.prompt_tokens_details.cached_tokens`. It does not include the separate
Supercache read and write counts.
# API support matrix
Source: https://docs.sailresearch.com/support
What each Sail inference API supports today, and what's coming soon
Sail provides inference endpoints compatible with the [OpenAI Responses API](https://developers.openai.com/api/reference/resources/responses/methods/create), the [OpenAI Chat Completions API](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create), and the [Anthropic Messages API](https://platform.claude.com/docs/en/api/messages/create).
All three inference APIs accept the same [models](/models) and [completion windows](/completion-windows).
Additionally, Sail offers a [Batch API](#batch-api) for running large numbers of [Responses API](#responses-api) requests efficiently in a single asynchronous job.
| API | Endpoint | Maturity |
| ----------------------- | --------------------------- | --------------------- |
| OpenAI Responses | `POST /v1/responses` | Stable |
| OpenAI Chat Completions | `POST /v1/chat/completions` | Stable |
| Anthropic Messages | `POST /v1/messages` | Beta |
| Batch | `POST /v1/batches` | Stable |
***
## Responses API
### Supported features
| Feature | Details |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Core parameters** | `model`, `input` (string or message array), `max_output_tokens`, `temperature`, `top_p`, `user`, `prompt_cache_key` |
| **Instructions** | `instructions` is prepended to the input as a system message. |
| **Structured outputs** | `text.format` with `type: "text"` or `type: "json_schema"` |
| **Reasoning** | `reasoning.effort` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`; `max` selects the same top tier as `xhigh`), `reasoning.generate_summary` (`auto` / `concise` / `detailed`) |
| **Function tools** | `tools` with `type: "function"` (client-side function calling with `name`, `description`, `parameters`, `strict`) |
| **Custom tools** | `tools` with `type: "custom"` |
| **Tool choice** | `tool_choice`: `"none"`, `"auto"`, `"required"`, or a specific function/custom tool |
| **Background mode** | `background: true` returns `202` immediately; poll with `GET /v1/responses/{id}` |
| **Streaming** | `stream: true` on foreground requests returns Server-Sent Events using OpenAI Responses event names. |
| **Prompt cache routing** | `prompt_cache_key` is an optional routing hint for requests that share a large prompt prefix |
| **Supercache** | [`metadata.supercache_write`](/supercache) stores a reusable prompt prefix for 24 hours. Matching reads are automatic. |
| **Image input** | `input_image` content blocks on [multimodal models](/models). Non-multimodal models accept text only. |
| **Video input** | `input_video` content blocks on models that declare video input support. The `video_url` value can be a URL string or an object with `url` and model-specific controls. |
| **Output logprobs** | `include: ["message.output_text.logprobs"]` returns per-token logprobs (best effort; omitted when unavailable). Set `top_logprobs` (0-512, default 0) to also get the highest-scoring alternative tokens at each position; it requires the `include` opt-in. |
### Supercache usage
When Supercache accounting data is available, completed Responses include
`metadata.supercached_input_tokens` and
`metadata.supercache_write_input_tokens` as decimal strings. Both fields are
included when their value is zero. `usage.input_tokens_details.cached_tokens`
includes tokens read from Supercache. The two metadata keys are reserved and
cannot be set in a request.
### Response status and output validation
Treat `completed`, `incomplete`, `failed`, and `cancelled` as terminal statuses
when polling a background response.
* `completed` means the response finished normally.
* `incomplete` means generation stopped early. A reason of
`"max_output_tokens"` is Sail's normalized truncation/cap reason: it is
reported whenever generation stops because of a length-based limit, such as
the request's `max_output_tokens`, the model's context window, or another
provider length signal. It does not by itself prove the request's output
limit alone was reached. A reason of `"content_filter"` means generation
stopped because of a content filter. When present, `usage` is preserved. A
response limited by `max_output_tokens` can include partial `output`; tokens
reported in `usage` are billed normally. An incomplete response is not an API
error.
* `failed` includes generations that Sail rejects because the result does not
satisfy the request after its retry attempts are exhausted. A request with
`tool_choice: "required"` can fail with `error.code: "server_error"` and the
message `"The model did not return a tool call required by the request."` A
generation with no visible text, refusal, or tool call can fail with
`error.code: "server_error"` and the message `"The model did not return
visible text, a refusal, or a tool call."` instead of returning an empty
completed response.
* `cancelled` means no further output will be produced.
Sail cannot currently guarantee `tool_choice: "required"` for
`openai/gpt-oss-*` models. Choose another model when every successful response
must contain a tool call.
### Not yet supported
| Feature | Notes |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Conversation chaining** | `previous_response_id` and `conversation` are not supported. Send the full input each request. |
| **Prompt templates** | The `prompt` parameter is not supported. |
| **Server-side tools** | `web_search`, `web_search_preview`, and `image_generation` tools are accepted for OpenAI-client compatibility and removed from the request; the model has no such tool to call. `file_search`, `code_interpreter`, `computer_use`, `mcp`, `shell`, and `apply_patch` are not supported. |
| **Multimodal input** | Audio and file input blocks are not supported. Image and video input are supported on models that declare those input modalities (see above). |
| **Include** | Accepted for compatibility when it is an array of strings. `reasoning.encrypted_content` is accepted for OpenAI-client compatibility, but reasoning items are returned without encrypted content. Requests that include `web_search_call.action.sources`, `code_interpreter_call.outputs`, `computer_call_output.output.image_url`, or `file_search_call.results` are rejected. |
| **Truncation** | Only `"disabled"` is accepted. Custom truncation strategies are not supported. |
| **Parallel tool calls** | `parallel_tool_calls` is accepted for compatibility. Models decide their own tool-call cadence, so the field has no effect. |
| **json\_object format** | `text.format.type: "json_object"` is not supported. Use `"json_schema"` instead. |
| **Service tier** | Only `"auto"` is accepted. Use `metadata.completion_window` to control response timing instead. |
| **Delete / cancel** | `DELETE /v1/responses/{id}` and cancel endpoints are not implemented. |
***
## Chat Completions API
### Supported features
| Feature | Details |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Core parameters** | `model`, `messages`, `max_completion_tokens`, `temperature`, `top_p`, `user`, `prompt_cache_key` |
| **Message roles** | `system`, `user`, `assistant`, `tool`, `function` (deprecated), `developer` |
| **Structured outputs** | `response_format` with `type: "text"`, `"json_object"`, or `"json_schema"` |
| **Reasoning** | `reasoning_effort` (`none` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`; `max` selects the same top tier as `xhigh`) |
| **Function tools** | `tools` with `type: "function"` (standard `{type, function: {name, description, parameters, strict}}` format) |
| **Custom tools** | `tools` with `type: "custom"` |
| **Tool choice** | `tool_choice`: `"none"`, `"auto"`, `"required"`, or a specific function/custom tool |
| **Parallel tool calls** | `parallel_tool_calls` is passed through |
| **Metadata** | `metadata` with string key-value pairs, including [`completion_window`](/completion-windows) and [`completion_webhook`](/webhooks) |
| **Prompt cache routing** | `prompt_cache_key` is an optional routing hint for requests that share a large prompt prefix |
| **Supercache** | [`metadata.supercache_write`](/supercache) stores a reusable prompt prefix for 24 hours. Matching reads are automatic. |
| **Streaming** | `stream: true` returns Server-Sent Events (`chat.completion.chunk`); `stream_options.include_usage` adds a final usage chunk. Reasoning is streamed as `reasoning_content` deltas and tool calls are emitted atomically. |
| **Image input** | `image_url` content parts on [multimodal models](/models). Non-multimodal models accept text only. |
| **Video input** | `video_url` content parts on models that declare video input support. URL objects and model-specific controls such as `num_frames` and `fps` are preserved. |
### Not yet supported
| Feature | Notes |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Multiple choices** | `n` must be `1`. |
| **Multimodal content** | Audio (`input_audio`) content parts are not supported. Image and video input are supported on models that declare those input modalities (see above). |
| **Sampling controls** | `frequency_penalty`, `presence_penalty`, `logit_bias`, `stop`, `seed`, `top_logprobs`, `logprobs`, `verbosity` are not supported. |
| **Audio modality** | `audio` and `modalities: ["audio"]` are not supported. |
| **Predicted output** | `prediction` is not supported. |
| **Web search** | `web_search_options` is not supported. |
| **Service tier** | Only `"auto"` is accepted. |
| **CRUD endpoints** | `GET`, `POST`, `DELETE` on stored completions are not implemented. |
| **Deprecated fields** | `max_tokens`, `functions`, `function_call` are rejected. Use their modern replacements. |
### Response notes
* Responses always contain exactly one choice (`n=1`).
* `finish_reason` reflects the provider result when available, including
`"stop"`, `"tool_calls"`, `"length"`, and `"content_filter"`.
* `system_fingerprint` and `service_tier` are not included in responses.
* `logprobs` is always `null`.
***
## Messages API
Anthropic Messages formatAnthropic SDK compatible
API reference →
The Messages API is Anthropic-compatible for agentic use: system prompts, tool
calling, and streaming (SSE) are supported. Prompt caching (`cache_control`)
is accepted but not yet applied.
### Supported features
| Feature | Details |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Core parameters** | `model`, `max_tokens`, `messages` |
| **System prompt** | The top-level `system` parameter (string or array of text blocks) |
| **Sampling** | `temperature` (0–1), `top_p` (0–1) |
| **Tools** | `tools` and `tool_choice` (`"auto"`, `"any"`, `"tool"`, `"none"`). The model calls tools; responses include `tool_use` blocks, and text `tool_result` blocks round-trip. Tool results with `is_error: true` retain an error signal for the model. |
| **Extended thinking** | `thinking` is translated to the model's reasoning |
| **Streaming** | `stream: true` returns Anthropic Server-Sent Events (`message_start`, `content_block_delta`, `message_stop`, …) after generation completes. It is not incremental token delivery. |
| **Structured outputs** | `output_config.format` with `type: "json_schema"` |
| **Metadata** | `metadata` with string key-value pairs, including [`completion_window`](/completion-windows) and [`completion_webhook`](/webhooks) |
| **Image input** | `image` content blocks on [multimodal models](/models). Non-multimodal models accept text only. |
| **Request routing** | `routing.allowed_countries: ["US"]` restricts that request to United States capacity |
| **Voyage attribution** | `X-Sail-Voyage-Id`, with optional span and agent headers, associates the model call with a [Voyage](/voyages-sdk) |
| **Token counting** | `POST /v1/messages/count_tokens` returns the request's input token count without running the model |
### Not yet supported
| Feature | Notes |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Prompt caching** | `cache_control` on content blocks is accepted but ignored (no cache read/write). |
| **Stop sequences** | `stop_sequences` is not supported. |
| **Top-K sampling** | `top_k` is not supported. |
| **Multimodal content** | Document content blocks are not supported. Image input is supported on multimodal models (see above). |
| **Tool result images** | Images inside `tool_result` are replaced with `[Image omitted: Sail does not yet support images inside tool results.]`. The request still succeeds. |
| **Redacted thinking** | Replayed `redacted_thinking` blocks are accepted so conversations can continue, but the opaque reasoning is not forwarded to the model. |
| **Service tier** | `service_tier` is not supported. |
| **Inference geo** | `inference_geo` is not supported. |
| **Batches** | `POST /v1/messages/batches` and related endpoints are not implemented. |
### Response notes
* `stop_reason` reflects the outcome. Sail returns `"end_turn"` normally,
`"tool_use"` for tool calls, `"max_tokens"` for token limits, and
`"refusal"` when the provider reports a refusal. Sail returns
`"model_context_window_exceeded"` when the provider reports that the
model's context window was exceeded.
* Responses contain `text` content blocks, plus `tool_use` blocks when the model calls a tool.
* Cache-related usage fields (`cache_creation_input_tokens`, `cache_read_input_tokens`) are not included (prompt caching isn't applied yet).
* Thinking output does not include an Anthropic cryptographic signature.
`thinking.budget_tokens` is approximated as medium reasoning effort unless
`output_config.effort` provides an explicit effort.
* `thinking.type: "disabled"` uses the selected model's `none` reasoning
control. Sail returns `400` when the selected model does not support disabling
reasoning.
The exact effect follows that model's `none` contract, so `disabled` does not
bypass a model-specific lowest-effort approximation. `output_config.effort`
overrides the effort level. A non-disabling override preserves thinking
output requested by `thinking.type: "enabled"` or `"adaptive"`; an effort
whose model mapping disables reasoning can remove it.
* When reasoning is present, Messages content begins with a `thinking` block
before the text block. Select content by block type rather than assuming
`content[0]` is text. The Chat Completions response exposes the same reasoning
through `reasoning_content`.
* Token counting applies the same replay-block policy as message creation:
redacted thinking is omitted and tool-result images count as the replacement
text marker.
* Non-streaming requests wait for up to nine minutes. If generation is still
running, Sail returns a `408 timeout_error` and includes the response ID in
`X-Sail-Message-Id`. Sail also returns `X-Should-Retry: false` so Anthropic
SDKs do not create a second task. The original task continues. Retrieve it with
`GET /v1/messages/{id}`, or use `stream: true` for long-running requests.
Streaming requests use heartbeats and remain connected for up to 20 minutes.
If that wait expires, the stream emits a timeout error with the response ID.
The task continues and remains retrievable with `GET /v1/messages/{id}`.
### Compatibility notes
* Sail accepts both the Anthropic `x-api-key` header and
`Authorization: Bearer `. If both are present, `Authorization` takes
precedence.
```python Python theme={null}
from anthropic import Anthropic
client = Anthropic(
api_key="YOUR_SAIL_API_KEY",
base_url="https://api.sailresearch.com",
)
```
```typescript TypeScript theme={null}
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
apiKey: process.env.SAIL_API_KEY,
baseURL: "https://api.sailresearch.com",
});
```
* The Anthropic Python and TypeScript SDKs type `metadata` with only `user_id`.
Sail also accepts `completion_window`. Keep the additional cast or type
assertion scoped to the `metadata` value:
```python theme={null}
from typing import cast
from anthropic.types import MetadataParam
metadata = cast(MetadataParam, {"completion_window": "balanced"})
```
```typescript theme={null}
metadata: {
completion_window: "balanced",
} as Anthropic.Messages.Metadata & { completion_window: string },
```
* The `anthropic-version` header is not required or checked.
* Errors use the Anthropic envelope
`{"type":"error","error":{"type":"...","message":"..."},"request_id":"..."}`
and Anthropic error types such as `invalid_request_error`,
`rate_limit_error`, and `overloaded_error`.
***
## Batch API
The Batch API runs large numbers of [Responses API](#responses-api) requests asynchronously. Every item targets `/v1/responses`. Batching `/v1/chat/completions` or `/v1/messages` is not currently supported. You can submit up to 100,000 requests in a single `POST /v1/batches` call, then poll `GET /v1/batches/{id}` for status and fetch each result by `custom_id`.
See [Sending Requests at Scale](/requests_at_scale) for the end-to-end workflow and the [Batch API reference](/api-reference/batches-api/create-a-batch) for the request and response schemas.
***
## Cross-API behavior
These behaviors apply across the inference API surfaces:
* **Streaming:** The Chat Completions API supports `stream: true`, returning Server-Sent Events (`chat.completion.chunk`); set `stream_options.include_usage` for a final usage chunk. The Messages API supports `stream: true`, returning Anthropic SSE events after generation completes rather than incremental token delivery. The Responses API supports foreground `stream: true`, returning OpenAI Responses SSE events. `background: true` requests return `202` immediately and cannot be streamed; use polling or [webhooks](/webhooks) for long-running background work.
* **Completion windows:** Express latency tolerance in exchange for lower token costs. See [Completion Windows](/completion-windows) and [Pricing](/pricing).
* **Context windows:** For each text request, Sail reserves at least 512 tokens,
or 0.5% on larger windows, from the model's published context window. The
input token count plus the requested maximum output must fit in the remaining
budget. This allowance covers model-specific request formatting that can add
tokens when the request runs. On the non-batch Responses endpoint
(`POST /v1/responses`), requests that provide `raw_prompt_tokens` skip text
formatting and use the exact raw-token count without this reserve, requiring
the raw token count plus `max_output_tokens` to fit within the model's context
window. The Batch API does not yet apply this `raw_prompt_tokens` admission
arithmetic.
* **Webhooks:** Set `metadata.completion_webhook` to receive a POST when processing finishes. See [Webhooks](/webhooks).
* **Response storage:** `store: false` is accepted for OpenAI compatibility, but does not change Sail's normal temporary request/response storage for processing, retries, polling, and idempotency. Customer Data remains governed by Sail's DPA retention and deletion terms.
# Tinker
Source: https://docs.sailresearch.com/tinker
Sample from Tinker-trained LoRA checkpoints on Sail with SailTokenCompleter
[Tinker](https://thinkingmachines.ai/tinker) is a training API for fine-tuning open-weight models with LoRA. Sail can run the sampling side of your Tinker training loop: `sail.SailTokenCompleter` is a drop-in [tinker-cookbook](https://github.com/thinking-machines-lab/tinker-cookbook) `TokenCompleter` that samples from your Tinker checkpoints on Sail, with no manual adapter upload step.
Token IDs go in and token IDs come out. The completer sends your prompt token IDs to Sail verbatim and returns sampled token IDs with per-token logprobs, so there is no chat-template or re-tokenization drift between training and sampling. Each call creates a background Responses API request for the [completion window](/completion-windows) you choose (`balanced` by default) and polls it to completion, retrying transient failures with exponential backoff.
We also have a [guide](tinker-rl) on how to use `sail.SailTokenCompleter` in a GRPO-style Tinker training loop.
## Install
```bash theme={null}
pip install sail tinker tinker-cookbook
export SAIL_API_KEY=sk_your_key_here
export TINKER_API_KEY=your_tinker_key
```
## Sample on Sail
`SailTokenCompleter` works anywhere tinker-cookbook expects a `TokenCompleter` (i.e. RL rollouts, evals, or direct calls):
```python theme={null}
import sail
from tinker import types
completer = sail.SailTokenCompleter(
model="moonshotai/Kimi-K2.6",
max_tokens=256,
temperature=0.7,
completion_window="balanced",
)
prompt = types.ModelInput.from_ints(tokens=tokenizer.encode("Question: 2+2?\nAnswer:"))
result = await completer(prompt, stop=["\n"])
result.tokens # sampled token IDs
result.maybe_logprobs # per-token logprobs (None when request_logprobs=False)
result.stop_reason
```
### Parameters
| Parameter | Default | Description |
| ------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `model` | (required) | Sail model ID. Must support LoRA serving when a LoRA source is set (see [LoRAs](/loras#supported-base-models)). |
| `max_tokens` | (required) | Maximum sampled tokens per call. |
| `temperature` | `1.0` | Sampling temperature. |
| `top_p` | `1.0` | Nucleus sampling threshold. |
| `completion_window` | `"balanced"` | [Completion window](/completion-windows) for each request. K2.6 LoRA requests use `balanced` by default; `flex` is available for background work and `priority` remains accepted for existing clients. LoRA requests cannot use `asap`; the selected window must be supported by the model. |
| `lora` | `None` | Name or ID of a LoRA [uploaded to Sail](/loras). |
| `tinker_lora_signed_url` | `None` | Signed Tinker checkpoint archive URL. Mutually exclusive with `lora`. |
| `adapter_config` | `None` | PEFT `adapter_config.json` contents (dict or JSON string). Required with `tinker_lora_signed_url`. |
| `tinker_lora_name` | `None` | Optional label for the Tinker checkpoint. |
| `metadata` | `None` | Extra request metadata merged into each request. |
| `timeout` | `None` | Per-HTTP-call timeout in seconds. |
| `request_logprobs` | `True` | Request per-token logprobs with each sample. |
The `stop` argument on the call itself accepts a string, a list of strings, or token IDs, matching the tinker-cookbook `TokenCompleter` contract.
## Sample from a Tinker checkpoint
To sample from a LoRA you are training in Tinker, save sampler weights, resolve a signed archive URL, and pass both the URL and the adapter's PEFT config to the completer. Sail downloads the checkpoint archive and loads the adapter for your requests.
```python theme={null}
import sail
# 1. Save sampler weights for the current step
save_future = await training_client.save_weights_for_sampler_async(
"rl-step-7",
ttl_seconds=3600,
)
save_result = await save_future
tinker_path = save_result.path # tinker:///sampler_weights/rl-step-7
# 2. Resolve a signed checkpoint archive URL
signed_url = await sail.get_tinker_checkpoint_signed_url_async(
service_client,
tinker_path,
ttl_seconds=3600, # optional: set/extend the checkpoint TTL
)
# 3. Sample from the checkpoint on Sail
completer = sail.SailTokenCompleter(
model="moonshotai/Kimi-K2.6",
max_tokens=256,
completion_window="balanced",
tinker_lora_signed_url=signed_url,
adapter_config=adapter_config, # contents of the PEFT adapter_config.json
tinker_lora_name="rl-step-7",
)
```
`adapter_config` is the PEFT adapter config for the LoRA Tinker is training. The same compatibility rules apply as for [uploaded LoRAs](/loras#adapter-requirements): the base model must match `model`, and the rank must be within the base model's limit.
When `ttl_seconds` is passed to `get_tinker_checkpoint_signed_url_async`, the helper sets the Tinker checkpoint's TTL before resolving the URL, so per-step RL sampler checkpoints are cleaned up automatically instead of accumulating in your Tinker account.
### Using an uploaded LoRA instead
If you have already [uploaded a LoRA to Sail](/loras), pass its name or ID as `lora` instead of a signed URL:
```python theme={null}
completer = sail.SailTokenCompleter(
model="moonshotai/Kimi-K2.6",
max_tokens=256,
completion_window="balanced",
lora="funnier-v1",
)
```
## Constraints
* **Tinker checkpoints only apply through `SailTokenCompleter`.** The adapter is loaded on Sail's raw-token sampling path. A plain text Responses or Chat Completions request that happens to carry Tinker checkpoint metadata is served by the base model. Sample from Tinker checkpoints only via `SailTokenCompleter`.
* **`lora` and `tinker_lora_signed_url` are mutually exclusive.** Pass one LoRA source per completer.
* **`adapter_config` is required with `tinker_lora_signed_url`.** Sail needs the PEFT config to load the checkpoint weights.
* **`model` must support LoRA serving** when a LoRA source is set (see [supported base models](/loras#supported-base-models)).
* **Kimi K2.6 LoRA requests use `balanced` by default.** `SailTokenCompleter` selects it by default. `flex` is available for background LoRA and Tinker work, and `priority` remains accepted for existing clients. The `asap` completion window is not available for LoRA requests.
* **Signed checkpoint URLs expire.** Resolve a fresh URL for each new checkpoint, and re-resolve if a long-running loop reuses an old one.
* **tinker-cookbook must be installed.** Constructing a `SailTokenCompleter` without it raises an error; the rest of the `sail` SDK works without Tinker packages.
# RL fine-tuning with Tinker
Source: https://docs.sailresearch.com/tinker-rl
Train a LoRA with Tinker while running every rollout on Sail
This guide walks through a small GRPO-style reinforcement-learning training loop that trains a LoRA adapter with [Tinker](https://thinkingmachines.ai/tinker) while sampling every rollout from Sail. Tinker owns the optimizer; Sail serves each fresh checkpoint through [`SailTokenCompleter`](/tinker), so your rollouts run on Sail's inference fleet.
The example fine-tunes Kimi K2.6 to solve grade-school math word problems, rewarding answers that land in `\boxed{}`.
## Prerequisites
* Python 3.11+
* A Sail API key and a Tinker API key
* The packages below
```bash theme={null}
pip install sail tinker tinker-cookbook datasets
export SAIL_API_KEY=sk_your_key_here
export TINKER_API_KEY=your_tinker_key
export HF_TOKEN=your_huggingface_token
```
## How one step fits together
Each training step is the same five moves:
1. **Snapshot** the current LoRA weights as a Tinker sampler checkpoint.
2. **Resolve** a signed URL to that checkpoint and wrap it in a `SailTokenCompleter`.
3. **Roll out** a batch of grouped completions through that completer on Sail.
4. **Score** the completions, turn them into advantages and Tinker training data.
5. **Train** one optimizer step on the Tinker client, then repeat with the updated weights.
## 1. Score a completion
An *environment* renders one prompt and scores one sampled completion. tinker-cookbook calls `initial_observation` to get the prompt tokens (and stop sequences), then `step` to score the tokens Sail sampled. The reward here is 1 for a correct boxed answer, with a small penalty for missing the `\boxed{}` format.
```python theme={null}
import re
from dataclasses import dataclass
from typing import Any
import tinker
from tinker_cookbook import renderers
from tinker_cookbook.rl.types import StepResult
QUESTION_SUFFIX = " Write your final answer in \\boxed{} format."
def extract_boxed(text: str) -> str | None:
match = re.search(r"\\boxed\s*\{([^{}]+)\}", text)
return match.group(1) if match else None
def answer_matches(completion: str, reference: str) -> bool:
def norm(t: str | None) -> str:
return re.sub(r"\s+", "", (t or "").strip().lower().replace(",", ""))
return norm(extract_boxed(completion)) == norm(extract_boxed(reference) or reference)
class MathEnv:
def __init__(self, question: str, answer: str, renderer: Any) -> None:
self.question = question
self.answer = answer
self.renderer = renderer
async def initial_observation(self):
convo = [{"role": "user", "content": self.question + QUESTION_SUFFIX}]
return self.renderer.build_generation_prompt(convo), self.renderer.get_stop_sequences()
async def step(self, action, *, extra=None):
message, termination = self.renderer.parse_response(action)
text = renderers.get_text_content(message)
correct_format = float(extract_boxed(text) is not None and termination.is_clean)
correct_answer = float(answer_matches(text, self.answer))
return StepResult(
reward=correct_answer - 0.1 * (1.0 - correct_format),
episode_done=True,
next_observation=tinker.ModelInput.empty(),
next_stop_condition=[],
metrics={"correct": correct_answer, "format": correct_format},
logs={},
)
```
A *group builder* fans one prompt out into `group_size` environments.
```python theme={null}
@dataclass(frozen=True)
class MathGroupBuilder:
row: dict
renderer: Any
group_size: int
async def make_envs(self):
return [
MathEnv(self.row["question"], self.row["answer"], self.renderer)
for _ in range(self.group_size)
]
# The reward lives in MathEnv.step, so there is no extra group-level bonus.
async def compute_group_rewards(self, trajectory_group, env_group):
return [(0.0, {}) for _ in trajectory_group]
async def cleanup(self):
return None
def logging_tags(self):
return ["math"]
```
## 2. Serve the latest checkpoint on Sail
After each Tinker step, save the weights as a sampler checkpoint, resolve a signed archive URL, and build a `SailTokenCompleter` pointed at it. The `adapter_config` is the PEFT config matching the LoRA Tinker is training. This ready-made one fits this example (Kimi K2.6 at rank 32); save it as `tinker_adapter_config.json`. Passing `ttl_seconds` tells Tinker to expire the checkpoint, so per-step checkpoints clean themselves up instead of piling up.
```json tinker_adapter_config.json theme={null}
{
"base_model_name_or_path": "moonshotai/Kimi-K2.6",
"bias": "none",
"inference_mode": true,
"lora_alpha": 32,
"lora_dropout": 0.0,
"peft_type": "LORA",
"r": 32,
"target_modules": [
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
"lm_head"
],
"task_type": "CAUSAL_LM"
}
```
```python theme={null}
import sail
async def sail_policy(training_client, service_client, adapter_config, name):
save_future = await training_client.save_weights_for_sampler_async(
name, ttl_seconds=3600
)
save_result = await save_future
signed_url = await sail.get_tinker_checkpoint_signed_url_async(
service_client, save_result.path, ttl_seconds=3600
)
return sail.SailTokenCompleter(
model="moonshotai/Kimi-K2.6",
max_tokens=1024,
temperature=1.0,
completion_window="balanced",
tinker_lora_signed_url=signed_url,
adapter_config=adapter_config,
tinker_lora_name=name,
)
```
## 3. The training loop
Wire it together: load the data, then each step snapshot → roll out on Sail → train.
```python theme={null}
import asyncio
import json
import random
import time
from datasets import load_dataset
from tinker_cookbook.rl.data_processing import assemble_training_data, compute_advantages
from tinker_cookbook.rl.rollouts import do_group_rollout
from tinker_cookbook.rl.train import train_step
from tinker_cookbook.tokenizer_utils import get_tokenizer
MODEL = "moonshotai/Kimi-K2.6"
def mean_reward(groups):
rewards = [float(r) for group in groups for r in group.get_total_rewards()]
return sum(rewards) / len(rewards) if rewards else 0.0
def split_degenerate_groups(groups):
kept = []
degenerate = []
for group in groups:
rewards = [float(r) for r in group.get_total_rewards()]
if rewards and len(set(rewards)) == 1:
degenerate.append(group)
else:
kept.append(group)
return kept, degenerate
async def main():
adapter_config = json.load(open("tinker_adapter_config.json"))
service_client = tinker.ServiceClient()
training_client = await service_client.create_lora_training_client_async(
base_model=MODEL, rank=32
)
renderer = renderers.get_renderer("kimi_k26", tokenizer=get_tokenizer(MODEL))
ds = load_dataset("microsoft/orca-math-word-problems-200k", split="train")
train_rows = [
{"question": r["question"], "answer": r["answer"]} for r in ds.select(range(2000))
]
run_id = f"orca-math-{int(time.time())}"
group_size = 4
groups_per_step = 16 # 16 prompts x 4 samples = 64 rollouts per step
for step in range(7):
# 1-2. Snapshot the current weights and serve them on Sail.
policy = await sail_policy(
training_client, service_client, adapter_config, f"{run_id}-step-{step}"
)
# 3. Roll out a batch of grouped completions through Sail.
rows = random.sample(train_rows, groups_per_step)
builders = [MathGroupBuilder(row=r, renderer=renderer, group_size=group_size) for r in rows]
groups = await asyncio.gather(*(do_group_rollout(b, policy) for b in builders))
# 4-5. GRPO-style advantages, then one Tinker optimizer step.
reward = mean_reward(groups)
training_groups, degenerate_groups = split_degenerate_groups(groups)
degenerate_pct = 100.0 * len(degenerate_groups) / len(groups) if groups else 0.0
if not training_groups:
raise RuntimeError("all rollout groups were degenerate")
advantages = compute_advantages(training_groups)
data, _ = assemble_training_data(training_groups, advantages)
await train_step(
data_D=data,
training_client=training_client,
learning_rate=1e-5,
num_substeps=1,
loss_fn="importance_sampling",
metrics={},
)
print(
f"Step {step:2d} | reward: {reward:.3f} | "
f"degenerate: {degenerate_pct:.0f}% | datums: {len(data)}"
)
asyncio.run(main())
```
That's the whole loop. Tinker holds the optimizer state and applies each update; Sail samples every rollout from the checkpoint you just wrote. To scale the rollout batch, raise `group_size` and `groups_per_step`, and Sail runs the completions concurrently. To watch the reward climb, log the `metrics` returned by `compute_advantages`/`train_step`.
## 4. Sandboxed rollouts
The math environment above only scores text, so it runs safely inside the
training process. An environment that executes model-written code should
not. tinker-cookbook has a seam for this: recipes that take a sandbox
factory run each rollout's commands in an isolated sandbox. Pass Sail's
factory and those sandboxes are Sailboxes:
```python theme={null}
import functools
import sail
sandbox_factory = functools.partial(sail.tinker_sandbox_factory, size="s")
```
When `image_ref` is preset, the factory uses that registry image. Otherwise,
it reads the `task.toml` next to the environment directory for
`[environment].docker_image`. When the task does not name a registry image,
Sail builds the environment's `Dockerfile` with the whole environment
directory as its build context. The registry image or Dockerfile must
produce a Debian- or Ubuntu-based filesystem. The factory creates a Sailbox
from the selected image and returns a sandbox the cookbook drives: commands,
file reads and writes, and cleanup all happen in the Sailbox, and the
cookbook's timeout terminates it. It is a plain module-level function, so it
survives the cookbook's pickling, and `functools.partial` keeps preset
arguments pickleable too. Each group's sandboxes are created concurrently.
The [Tinker reference](/sdk-tinker) documents the full sandbox API.
## Next steps
* [Tinker](/tinker): the `SailTokenCompleter` reference (parameters, LoRA modes, and constraints).
* [LoRAs](/loras): upload and serve a PEFT adapter directly, without a Tinker training loop.
* [Completion Windows](/completion-windows): control the latency/cost tier your rollouts run on.
# Trust Center
Source: https://docs.sailresearch.com/trust-center
# Overview
Source: https://docs.sailresearch.com/usage
Programmatic access to spend, usage, tokens, and latency
The Usage API lets you query your Sail usage from scripts, CLIs, or dashboards,
the same data shown on the dashboard in [app.sailresearch.com](https://app.sailresearch.com).
It covers two families:
* **Billing & cost**: combined spend, an Inference and Sailboxes product split,
Sailbox line-item spend, credit balance, burn rate, days remaining, per-model
cost rankings, per-API-key inference usage, and inference token counters.
* **Operational activity & latency**: request counts and time series, recent
requests, task activity, and turn/trajectory latency distributions.
## Base URL
```
https://api.sailresearch.com
```
## Authentication
All requests require a Bearer API key in the `Authorization` header.
This is the same API key you use for the inference API at
`api.sailresearch.com`. Create or manage keys from the [Sail
dashboard](https://app.sailresearch.com).
```python theme={null}
import requests
headers = {"Authorization": "Bearer YOUR_SAIL_API_KEY"}
params = {"bucket_size": "1h", "environment": "all"}
resp = requests.get(
"https://api.sailresearch.com/v2/usage",
headers=headers,
params=params,
)
print(resp.json())
```
```bash theme={null}
curl -s -H "Authorization: Bearer $SAIL_API_KEY" \
"https://api.sailresearch.com/v2/usage?bucket_size=1h&environment=all" | jq
```
## Product-aware spend
Use the summary endpoint to read combined spend and its exact product split:
```bash theme={null}
curl -s -H "Authorization: Bearer $SAIL_API_KEY" \
"https://api.sailresearch.com/v2/usage/summary?range=30d" | \
jq '{period_spend, product_spend, sailbox_spend}'
```
`product_spend` always has exactly two buckets, `inference` and `sailboxes`,
which add up to `period_spend`. Inference token, model, completion-window, and
latency metrics exclude Sailbox usage. See [Usage API endpoints](/usage-endpoints#product-accounting)
for the full accounting contract and field definitions.
Full reference for every usage route: parameters, response shapes, error
formats, and headers.
Usage data may have a slight delay and is not real-time. Billing-derived
fields (spend, balance, per-API-key usage, token counters) are sourced from
metered billing data and can lag the most recent minutes more than the
operational activity and latency endpoints.
Monetary fields are fractional USD cents (floats), and token fields are raw
token counts. See [Endpoints](/usage-endpoints) for the exact units per field.
# Usage API endpoints
Source: https://docs.sailresearch.com/usage-endpoints
Reference for every usage route, including spend, tokens, activity, and latency
All endpoints live under the base URL and require a Bearer API key (the same key
used for the inference API). The org is derived from the key.
```text theme={null}
https://api.sailresearch.com
```
```bash theme={null}
curl -s -H "Authorization: Bearer $SAIL_API_KEY" \
"https://api.sailresearch.com/v2/usage/summary?range=30d" | jq
```
Routes fall into two families: **Billing & cost** (spend, balance, and token
counters) and **Operational activity & latency** (request counts, tasks, and
latency distributions).
## Common parameters
Most routes accept a rolling-window `range` and, for the operational routes, an
`environment` filter.
| Parameter | Values | Description |
| ------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | Rolling window, or the current billing `period`. The default varies per route. Unknown values fall back to the default rather than erroring. |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | Customer-facing environment label (operational routes). Combine with a comma, e.g. `dev,prod`. Default `all`. |
`range` never returns a 400 for an unrecognized value. It silently falls back
to the endpoint's default window. Invalid `environment`, `date`, `start`,
`end`, `bucket_size`, `status`, `kind`, or `after` values do return 400, as
does an unsupported `sla` on `/tasks` and `/latency/timeseries`. On `GET
/v2/usage`, an unrecognized `sla` or `model` is not rejected. It is applied as
a filter that matches nothing.
Monetary fields (`balance`, `period_spend`, `burn_rate`, `avg_cost_per_day`,
`product_spend`, `sailbox_spend`, and breakdown `total`) are **fractional USD
cents** expressed as floats. For example `73795.09` is roughly \$737.95. Token
fields on the public endpoints are **raw inference token counts**.
## Product accounting
Billing responses follow these guarantees:
* `period_spend` and each breakdown bucket's `total` include all positive
Inference and Sailbox charges.
* `product_spend` always contains exactly two product families, `inference` and
`sailboxes`. It never emits an `other` family, and the two values add up to
the corresponding combined total.
* `sailbox_spend` reports the currently itemized Sailbox charges. Sailbox
products without a line item still count toward `product_spend.sailboxes`,
so the line-item fields may sum to less than it.
* Token, model, completion-window, request, and latency metrics are
inference-only. Sailbox quantities and identifiers are not represented as
tokens, models, or completion windows.
* Only base input, output, and cached-input token products add token counts.
Surcharges add inference spend without duplicating token quantities. Cached
input is included in `input`, so `total = input + output` and cached tokens
must not be added to `total` again.
* Model and completion-window breakdowns include only explicitly attributed
inference spend. Missing attribution stays unassigned instead of creating an
`other` model or completion window.
To calculate product percentages, divide each product amount by the combined
total. When the combined total is zero, both percentages are zero.
```text theme={null}
inference percentage = product_spend.inference / period_spend
sailboxes percentage = product_spend.sailboxes / period_spend
```
## Billing & cost
### GET /v2/usage/summary
Combined Inference and Sailbox spend, credit balance, burn rate, days remaining,
inference token totals, inference SLA spend mix, and a prior-period comparison.
| Parameter | Values | Default |
| --------- | ---------------------------------------- | ------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `30d` |
```json theme={null}
{
"object": "usage.summary",
"available": true,
"empty": false,
"has_metronome_customer": true,
"range": "30d",
"balance": 73795.09,
"balance_unavailable": false,
"period_spend": 105392.94,
"product_spend": {
"inference": 87200.31,
"sailboxes": 18192.63
},
"sailbox_spend": {
"active_vcpu": 9240,
"active_memory": 4720,
"active_disk": 3210,
"creation_s": 522.63,
"creation_m": 500
},
"burn_rate": 3513.1,
"days_remaining": 21,
"model_count": 14,
"total_requests": 139197,
"tokens": {
"total": 2637290778,
"input": 2562190467,
"output": 75100311,
"cached": 2133342376
},
"avg_cost_per_day": 3513.1,
"sla_mix": {
"asap": 0.78,
"standard": 0.21,
"flex": 0.01
},
"prior_period": {
"period_spend": 57216.45,
"model_count": 13,
"tokens": {
"total": 1472254959,
"input": 1402406455,
"output": 69848504,
"cached": 945778881
},
"avg_cost_per_day": 1907.21
}
}
```
| Field | Type | Description |
| ----------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `empty` | boolean | `true` when the org has a billing account but no positive billed Inference or Sailbox spend in the window. |
| `has_metronome_customer` | boolean | `false` (with zeroed fields) when the org has no billing account. |
| `balance` | number | Net credit balance, in fractional cents. |
| `balance_unavailable` | boolean | `true` when the balance lookup failed; other fields are still returned. |
| `period_spend` | number | Combined Inference and Sailbox spend over the range, in fractional cents. |
| `product_spend` | object | Combined spend split into exactly `inference` and `sailboxes`. The values add up to `period_spend`. |
| `product_spend.inference` | number | Positive Inference charges, including surcharges, in fractional cents. |
| `product_spend.sailboxes` | number | All positive Sailbox charges, including charges without a visible `sailbox_spend` field, in fractional cents. |
| `sailbox_spend` | object | Currently itemized Sailbox charges. These fields may sum to less than `product_spend.sailboxes`. |
| `sailbox_spend.active_vcpu` | number | Active Sailbox vCPU-hour spend, in fractional cents. |
| `sailbox_spend.active_memory` | number | Active Sailbox memory GiB-hour spend, in fractional cents. |
| `sailbox_spend.active_disk` | number | Active Sailbox disk GiB-hour spend across supported architectures, in fractional cents. |
| `sailbox_spend.creation_s` | number | Sailbox S creation spend, in fractional cents. |
| `sailbox_spend.creation_m` | number | Sailbox M creation spend, in fractional cents. |
| `burn_rate` | number | Combined spend per day over the range, in fractional cents. |
| `days_remaining` | number \| null | `balance / burn_rate`, or `null` when unknown or above 365. |
| `model_count` | number | Number of explicitly attributed inference models in the range. |
| `total_requests` | number \| null | Total billed inference requests over the range, when available. |
| `tokens` | object | `total`, `input`, `output`, and `cached` raw inference token counts. Cached input is a subset of input, and Sailbox quantities are excluded. |
| `sla_mix` | object | Fraction of explicitly attributed inference spend per completion window. Unattributed inference spend is outside the denominator and does not create a synthetic tier. |
| `prior_period` | object | Prior-window combined `period_spend` and `avg_cost_per_day`, plus inference-only `model_count` and token totals. |
Plan tier is not exposed via the API; it is shown only on the dashboard.
### GET /v2/usage/breakdown
Combined spend and inference token breakdowns per time bucket, plus inference
model cost rankings. Use `range=day` with `date=YYYY-MM-DD` to drill into a
single day at hourly granularity.
| Parameter | Values | Default | Description |
| --------- | ----------------------------------------------- | ------- | ------------------------------------------------ |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period`, `day` | `30d` | `1h`/`6h`/`24h`/`day` return hourly granularity. |
| `date` | `YYYY-MM-DD` | none | Required when `range=day`. |
```json theme={null}
{
"object": "usage.breakdown",
"available": true,
"range": "7d",
"granularity": "day",
"data": [
{
"timestamp": "2026-06-30",
"total": 10984.59,
"product_spend": {
"inference": 10800,
"sailboxes": 184.59
},
"models": {
"zai-org/GLM-5.3": {
"total": 10657.96,
"tokens": 439626559,
"input_tokens": 432900739,
"output_tokens": 6725820,
"cached_tokens": 419001344
},
"moonshotai/Kimi-K2.6": {
"total": 22.33,
"tokens": 283710,
"input_tokens": 250852,
"output_tokens": 32858,
"cached_tokens": 194632
}
},
"slas": { "asap": 10432.31, "standard": 366.8 }
}
],
"models": [
{
"model": "zai-org/GLM-5.3",
"total": 10657.96,
"tokens": 439626559,
"input_tokens": 432900739,
"output_tokens": 6725820,
"cached_tokens": 419001344,
"slas": { "asap": 10432.31 },
"percentage": 0.998
}
]
}
```
| Field | Type | Description |
| ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `data[]` | array | One entry per time bucket. `total` includes Inference and Sailboxes. |
| `data[].product_spend` | object | The bucket's `inference` and `sailboxes` spend, in fractional cents. The values add up to `data[].total`. |
| `data[].models` | object | Inference spend and token counts keyed by explicitly attributed model ID. Unattributed spend does not create a synthetic model. |
| `data[].slas` | object | Inference spend keyed by explicitly attributed completion window. Unattributed spend does not create a synthetic completion window. |
| `models[]` | array | Per-model inference rankings across the whole range, sorted by `total` spend descending. |
| `models[].percentage` | number | The model's fraction of explicitly model-attributed inference spend. Sailbox and unattributed inference spend are outside the denominator. |
Inference spend without a completion-window attribution is the remainder
below. It is plain inference spend, not an `other` product or completion
window.
```text theme={null}
unattributed inference = max(0, data[].product_spend.inference - sum(data[].slas values))
```
Usage breakdowns report the `balanced` window under the key `standard`.
### GET /v2/usage/api-keys
Per-API-key usage, broken down by `(api_key_id, model, sla)`, with a windowed
time series. `display_name` and `display_prefix` are populated for keys that
still exist; deleted keys return `null` for both (only the stable `api_key_id`
remains).
This endpoint reports inference usage only. Sailbox charges accrue over a
Sailbox's lifetime and do not map reliably to a single API key, so Sailbox
usage is excluded from the per-key rows. Use `/v2/usage/summary` or
`/v2/usage/breakdown` for product-aware spend.
| Parameter | Values | Default |
| --------- | ---------------------------------------- | ------------------ |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
| `window` | `hour`, `day` | derived from range |
```json theme={null}
{
"object": "usage.api_keys",
"available": true,
"range": "7d",
"granularity": "day",
"keys": [
{
"key_identity": "id:key_abc123",
"api_key_id": "key_abc123",
"display_name": "Production",
"display_prefix": "sk_QpCO",
"request_count": 4210,
"total_tokens": 31000000,
"input_tokens": 24000000,
"output_tokens": 7000000,
"cached_tokens": 1200000,
"regular_cached_tokens": 900000,
"supercached_tokens": 300000,
"supercache_write_tokens": 50000,
"model": "moonshotai/Kimi-K2.6",
"sla": "asap"
}
],
"time_series": [
{
"time_bucket": "2026-06-30T00:00:00Z",
"key_identity": "id:key_abc123",
"api_key_id": "key_abc123",
"display_name": "Production",
"display_prefix": "sk_QpCO",
"request_count": 600,
"total_tokens": 4400000,
"input_tokens": 3400000,
"output_tokens": 1000000,
"cached_tokens": 150000,
"regular_cached_tokens": 100000,
"supercached_tokens": 50000,
"supercache_write_tokens": 10000,
"model": "moonshotai/Kimi-K2.6",
"sla": "asap"
}
]
}
```
| Field | Type | Description |
| ------------------------- | -------------- | ------------------------------------------------------------------------ |
| `key_identity` | string | Stable identity used to correlate `keys` with `time_series` rows. |
| `display_name` | string \| null | `null` for deleted keys. |
| `display_prefix` | string \| null | Non-secret key prefix (for example, `sk_QpCO`); `null` for deleted keys. |
| `input_tokens` | number | All input tokens, including cached input and Supercache writes. |
| `cached_tokens` | number | All cached input tokens, including regular cache and Supercache reads. |
| `regular_cached_tokens` | number | The `cached_tokens` subset billed at the regular cached-token rate. |
| `supercached_tokens` | number | The `cached_tokens` subset billed at the Supercache read rate. |
| `supercache_write_tokens` | number | The `input_tokens` subset billed at the Supercache write rate. |
### GET /v2/usage/tokens
Inference token counters (input/output/cached) over the range, as raw counts.
Only base token products contribute quantities. Sailbox usage and inference
surcharges do not add tokens. Cached input is already included in `input`.
| Parameter | Values | Default |
| --------- | ---------------------------------------- | ------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
```json theme={null}
{
"object": "usage.tokens",
"available": true,
"range": "24h",
"tokens": {
"total": 6159231,
"input": 5582132,
"output": 577099,
"cached": 2050614
}
}
```
### GET /v2/usage/tokens/timeseries
Raw inference token counts per time bucket, broken down by input/output/cached.
The same base-product and cached-input rules as `/tokens` apply.
| Parameter | Values | Default |
| --------- | ---------------------------------------- | ------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
```json theme={null}
{
"object": "usage.tokens.timeseries",
"available": true,
"range": "7d",
"series": [
{
"time_bucket": "2026-06-30T00:00:00Z",
"total": 4400000,
"input": 3400000,
"output": 1000000,
"cached": 150000
}
]
}
```
## Operational activity & latency
These routes report request counts, task activity, and latency. They all
accept the [`environment`](#common-parameters) filter.
### GET /v2/usage/activity
Consolidated completed-request counts and average latency over rolling windows.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------- | ------- |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
```json theme={null}
{
"object": "usage.activity",
"available": true,
"requests": {
"last_1m": 1,
"last_1h": 385,
"last_24h": 3251,
"last_7d": 55592
},
"latency": { "avg_1m_ms": 6114, "avg_1h_ms": 16494 }
}
```
### GET /v2/usage/activity/timeseries
Per-model completed-request counts over time.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------- | ------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
```json theme={null}
{
"object": "usage.activity.timeseries",
"available": true,
"metric": "requests",
"range": "24h",
"series": [
{
"time_bucket": "2026-07-01T00:00:00Z",
"model": "openai/gpt-oss-120b",
"count": 420
}
]
}
```
### GET /v2/usage/recent
The most recent requests, including active and finished requests when available.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------- | ------- |
| `limit` | `1`–`50` | `10` |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
```json theme={null}
{
"object": "usage.activity.recent",
"available": true,
"recent_requests": [
{
"response_id": "resp_019f383e-efed-78c0-8f06-f14e80dd7a0d",
"model": "zai-org/GLM-5.3",
"sla": "standard",
"status": "queued",
"created_at": "2026-07-06T16:24:36.589Z",
"updated_at": "2026-07-06T16:24:36.589Z"
}
]
}
```
`sla` is `null` when the request has no resolved completion window.
### GET /v2/usage/tasks
Paginated task activity with per-task token breakdowns, spanning both active and
finished requests.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------------------------------------------------------------- | --------- |
| `range` | `1h`, `24h` | `24h` |
| `status` | `queued`, `in_progress`, `completed`, `failed`, `cancelled` | all |
| `model` | model ID | all |
| `sla` | `asap`, `priority`, `balanced`, `flex` (`balanced` also covers requests sent with the legacy name `standard`) | all |
| `sort` | `created`, `latency` | `created` |
| `limit` | `1`–`100` | `25` |
| `after` | cursor from `next_cursor` | none |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
Only `1h` and `24h` are valid for `range`; any other value resolves to `24h`
(reported back as `effective_range`).
```json theme={null}
{
"object": "usage.activity.tasks",
"available": true,
"active_tasks_available": true,
"tasks": [
{
"response_id": "resp_019f383e-c2a9-74c6-bff5-2627e18abc4b",
"model": "openai/gpt-oss-120b",
"status": "completed",
"sla": "asap",
"created_at": "2026-07-06T16:24:25.001Z",
"updated_at": "2026-07-06T16:24:31.114Z",
"completed_at": "2026-07-06T16:24:31.114Z",
"duration_ms": 6114,
"input_tokens": 71,
"cached_input_tokens": 70,
"output_tokens": 39,
"reasoning_tokens": 0,
"total_tokens": 110
}
],
"next_cursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTA2VD...",
"effective_range": "24h",
"legacy_sla_filters": []
}
```
| Field | Type | Description |
| ------------------------ | -------------- | ------------------------------------------------------------------------------------- |
| `active_tasks_available` | boolean | `true` when in-flight (active) tasks could be merged into the page. |
| `sla` | string \| null | Completion window; `null` when unresolved. |
| `completed_at` | string \| null | `null` for tasks that have not finished. |
| `duration_ms` | number \| null | End-to-end duration; `null` until the task finishes. |
| `next_cursor` | string \| null | Pass as `after` to fetch the next page; `null` on the last page. |
| `effective_range` | string | The range actually applied (`1h` or `24h`). |
| `legacy_sla_filters` | array | Legacy SLA filter descriptors, when the org still has tasks under retired SLA labels. |
### GET /v2/usage/latency/turn
Turn latency distribution for a single request-to-response time, with p50/p95/p99,
aggregate and per-SLA.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------- | ------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
```json theme={null}
{
"object": "usage.latency.turn",
"available": true,
"aggregate": {
"n": 14210,
"avg_ms": 2050,
"p50_ms": 1800,
"p95_ms": 4200,
"p99_ms": 6100,
"buckets": [
{ "bucket_lo_ms": 0, "bucket_hi_ms": 1000, "count": 3200 },
{ "bucket_lo_ms": 8000, "bucket_hi_ms": null, "count": 140 }
],
"approx": false
},
"by_sla": {
"asap": {
"n": 9000,
"avg_ms": 1700,
"p50_ms": 1500,
"p95_ms": 3800,
"p99_ms": 5400,
"buckets": [],
"approx": false
}
}
}
```
| Field | Type | Description |
| ------------------------ | -------------- | ---------------------------------------------------------------------------------------------------------- |
| `buckets[].bucket_hi_ms` | number \| null | Upper edge of the histogram bucket; `null` for the open-ended top bucket. |
| `percentile_mode` | string | Optional: how percentiles were derived, when the server reports it. |
| `approx` | boolean | `true` when percentiles were sampled rather than computed exactly (omitted when false in the time series). |
### GET /v2/usage/latency/trajectory
Trajectory latency distribution for a multi-turn conversation, with the
same shape as [`/latency/turn`](#get-v2-usage-latency-turn). Trajectory
responses additionally carry `avg_turns_per_trajectory`, and may set
`approx: true` when percentiles are sampled.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------- | ------- |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
```json theme={null}
{
"object": "usage.latency.trajectory",
"available": true,
"aggregate": {
"n": 3200,
"avg_ms": 9400,
"p50_ms": 7600,
"p95_ms": 21000,
"p99_ms": 34000,
"buckets": [{ "bucket_lo_ms": 0, "bucket_hi_ms": 5000, "count": 900 }],
"approx": false,
"avg_turns_per_trajectory": 4.7
},
"by_sla": {}
}
```
### GET /v2/usage/latency/timeseries
Latency percentiles over time for turns or trajectories.
| Parameter | Values | Default |
| ------------- | ------------------------------------------------------- | ------- |
| `kind` | `turn`, `trajectory` | `turn` |
| `range` | `1h`, `6h`, `24h`, `7d`, `30d`, `period` | `24h` |
| `model` | model ID (only with `kind=turn`) | none |
| `sla` | completion window (only with `kind=turn`) | none |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` |
`model` and `sla` filters are only supported for `kind=turn`. Passing either
with `kind=trajectory` returns a 400.
```json theme={null}
{
"object": "usage.latency.timeseries",
"available": true,
"kind": "turn",
"range": "24h",
"series": [
{
"time_bucket": "2026-07-06T10:00:00Z",
"count": 420,
"avg_ms": 2000,
"p50_ms": 1800,
"p95_ms": 4100,
"p99_ms": 6000
}
]
}
```
Series points include `approx` and `avg_turns_per_trajectory` only when relevant
(both are omitted otherwise).
### GET /v2/usage
Completed-request latency and counts in **fixed time buckets**, with one row per
environment per bucket. Unlike the rolling-window routes above, this endpoint
takes an explicit `start`/`end`/`bucket_size` window, making it suited to
charting a specific time range at a fixed resolution.
| Parameter | Values | Default | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| `start` | RFC3339 timestamp | 24h ago | Inclusive window start. |
| `end` | RFC3339 timestamp | now | Exclusive window end. When omitted, aligned to the bucket boundary. |
| `bucket_size` | `1m`, `1h` | auto | Defaults to `1m` for windows up to 6 hours, otherwise `1h`. |
| `environment` | `all`, `dev`, `prod`, `beta`, or a comma-separated list | `all` | Customer-facing environment label. |
| `model` | model ID | none | Optional exact model filter. Unrecognized values match nothing. |
| `sla` | `asap`, `priority`, `balanced`, `flex` (`balanced` also covers requests sent with the legacy name `standard`) | none | Optional completion-window filter. Unrecognized values match nothing rather than returning a 400. |
A window that would produce more than 10,000 buckets for the chosen
`bucket_size` returns a 400. Widen `bucket_size` or shorten the window.
```json theme={null}
{
"object": "usage",
"available": true,
"start": "2026-07-05T16:00:00Z",
"end": "2026-07-06T16:00:00Z",
"bucket_size": "1h",
"buckets": [
{
"environment": "prod",
"bucket_start": "2026-07-05T16:00:00Z",
"bucket_end": "2026-07-05T17:00:00Z",
"completed_count": 12,
"latency_sum_ms": 24000,
"latency_count": 12,
"avg_latency_ms": 2000
}
]
}
```
Empty buckets are included (with zeroed counts) so a series has no gaps.
## Errors
Errors use the same envelope as the inference API. For usage-API errors, `code`
is set to the same string as `type`, and `param` is always `null`:
```json theme={null}
{
"error": {
"message": "status: must be one of: queued, in_progress, completed, failed, cancelled",
"type": "invalid_request_error",
"param": null,
"code": "invalid_request_error"
}
}
```
Authentication and rate-limit failures are produced by the shared API middleware
and may carry a distinct `code` (for example `invalid_api_key`) or `null` (a
missing `Authorization` header).
| HTTP status | `type` | `code` | When |
| ----------- | ----------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `invalid_request_error` | `invalid_request_error` | Invalid `date`, `start`, `end`, `bucket_size`, `environment`, `status`, `kind`, or `after` cursor; an unsupported `sla` on `/tasks` or `/latency/timeseries`; a `model`/`sla` filter with `kind=trajectory`; or a window exceeding 10,000 buckets on `GET /v2/usage`. An unknown `range` is **not** rejected. It falls back to the default. |
| 401 | `authentication_error` | `null` \| `invalid_api_key` | Missing (`code` null), invalid, or expired credentials. |
| 402 | `billing_error` | `credits_exhausted` | API key disabled due to insufficient credits. The error also includes a `billing_url`. |
| 403 | `missing_org_identity` | `missing_org_identity` | API key is not associated with an organization. |
| 429 | `rate_limit_error` | `rate_limited` | Too many concurrent requests. |
| 500 | `api_error` | `api_error` | Internal server error while fetching usage data. |
| 503 | `usage_unavailable` | `usage_unavailable` | Usage data is temporarily unavailable for the requested query. |
| 504 | `usage_query_timeout` | `usage_query_timeout` | Query timed out. The error object includes `"retryable": true` and the response sets a `Retry-After: 2` header. |
Endpoints return an empty/zeroed payload with HTTP 200 (not an error) when the
org has no billing account or no usage in the requested window.
# Introduction
Source: https://docs.sailresearch.com/voyages
Observability and timeline for long-running background agents on Sail
Voyages are Sail's telemetry layer for long-running background-agent tasks.
Add a few SDK calls to your existing agent harness. Sail records the run as a
trace of named agents, spans, events, Sail inference calls, and Sailbox execs.
Every Voyage gets its own page in the dashboard at `app.sailresearch.com`,
showing its recorded trajectory. The SDK returns the link from
`sail.voyage.dashboard_url()`, and the API returns it as `dashboard_url`.
While a Voyage is marked running, its page checks for newly received Voyage
events every five seconds while the page is visible. You can pause these
updates or refresh manually at any time. The **Event auto-refresh on** label
describes dashboard updates, not whether the agent process is healthy. Open
**Execution Trace → Raw events** to inspect the latest event window or page
forward through the full event history.
You keep control of the agent loop. Sail provides the runtime pieces
(Sailbox and inference) plus the Voyage timeline.
Voyages are not an agent framework. They do not own your planner, memory,
orchestration, tool abstractions, prompts, or retry policy. Use them with your
own Python scripts, LangGraph, CrewAI, job runners, subprocesses, or any custom
loop where you want visibility into what happened.
**Using a coding agent?** Start with the public [Sail skills
package](https://github.com/sailresearchco/sail-skills). Tell your agent: "Use
Sail Voyages to add automatic telemetry to my background agent workload. Keep
my harness, use the `sail` package, wrap the run with `sail.voyage.run(...,
version=1)`, add `@sail.agent` and `@sail.span` where they fit naturally, and
attribute Sail inference calls plus any Sailbox execs the workload already
uses." You do not need to know the individual skill names. The package gives
your coding agent the right Voyage instructions when they are relevant.
## Install
```bash theme={null}
pip install sail
export SAIL_API_KEY=sk_your_key_here
```
Both paths authenticate SDK processes: the SDK reads `SAIL_API_KEY` first and
falls back to the credential `sail auth login` stores under `~/.sail`. Use
`SAIL_API_KEY` for CI and deployments; logging in once is enough for local
scripts.
## When to use a Voyage
Use a Voyage when:
* An agent task runs for more than a few seconds and you want to see its
trajectory in real time.
* Multiple cooperating agents (Reviewer, TestRunner, GitHub-poster, etc.)
contribute to one logical task.
* You need a customer-visible record of what an agent did: events, model calls,
Sailbox execs, terminal status, and other evidence for debugging or auditing.
Do not use a Voyage for one-shot API calls. A single
`sail.inference.responses.create()` outside a Voyage works fine and shows up
in your inference dashboard without extra instrumentation.
## Mental model
```python theme={null}
with sail.voyage.run(name="research-agent", version=1):
researcher_step()
```
* A **Voyage** is one task.
* An **agent** is a named participant within the task (e.g., "Reviewer").
* A **span** is a logical step the agent performs (e.g., "draft-response").
* An **event** is a timestamped marker within a span.
* A **model call** is automatically recorded when you call Sail inference
inside a Voyage. If no span is active, the SDK creates an auto-span.
* A **Sailbox exec** is automatically recorded when you call `sb.exec()`
inside a Voyage. If no span is active, the SDK creates an auto-span.
## Minimal example
```python theme={null}
import sail
@sail.agent("Researcher")
@sail.span("draft answer")
def draft_answer(topic: str):
sail.voyage.event("research.started", payload={"topic": topic})
response = sail.inference.responses.create(
model="zai-org/GLM-5.3",
input=f"Give one concise research note about: {topic}",
background=False,
)
sail.voyage.event("research.finished", payload={"response_id": response["id"]})
with sail.voyage.run(name="research-demo", version=1):
draft_answer("long-term health benefits of running")
print(f"Voyage URL: {sail.voyage.dashboard_url()}")
```
Run this, then open the printed URL. You should see one Voyage with one
`Researcher` agent, one explicit span, two events, one attributed model call,
and terminal status `voyage completed`.
## What gets attributed automatically
When you call Sail inference inside a Voyage, the SDK attaches active Voyage,
agent, and span context so the model call appears in the dashboard. Decorators
are the most direct way to attribute function-shaped work:
```python theme={null}
@sail.agent("Reviewer")
@sail.span("draft")
def draft_review():
return sail.inference.responses.create(
model="zai-org/GLM-5.3",
input="Summarize this diff: ...",
background=False,
timeout=120,
)
```
When you call `sb.exec()` inside a Voyage, the SDK also carries the active
Voyage, agent, and span context into the Sailbox command. `sb.exec(...)` returns
a request handle. Call `.wait()` for foreground commands so your program
observes completion, return code, stdout/stderr tails, and the attributed exec
row:
```python theme={null}
@sail.agent("TestRunner")
@sail.span("unit-tests")
def run_tests():
req = sb.exec("pytest -q", timeout=600)
req.wait()
```
You do not need to pass IDs by hand. If no span is active, Sail inference and
Sailbox execs still get a timed auto-span named from the calling code when
possible. Auto-spans do not invent agents, so declare an agent when the
dashboard should show ownership.
## Explicit vs auto spans
Use an explicit span when the step name matters to humans:
```python theme={null}
@sail.agent("Researcher")
@sail.span("score evidence")
def score_evidence():
sail.inference.responses.create(model="zai-org/GLM-5.3", input="...")
```
If you leave the span out, Sail still captures SDK-owned work. The model call
below is recorded under the `Researcher` agent and wrapped in a timed auto-span:
```python theme={null}
@sail.agent("Researcher")
def score_evidence():
sail.inference.responses.create(model="zai-org/GLM-5.3", input="...")
```
Auto-spans are useful when you are adding telemetry to an existing agent
harness. Add `@sail.span(...)` when you want a specific step name, payload, or
parent/child shape.
## Multiple agents in one Voyage
A real code-review agent has at least three distinct participants. A `GitHub`
agent clones the repository, a `TestRunner` runs the suite in a Sailbox, and a
`Reviewer` drafts the summary with inference. Declare each one with
`@sail.agent(...)`, and the dashboard shows three named agents under one
Voyage, each with its own spans and events. See the
[Voyages Patterns](./voyages-patterns) guide for the full multi-agent example.
## Terminal status
Every Voyage needs exactly one terminal event before the controller process
exits. `with sail.voyage.run(...)` handles it: clean exit emits
`voyage.completed`. An exception emits `voyage.failed` and re-raises.
Terminal status is first-terminal-wins. Events after the terminal are
best-effort delivery only.
```python theme={null}
with sail.voyage.run(name="task"):
do_work()
```
Controllers whose create and terminal sites live in different places use
the `create()` primitive and call `voyage.complete()` / `voyage.fail()`
themselves.
## Stop recording and delete
Use `voyage.cancel()` or `sail.voyage.cancel()` to stop recording a running
Voyage:
```python theme={null}
voyage = sail.voyage.create(name="overnight-eval")
...
voyage.cancel()
```
Cancel marks the server-side Voyage `cancelled` and stops this SDK instance
from recording further events. The server does not reject late events from
other sources, and cancel does **not** terminate external or non-Sailbox agent
code. If you own that process, stop it separately.
You can delete a finished Voyage from its dashboard detail page; this removes
its history and content from the dashboard and API. Security audit records are
retained. There is no API or SDK delete yet.
## What to read next
* [Voyages Quickstart](./voyages-quickstart): copy-paste-ready 60-second
example.
* [Voyages Patterns](./voyages-patterns): multi-agent and child-attach
for subprocesses.
* [Sail skills package](https://github.com/sailresearchco/sail-skills):
agent-ready playbooks for building, instrumenting, and debugging Voyage runs.
* [Building Agents](./agents): Sail's Responses API and tool calling.
* [Sailboxes](./sailboxes): the runtime substrate.
# Patterns & best practices
Source: https://docs.sailresearch.com/voyages-patterns
Production patterns for multi-agent Sailbox work and subprocess attach
Two patterns matter most once you move beyond the quickstart: recording
multiple agents in one Voyage and attaching subprocesses to a parent Voyage.
Use decorators for function-shaped work. Use a `with` span only when a
temporary block is clearer than a named function.
## Multi-agent
When one logical task involves multiple cooperating agents (a Reviewer, a
TestRunner, a GitHub-poster), each agent gets its own context, spans, and
attributed work. They all share one Voyage, so the dashboard shows one trace
for the full background agent workload.
```python theme={null}
import sail
sb = sail.Sailbox.create(
app=sail.App.find(name="review-agent", mint_if_missing=True),
image=sail.Image.debian_arm64.apt_install("git").build(),
)
voyage = sail.voyage.create(
name="multi-agent-code-review",
sailbox_id=sb.sailbox_id,
metadata={"pr_number": 1234},
)
# create() is used here because the Sailbox is created before the Voyage
# finishes. For simpler scripts, wrap the work in sail.voyage.run(...).
@sail.agent("GitHub", role="source_control")
@sail.span("clone")
def clone_repo():
sb.exec(
"git clone --depth 1 https://github.com/public/repo.git /tmp/repo",
timeout=120,
).wait()
@sail.agent("TestRunner", role="executor")
@sail.span("unit-tests")
def run_tests():
sb.exec("cd /tmp/repo && pytest -q", timeout=600).wait()
@sail.agent("Reviewer", role="reviewer")
@sail.span("draft-review")
def draft_review():
response = sail.inference.responses.create(
model="zai-org/GLM-5.3",
input="Review the diff in /tmp/repo...",
background=False,
timeout=120,
)
sail.voyage.event("review.drafted", payload={"response_id": response["id"]})
clone_repo()
run_tests()
draft_review()
voyage.complete(message="review posted")
```
**Naming convention:** the first (and only required) argument is the
display name ("Reviewer"). The stable attribution key is derived from it
automatically. `role=` is the optional cohort taxonomy ("reviewer",
"test\_runner", "source\_control", "executor"). The dashboard groups runs by
name and offers role as a categorical filter. Pass `slug=` (advanced) to pin
the attribution key across display renames.
## Sailbox exec attribution and `.wait()`
Sailbox commands run as first-class Voyage evidence when they happen inside a
Voyage. Keep the agent context active around the call so the dashboard can show
which participant owned the command:
```python theme={null}
@sail.agent("TestRunner", role="executor")
@sail.span("unit-tests")
def run_tests():
result = sb.exec("pytest -q", timeout=600).wait()
sail.voyage.event("tests.finished", payload={"exit_code": result.exit_code})
```
`sb.exec(...)` returns immediately with a request handle. For foreground
commands, call `.wait()` to observe completion, return code, and output tails.
If there is no active span, Sail creates an auto-span for the exec. For
foreground commands, that span closes when `.wait()` observes the result.
Use explicit spans for steps you want named in the product. Let auto-spans cover
low-level calls when you are migrating an existing harness and only need
attribution with minimal code changes.
## Child-process attach
When the controller spawns subprocesses (e.g., a parallel test runner),
the subprocess should attach to the parent's Voyage rather than create
its own. The parent exports `SAIL_VOYAGE_ID`. The child calls
`sail.voyage.attach()`, which reads it:
```python theme={null}
# parent.py
import os
import subprocess
import sail
with sail.voyage.run(name="parent-with-children") as voyage:
@sail.agent("Orchestrator", role="planner")
@sail.span("spawn-workers")
def spawn_workers():
subprocess.run(
["python", "worker.py"],
env={**os.environ, **voyage.child_env()},
check=True,
)
spawn_workers()
```
`child_env()` returns the handoff env (`SAIL_VOYAGE_ID`, plus the active
agent context as the child's `SAIL_AGENT_*` defaults). It returns `{}` when
telemetry is disabled, so the same code runs keyless.
```python theme={null}
# worker.py
import sail
# Reads SAIL_VOYAGE_ID from env and joins the parent's Voyage.
voyage = sail.voyage.attach()
@sail.agent("Worker", role="executor")
@sail.span("do-work")
def do_work():
sail.voyage.event("worker.tick", payload={"step": 1})
do_work()
# Note: do NOT call voyage.complete() in the child.
# The parent owns terminal status. First-terminal-wins.
```
## Common cross-pattern pitfalls
* **Do not complete the Voyage from inside an agent block.** Call
`voyage.complete()` at the top level, after all agent contexts have
exited.
* **Do not reuse a Voyage across logical tasks.** One Voyage per task. If
the agent does N tasks, record N Voyages.
* **Do not put secrets in event payloads.** Sail applies server-side redaction,
but the safest pattern is to summarize or hash sensitive values before
recording them.
* **Use explicit handles when one execution context juggles Voyages.**
Concurrent async tasks can each select their own current Voyage. A raw thread
or other context that never selects one uses the process-wide fallback.
When one context switches among multiple Voyages, call methods on the
intended `Voyage` object and pass `voyage=` to inference wrappers instead of
relying on implicit attribution.
## Reference
* [Voyages overview](./voyages)
* [Voyages quickstart](./voyages-quickstart)
* [Sail skills package](https://github.com/sailresearchco/sail-skills)
* [Sailboxes](./sailboxes)
* SDK package: [`sail` on PyPI](https://pypi.org/project/sail/)
# Quickstart
Source: https://docs.sailresearch.com/voyages-quickstart
Ship a Voyage-instrumented agent in 60 seconds
This guide gets you from zero to a working Voyage in under a minute of
reading. For the conceptual overview, see [Voyages](./voyages).
Working through this with a coding agent? Point it at the
[Sail skills package](https://github.com/sailresearchco/sail-skills) and ask:
```text theme={null}
Build a small background agent on Sail with Voyage telemetry. Keep the
agent harness simple, use the sail package, wrap the run with
sail.voyage.run(..., version=1), use @sail.agent and @sail.span for
attribution, make the dashboard trace show model calls, and attribute Sailbox
execs only if the workload already uses a Sailbox.
```
## Prerequisites
* Python 3.9+
* A Sail API key
* The Sail SDK: `pip install sail`
## Step 1: install + auth
```bash theme={null}
pip install sail
export SAIL_API_KEY=sk_your_key_here
```
`SAIL_API_KEY` always wins. When it is unset, the SDK uses the credential
stored by `sail auth login` under `~/.sail`.
## Step 2: write the minimal Voyage
```python theme={null}
# voyage_hello.py
import sail
@sail.agent("Researcher")
@sail.span("say hello")
def say_hello():
sail.voyage.event("hello.fired", payload={"message": "hi from sail"})
response = sail.inference.responses.create(
model="zai-org/GLM-5.3",
input="In one sentence, say hello.",
background=False,
)
sail.voyage.event("inference.done", payload={"response_id": response["id"]})
with sail.voyage.run(
name="hello-voyage",
version=1,
metadata={"example": "quickstart"},
):
say_hello()
print(f"Open: {sail.voyage.dashboard_url()}")
```
`run()` emits `voyage.completed` when the block exits cleanly. If the block
raises, it emits `voyage.failed` and re-raises. No try/except needed.
## Step 3: run it
```bash theme={null}
python voyage_hello.py
```
You should see one line printed:
```
Open: https://app.sailresearch.com/prod/voyages/voy_...
```
## Step 4: verify in the dashboard
Open the printed URL. You should see:
* **Status:** `voyage completed`
* **Agents:** 1 (`Researcher`)
* **Events:** 2 (`hello.fired`, `inference.done`)
* **Model calls:** 1 (`zai-org/GLM-5.3`, status `completed`)
* **Execution Trace:** one agent block with one span, two events nested
underneath, one model row.
You now have a Voyage: a dashboard trace for a background agent run, including
events, model-call attribution, and terminal status.
## Decorators
For function-shaped work, decorate instead of nesting context managers. You get
the same events, spans, and attribution:
```python theme={null}
import sail
@sail.agent("Researcher")
@sail.span()
def say_hello():
sail.voyage.event("greeting.sent", payload={"channel": "stdout"})
with sail.voyage.run(name="hello-voyage", version=1):
say_hello()
```
Calls you do not wrap are still captured. Sail inference and Sailbox execs made
inside a Voyage get automatic, timed spans named after your calling code and
marked "auto" in the dashboard.
## What to do next
* Add a Sailbox: see the [Sailboxes guide](./sailboxes) for creating a
long-running sandboxed VM, then pass `sailbox_id=sb.sailbox_id` to
`sail.voyage.create()` so the Voyage is bound to that Sailbox.
* Prefer an agent-guided setup? Use the
[Sail skills package](https://github.com/sailresearchco/sail-skills) and ask
your coding agent to build or instrument a background agent with Voyage
telemetry.
* Add a second agent: see [Voyages Patterns → Multi-agent](./voyages-patterns#multi-agent).
* Something looking wrong? Install the
[Sail skills](https://github.com/sailresearchco/sail-skills) and use the
`sail-voyage-debugging` skill.
## Common first-run gotchas
* **Voyage doesn't appear in dashboard.** Make sure `SAIL_API_KEY` is set to
a valid Sail API key (`sk_...`). Events are recorded against the key's
org, so a missing or wrong-org key means nothing shows up.
* **Process exits without a terminal event.** The Voyage stays
"in progress" forever (a bounded best-effort flush at exit delivers
trailing events, but can still drop them if the network is down. It also
never marks the Voyage terminal). Use `with sail.voyage.run(...)` so the
terminal state is emitted for you, or call `voyage.complete()` /
`voyage.fail()` yourself.
* **Unexpected "auto" spans.** If a Sail inference call or Sailbox exec runs
inside a Voyage with no active span, the SDK creates a timed span for it
automatically. That is expected. Add `@sail.span(...)` only when you want to
choose the step name yourself.
* **`CERTIFICATE_VERIFY_FAILED` on macOS Python.** Some python.org installs do
not have a usable root CA bundle. Install `certifi` and point Python at it:
`python -m pip install certifi`, then
`export SSL_CERT_FILE="$(python -c 'import certifi; print(certifi.where())')"`.
* **Unsupported model.** Use a model id from Sail, not necessarily the model
your coding agent is using. Start with `zai-org/GLM-5.3`; to list your
account's available models, call `GET https://api.sailresearch.com/v1/models`
with your `SAIL_API_KEY`.
# Voyages
Source: https://docs.sailresearch.com/voyages-sdk
The sail.voyage API: record agent and task trajectories
`sail.voyage` is 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
[inference calls](/voyages-sdk-inference). For a guided introduction, see the
[Voyages guide](/voyages); this page is the API reference.
```python theme={null}
import sail
@sail.agent("Solver")
@sail.span("call model")
def solve():
sail.voyage.event("model.called", payload={"model": "zai-org/GLM-5.3"})
with sail.voyage.run(name="overnight-eval", version=1):
solve()
print(sail.voyage.id(), sail.voyage.dashboard_url())
```
## Two ways to call
Every operation is available two ways:
* **Module-level helpers** (`sail.voyage.event(...)`, `sail.voyage.span(...)`,
…) act on the **current Voyage** of your execution context, set by
`create()`/`attach()` (see the note below).
* **Methods on the `Voyage` object** returned by `create()`/`attach()` act on
that specific Voyage.
They behave identically; the module-level helpers just save you from threading
the `Voyage` object through your code.
The current Voyage follows Python `contextvars` with a process-wide fallback:
concurrent tasks that each start their own Voyage keep their own attribution,
and a context that never started one (a raw `threading.Thread`, code after
`asyncio.run` returns) uses the process's most recently started Voyage. Span
and agent contexts are strictly context-scoped, so a raw thread does not
inherit the active span/agent but can still use the current Voyage for
inference correlation.
### Async forms
Networked operations expose an async form through `.aio`. Module-level
`create` and `attach` provide `create.aio()` and `attach.aio()`. The
`complete`, `fail`, `cancel`, and `flush` methods provide `.aio` forms both on
the module and on a `Voyage` object. `run()` supports `async with`:
```python theme={null}
async with sail.voyage.run(name="async-eval", version=1) as voyage:
await sail.inference.responses.create.aio(
model="zai-org/GLM-5.3",
input="Summarize this run.",
)
await voyage.flush.aio()
```
## sail.voyage.run
```python theme={null}
def run(
name: str,
*,
version: int | None = None,
metadata: dict | None = None,
sailbox_id: str | None = None,
) -> ContextManager[Voyage | NoopVoyage]
```
This is the recommended entry point. It wraps one block of work in a single
Voyage and handles the terminal lifecycle for you. Use `with` for synchronous
code or `async with` when the work runs in an event loop.
```python theme={null}
with sail.voyage.run("code-review", version=1) as voyage:
do_work()
```
Creates the Voyage on enter (same arguments and semantics as
[`create()`](#sail-voyage-create); always creates, never reads
`SAIL_VOYAGE_ID`), emits `voyage.completed` on clean exit, and on an
exception emits `voyage.failed` with the exception's type and message, then
re-raises. Terminal delivery is the same bounded best-effort flush as
`complete()`/`fail()`. A `voyage.flush()` inside the block confirms only
events emitted before that call, not the terminal event emitted on exit. Use
`create()` plus `complete()`/`fail()` and a following `flush()` when terminal
delivery must be raise-on-failure confirmed. Without `SAIL_API_KEY` the block
runs with telemetry disabled. If your start and finish happen in different
parts of your code, use `create()` with `complete()`/`fail()` instead.
## sail.voyage.create
```python theme={null}
def create(
name: str,
*,
version: int | None = None,
metadata: dict | None = None,
sailbox_id: str | None = None,
) -> Voyage | NoopVoyage
```
Creates a new Voyage, makes it the current Voyage for the execution context,
and updates the process-wide fallback used by contexts that have not selected
one. It emits `voyage.started`. Always creates, even when `SAIL_VOYAGE_ID` is
set in the environment; a child process joining its parent's Voyage uses
[`attach()`](#sail-voyage-attach) instead.
| Parameter | Default | Description |
| ------------ | ------- | -------------------------------------------------------------------------- |
| `name` | | Required. The series name the dashboard groups runs under. |
| `version` | `None` | Optional positive integer; bump when the harness/prompts/model mix change. |
| `metadata` | `None` | JSON object (≤ 64 KiB) attached to the Voyage. |
| `sailbox_id` | `None` | Bind the Voyage to a Sailbox. Falls back to `SAILBOX_ID`. |
**Returns** a [`Voyage`](#the-voyage-object). When `SAIL_API_KEY` is absent it
returns a no-op Voyage instead (see below).
## sail.voyage.attach
```python theme={null}
def attach(voyage_id: str | None = None) -> Voyage | NoopVoyage
```
Attaches to an existing Voyage, makes it current for the execution context,
and updates the process-wide fallback. `voyage_id` defaults to
`SAIL_VOYAGE_ID`, the handoff a parent process sets so its children join the
parent's Voyage (see
[child-process attach](/voyages-patterns#child-process-attach)). Raises
`ValueError` when neither is provided and telemetry is enabled; without
`SAIL_API_KEY` it returns a no-op Voyage instead, so a keyless child keeps
running with telemetry disabled. Attaching does not emit a second
`voyage.started`.
### No-op when unauthenticated
If `SAIL_API_KEY` is not set, `create()` and `attach()` return a `NoopVoyage`:
no Voyage is created, no network calls are made, and `id()` /
`dashboard_url()` return `None`. Every method is a safe no-op. This lets the
same script run locally without credentials. (Sailbox and inference APIs
still require `SAIL_API_KEY`.)
## The Voyage object
`create()` and `attach()` return a `Voyage` with these attributes:
| Attribute | Type | Description |
| --------------- | ------------- | --------------------------------------- |
| `id` | `str \| None` | Voyage id (`None` on a no-op Voyage). |
| `dashboard_url` | `str \| None` | Dashboard URL for the Voyage. |
| `status` | `str \| None` | Latest known terminal/lifecycle status. |
| `name` | `str \| None` | Voyage name. |
| `version` | `int \| None` | Voyage version. |
| `sailbox_id` | `str \| None` | Bound Sailbox, if any. |
| `metadata` | `dict` | Metadata supplied at creation. |
It exposes the same operations as the module-level helpers below
(`event`, `span`, `agent`, `error`, `complete`, `fail`, `flush`, `headers`).
## event
```python theme={null}
def event(
kind: str,
level: str = "info",
message: str | None = None,
payload: dict | None = None,
*,
span_id: str | None = None,
parent_span_id: str | None = None,
error_type: str | None = None,
occurred_at: str | None = None,
sequence_id: int | None = None,
) -> None
```
Records a timestamped event on the current span/agent. Agent attribution
comes from the enclosing [`agent()`](#agent) context, or from the
`SAIL_AGENT_*` env defaults when no context is active. There is no
per-event override; a one-shot attributed event is
`with voyage.agent(...): voyage.event(...)`. Events are buffered locally and
flushed by a background thread; `event()` validates input, enqueues quickly,
and does not raise network errors.
| Parameter | Default | Description |
| ---------------------------- | -------- | ----------------------------------------------------------- |
| `kind` | required | Event kind/name (≤ 128 characters, non-empty). |
| `level` | `"info"` | One of `debug`, `info`, `warn`, `error`. |
| `message` | `None` | Human-readable message. Truncated at 4 KiB with a warning. |
| `payload` | `None` | JSON object (≤ 64 KiB). |
| `span_id` / `parent_span_id` | `None` | Override span placement; default from the active span. |
| `error_type` | `None` | Error class name for error events. |
| `occurred_at` | `None` | RFC3339 timestamp with timezone; defaults to now. |
| `sequence_id` | `None` | Explicit monotonic ordering id; auto-assigned when omitted. |
## span
```python theme={null}
def span(
span_name: str | None = None,
*,
message: str | None = None,
payload: dict | None = None,
span_id: str | None = None,
parent_span_id: str | None = None,
) -> ContextManager # also usable as a decorator
```
Usable as a context manager or a **decorator**. `@sail.span()` names the
span after the decorated function's `__qualname__`; the `with` form requires
a name. The decorator resolves the current Voyage at call time, so
module-level decoration before `create()` attributes correctly. Decorating a
generator function raises `TypeError` (the context would close at generator
creation); `async def` is fully supported.
```python theme={null}
@sail.span()
def fetch_sources(urls):
...
```
Returns a context manager that emits `span.started` on enter and
`span.completed` (or `span.failed`, with the exception type) on exit. Spans
nest: a span opened inside another becomes its child automatically. A span
carries no agent identity of its own. Wrap it in
[`agent()`](#agent) to attribute it and everything inside it to a named agent.
### Span outcomes
The yielded span object accepts outcome data via `merge_payload()`. The
terminal event carries the started payload shallow-merged with everything
merged during the span; outcome keys win on conflict, and repeated calls
accumulate. The `span.started` event is unchanged, and outcomes ride
`span.failed` too. Partial results recorded before a crash are kept.
```python theme={null}
with voyage.span("score-subject", payload={"subject": "tennis"}) as s:
result = score(subject)
s.merge_payload({"score": result.score, "verdict": result.verdict})
```
```python theme={null}
with voyage.agent("Reviewer"):
with voyage.span("draft-review"):
voyage.event("review.drafted")
```
### Auto spans
When Sail inference or a Sailbox exec runs inside a current Voyage with no
active span, the SDK synthesizes a timed span for that operation. The generated
span is marked as auto in the dashboard and named from the calling code when
possible. Explicit spans always win; auto-spans only fill gaps where you
declared nothing.
```python theme={null}
@sail.agent("Researcher")
def collect():
# No active span: this call gets a timed auto-span.
sail.inference.responses.create(model="zai-org/GLM-5.3", input="...")
```
Sailbox commands are covered the same way. A foreground command's auto-span
lasts until the command finishes; a background command's auto-span covers only
starting it, not the detached run.
## agent
```python theme={null}
def agent(
name: str,
*,
role: str | None = None,
slug: str | None = None,
) -> ContextManager
```
Returns a context manager that marks all events and inference calls inside it
as belonging to a named agent. `name` (the only required argument) is the
display identity shown in the dashboard; the stable attribution key is derived
from it automatically (lowercased, ASCII, hyphenated). `role=` is an optional
grouping label for filtering across workflows. `slug=` (advanced) pins the
attribution key explicitly. Use it when renaming a display name should keep
one identity, or when a child process must attach as the same agent.
```python theme={null}
with voyage.agent("Reviewer"):
...
```
### Decorator form
`agent()` and `span()` also work as decorators, which is the most direct way
to instrument a whole function. `sail.agent` and `sail.span` are top-level
re-exports of the same objects.
```python theme={null}
import sail
@sail.agent("Researcher")
@sail.span()
def collect_sources(urls):
return [fetch(url) for url in urls]
```
Each call enters a fresh agent/span frame (concurrent calls don't share
state) and emits the same events as the `with` form.
## error
```python theme={null}
def error(
error_type: str | None = None,
message: str | None = None,
payload: dict | None = None,
) -> None
```
Records an error-level `voyage.error` event without terminating the Voyage.
## complete
```python theme={null}
def complete(message: str | None = None, payload: dict | None = None) -> None
```
Emits `voyage.completed` and performs a bounded (10s) best-effort flush.
Always call `complete()` (or `fail()`) before your process exits. The first
terminal event wins; any events emitted after it are delivered best-effort. On
delivery failure it warns and returns (the event stays
buffered for background/atexit retry) instead of raising; call
[`flush()`](#flush) afterwards if you need raise-on-failure delivery
confirmation.
## fail
```python theme={null}
def fail(
error_type: str = "harness_error",
message: str | None = None,
payload: dict | None = None,
) -> None
```
Emits `voyage.failed` and performs the same bounded best-effort flush as
`complete()`. It warns instead of raising on delivery failure. `error_type`
must be non-empty.
```python theme={null}
voyage = sail.voyage.create(name="task")
try:
do_work()
voyage.complete(message="ok")
except Exception as exc:
voyage.fail(error_type=exc.__class__.__name__, message=str(exc))
raise
```
## flush
```python theme={null}
def flush(timeout: float | None = None) -> None
```
Blocks until all buffered events are delivered, raising on delivery failure. A
bounded best-effort flush also runs automatically at process exit, but that is
not a substitute for `complete()`/`fail()` for product-critical terminal state.
## headers
```python theme={null}
def headers(existing: Mapping[str, str] | None = None) -> dict[str, str]
```
Returns a copy of `existing` with the full attribution context set:
`X-Sail-Voyage-Id` for the current Voyage, plus `X-Sail-Voyage-Span-Id` and
`X-Sail-Voyage-Agent-Id` for the span/agent active at call time. Use this to
correlate a raw HTTP/OpenAI client with the Voyage when you can't use the
[inference wrappers](/voyages-sdk-inference). Compute it per request, never
once at client construction, so each call carries the context actually
active when it is made.
## child\_env
```python theme={null}
def child_env(*, agent: bool = True) -> dict[str, str]
```
Env vars a child process needs to [`attach()`](#sail-voyage-attach) to the
current Voyage: merge into the child's environment instead of exporting
`SAIL_VOYAGE_ID` by hand. With `agent=True` (default) the active `agent()`
context rides along as the child's `SAIL_AGENT_*` defaults. Returns `{}`
when telemetry is disabled, so the handoff is keyless-safe.
```python theme={null}
subprocess.run(["python", "worker.py"], env={**os.environ, **sail.voyage.child_env()})
```
## disable
```python theme={null}
def disable() -> NoopVoyage
```
Disables Voyage telemetry for this process by installing a no-op current
Voyage, the public form of the state `create()`/`attach()` enter when
`SAIL_API_KEY` is absent. For controllers that catch a startup telemetry
failure and choose to continue unobserved.
## Module helpers
```python theme={null}
def id() -> str | None
def dashboard_url() -> str | None
def cancel() -> None
```
`sail.voyage.id()` and `sail.voyage.dashboard_url()` return the current
Voyage's id and dashboard URL (or `None` when there is no current Voyage or
it is a no-op).
`sail.voyage.cancel()` delegates to the current Voyage and marks it cancelled
on the server. It does not emit a `voyage.cancelled` event. With no current
Voyage, or when the current Voyage is a no-op because telemetry is disabled, it
returns without network I/O.
## Environment variables
| Variable | Purpose |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SAIL_API_KEY` | Required for real Voyage events and inference. A Sail API key (`sk_...`). |
| `SAILBOX_ID` | Default `sailbox_id` attached to new Voyages. |
| `SAIL_VOYAGE_ID` | Default voyage id for `attach()`. |
| `SAIL_AGENT_ID` | Default event `agent_id`; normalized to the derived slug form, so it matches `agent()` in a parent process. |
| `SAIL_AGENT_NAME` | Default event `agent_name`. |
| `SAIL_AGENT_ROLE` | Default event `agent_role`. |
| `SAIL_VOYAGE_AUTO_SPANS` | Set to `0`, `false`, `no`, or `off` to disable synthesized spans for un-spanned Sail inference and Sailbox execs. |
| `SAIL_VOYAGE_DEBUG` | Warn on every occurrence of a degradation, not just the first. By default each kind of degradation (no-op mode, dropped events, stubbed payloads, failed flushes) warns once. |
## Validation and delivery semantics
* **Validation:** `kind` ≤ 128 characters; `level` one of `debug`/`info`/`warn`/`error`;
`payload` and `metadata` must be JSON objects; `occurred_at` must be
RFC3339 with a timezone; `version` must be a positive integer. Oversized
human text does not raise: a `message` or span name over 4 KiB is
truncated, and a `payload` over 64 KiB is replaced by a
`{"_truncated": true, "_original_bytes": N}` stub, each with a warning.
`metadata` over 64 KiB raises at `create()` (startup is when failing is
cheapest). `create()` and `attach()` validate their arguments before the
no-key gate, so a malformed call raises even when telemetry is disabled.
* **Buffering:** events go to a bounded local buffer. When it is full, the
oldest non-terminal events are dropped first and a `sdk.events_dropped`
notice is emitted; lifecycle events (`voyage.started` plus the terminal
`voyage.completed`/`voyage.failed`) are preserved.
* **Delivery semantics:** `flush()` blocks and raises delivery errors
(`sail.VoyageError` and subclasses), the strict primitive. `complete()`
and `fail()` perform a bounded best-effort flush and warn instead of
raising. `cancel()` calls the dedicated cancel endpoint and raises
`sail.VoyageError` subclasses on HTTP failure. `event()` never raises
network errors.
* **Cancel semantics:** `cancel()` marks the server-side Voyage cancelled
without enqueueing or posting a `voyage.cancelled` event. It stops recording
future trace updates; it does not terminate external agent code. Without
`SAIL_API_KEY`, `NoopVoyage.cancel()` is a safe no-op.
# Errors
Source: https://docs.sailresearch.com/voyages-sdk-errors
Voyage and inference exception taxonomy
Voyage and inference exceptions derive from `sail.SailError`, the base for
every SDK error. `flush()` raises these on delivery failure; `complete()` and
`fail()` warn instead of raising (the terminal event stays buffered for
retry); `event()` never raises network errors.
```text theme={null}
SailError
├─ VoyageError
│ └─ VoyageHTTPError
│ └─ VoyageNotFoundError
├─ InferenceError
│ └─ InferenceHTTPError
```
```python theme={null}
import sail
try:
sail.voyage.complete(message="done")
sail.voyage.flush() # raise-on-failure delivery confirmation
except sail.VoyageNotFoundError:
... # the Voyage no longer exists for this API key
except sail.VoyageError as exc:
... # any other Voyage delivery failure
```
All SDK network calls are bounded: inference requests default to a 600s
timeout, voyage lifecycle calls to 10s, and `flush()` bounds each batch
send (60s) even when called without a timeout. A wedged connection
surfaces as one of the errors below rather than blocking forever.
## Recommended lifecycle
Most agents should use `with sail.voyage.run(...):`. It emits
`voyage.completed` on a clean exit, emits `voyage.failed` and re-raises on an
exception, and performs the same bounded best-effort terminal delivery as the
manual helpers.
Use `create()` / `complete()` / `fail()` only when your controller's start and
terminal sites are separated, or when you need strict raise-on-failure
confirmation of the terminal lifecycle event. Call `flush()` after the terminal
helper so `voyage.completed` or `voyage.failed` is already in the delivery
buffer:
```python theme={null}
voyage = sail.voyage.create(name="nightly-research", version=1)
try:
do_work()
except BaseException as exc:
voyage.fail(error_type="harness_error", message=str(exc))
voyage.flush()
raise
else:
voyage.complete(message="done")
voyage.flush()
```
## VoyageError
Base class for Voyage SDK delivery errors, such as a flush that times out or
cannot deliver a required terminal event. Subclass of `SailError`.
## VoyageHTTPError
Raised when the Voyage API returns an HTTP error. Subclass of `VoyageError`.
| Attribute | Type | Description |
| ------------- | -------------- | --------------------------- |
| `status_code` | `int` | HTTP status code. |
| `response` | `dict \| None` | Parsed error response body. |
## VoyageNotFoundError
Raised when a Voyage cannot be found for the current API key (HTTP 404).
Subclass of `VoyageHTTPError`, so it carries `status_code` and `response`.
## InferenceError
Base class for [inference](/voyages-sdk-inference) wrapper errors. Raised, for
example, when an unsupported wrapper option such as `stream=True` is passed, or
when no API key is configured. Subclass of `SailError`.
## InferenceHTTPError
Raised when a Sail inference endpoint returns a non-2xx response. Subclass of
`InferenceError`.
| Attribute | Type | Description |
| ------------- | -------------- | --------------------------- |
| `status_code` | `int` | HTTP status code. |
| `response` | `dict \| None` | Parsed error response body. |
# Inference
Source: https://docs.sailresearch.com/voyages-sdk-inference
Voyage-correlated wrappers over Sail's inference endpoints
`sail.inference` provides thin wrappers over Sail's hosted inference endpoints.
They POST the JSON payload as given and return the raw JSON response as a
`dict`. When a [Voyage](/voyages-sdk) is active, the wrappers attach
correlation headers so the model call shows up on the Voyage timeline, scoped
to the active span and agent. Raw clients can attach the same headers to
Responses, Chat Completions, or Anthropic Messages requests.
```python theme={null}
import sail
resp = sail.inference.responses.create(
model="zai-org/GLM-5.3",
input="Say hello in one sentence.",
)
chat = sail.inference.chat.completions.create(
model="zai-org/GLM-5.3",
messages=[{"role": "user", "content": "hello"}],
)
```
Async code uses the same methods through `.aio`:
```python theme={null}
resp = await sail.inference.responses.create.aio(
model="zai-org/GLM-5.3",
input="Say hello in one sentence.",
)
```
## Context-window admission
For text input sent through `responses.create` or
`chat.completions.create`, Sail reserves at least 512 tokens, or 0.5% on
larger context windows, for model-specific request formatting. The input token
count plus the requested maximum output must fit in the remaining budget.
Responses requests that provide `raw_prompt_tokens` use their exact raw-token
count without this reserve.
## responses.create
```python theme={null}
def create(
*,
voyage: Voyage | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | None = None,
**payload,
) -> dict
```
POSTs `payload` to `/v1/responses` and returns the raw JSON response dict.
When polling a background response, treat `completed`, `incomplete`, `failed`,
and `cancelled` as terminal. An `incomplete` response can include `output` and
`usage`; inspect `incomplete_details.reason` before deciding whether to use the
output or submit another request. Partial output from a `max_output_tokens`
stop may be usable. Handle a `content_filter` stop separately.
| Parameter | Default | Description |
| ----------- | ------- | ------------------------------------------------------------------ |
| `**payload` | | The request body (e.g. `model`, `input`). Sent as-is. |
| `voyage` | `None` | Correlate with an explicit Voyage. Defaults to the current Voyage. |
| `headers` | `None` | Extra request headers to merge. |
| `timeout` | `None` | Request timeout in seconds; defaults to a bounded 600s. |
## responses.retrieve
```python theme={null}
def retrieve(
response_id: str,
*,
voyage: Voyage | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | None = None,
) -> dict
```
Fetches `/v1/responses/{response_id}` and returns the raw JSON response dict.
The wrapper does not poll automatically. Call it again only while `status` is
nonterminal.
## chat.completions.create
```python theme={null}
def create(
*,
voyage: Voyage | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | None = None,
**payload,
) -> dict
```
POSTs `payload` to `/v1/chat/completions` and returns the raw JSON response
dict. Same parameters as `responses.create`.
## Voyage correlation
If a current Voyage exists (or you pass `voyage=`), the wrappers add the
`X-Sail-Voyage-Id` header plus the active span/agent context so the dashboard
attributes the model call to the right place on the timeline. Pass `voyage=`
to correlate with a specific Voyage, or call inference with no active Voyage
for ordinary uncorrelated inference.
```python theme={null}
with voyage.agent("Reviewer"):
with voyage.span("draft"):
# Auto-attributed to this agent/span.
sail.inference.responses.create(model="zai-org/GLM-5.3", input="...")
```
**Auto-spans:** a wrapper call made with *no* active span gets a real,
timed span created around it automatically (named after the calling function
when derivable), so the model call is attributed to a span instead of
appearing unscoped. Auto-spans are marked as auto in the dashboard. Explicit
spans always win, so synthesis happens only where you declared nothing. Set
`SAIL_VOYAGE_AUTO_SPANS=0` to disable.
```python theme={null}
@sail.agent("Reviewer")
@sail.span("draft review")
def explicit_span():
sail.inference.responses.create(model="zai-org/GLM-5.3", input="...")
@sail.agent("Reviewer")
def auto_span():
# No active span here, so Sail creates a timed auto-span for this model call.
sail.inference.responses.create(model="zai-org/GLM-5.3", input="...")
```
Auto-spans do not infer an agent. Wrap the function or block in
`@sail.agent(...)` / `with voyage.agent(...)` when ownership should appear in
the dashboard.
## Streaming with the SDK wrapper
The high-level `sail.inference.*` wrappers return parsed JSON objects and do
not expose a streaming iterator. Passing `stream=True` to those wrappers raises
`sail.InferenceError` before sending the request. Use a raw HTTP client, the
OpenAI SDK, or the Anthropic SDK pointed at Sail when you need API-level
streaming.
## Raw HTTP, OpenAI, and Anthropic clients
Wrap an OpenAI-style client pointed at Sail's API once, and every call
attributes itself. Headers are computed at call time, so there is no
construction-time snapshot that can go stale. Un-spanned calls get the same
synthesized auto-spans as the `sail.inference` wrappers.
```python theme={null}
import os
from openai import OpenAI
import sail
client = sail.voyage.wrap_openai(
OpenAI(
base_url="https://api.sailresearch.com/v1",
api_key=os.environ["SAIL_API_KEY"],
)
)
with sail.voyage.agent("Reviewer"):
client.responses.create(model="zai-org/GLM-5.3", input="...") # auto-attributed
```
`wrap_openai` wraps `responses.create`, `responses.retrieve`, and
`chat.completions.create` in place (whichever exist), is idempotent, and
resolves the context-local current Voyage on each call with the process-wide
fallback. Pass `voyage=` to pin one.
For the Anthropic SDK, attach the Voyage headers to each Messages request:
```python theme={null}
import os
from anthropic import Anthropic
import sail
client = Anthropic(
base_url="https://api.sailresearch.com",
api_key=os.environ["SAIL_API_KEY"],
)
with sail.voyage.run(name="messages-client", version=1) as voyage:
with voyage.agent("Reviewer"):
with voyage.span("call"):
message = client.messages.create(
model="zai-org/GLM-5.3",
max_tokens=256,
messages=[{"role": "user", "content": "Review this change."}],
extra_headers=sail.voyage.headers(),
)
```
The Messages call appears in the same Voyage model-call views as Responses
and Chat Completions calls.
For any other HTTP client, call the endpoint directly and attach the
attribution headers yourself with
[`sail.voyage.headers()`](/voyages-sdk#headers). The helper carries the full
context (voyage id plus the span/agent active at call time), so compute it
per request, never once at client construction:
```python theme={null}
import json, os, urllib.request
import sail
sail.voyage.create(name="raw-client")
api_url = "https://api.sailresearch.com"
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": "zai-org/GLM-5.3", "input": "hello"}).encode(),
headers=headers,
method="POST",
)
```
## Voyage limitations
Voyages is telemetry only. It records and attributes your runs; it is not an
agent framework and adds no orchestration or tool abstractions.
## Errors
Inference wrappers raise `sail.InferenceError` (e.g. for unsupported wrapper
options such as `stream=True`, or a
missing API key) and `sail.InferenceHTTPError` for non-2xx responses. See
[Errors](/voyages-sdk-errors).
# Webhooks
Source: https://docs.sailresearch.com/webhooks
Receive completion notifications via completion_webhook and webhook_token
When you create a request through `POST /v1/responses`, `POST /v1/chat/completions`, or `POST /v1/messages`, you can provide a **completion webhook** in request metadata. When processing finishes, Sail will POST the full response payload to your URL so you can process it without polling.
## Enabling a completion webhook
Include a `completion_webhook` URL in the `metadata` object of your create request. The URL must be `http` or `https`.
```python theme={null}
from openai import OpenAI
client = OpenAI(
api_key="YOUR_SAIL_API_KEY",
base_url="https://api.sailresearch.com/v1",
)
response = client.responses.create(
model="zai-org/GLM-5.3",
input="Summarize this document.",
background=True,
metadata={
"completion_webhook": "https://your-server.com/sail-completion",
},
)
```
```bash theme={null}
curl -X POST https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer YOUR_SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "Summarize this document.",
"background": true,
"metadata": {
"completion_webhook": "https://your-server.com/sail-completion"
}
}'
```
If `metadata.completion_webhook` is omitted or invalid, no webhook request is sent. The create call and the response itself are unchanged; webhooks are optional and best-effort.
For Chat Completions and Anthropic Messages, pass the same metadata keys (`completion_webhook`, `webhook_token`) on the request body.
## Webhook payload
Sail sends a **POST** request to your URL with:
* **Content-Type:** `application/json`
* **Body:** The same general JSON object returned by `GET /v1/responses/{response_id}`. Webhook payloads can omit `metadata.supercached_input_tokens` and `metadata.supercache_write_input_tokens`.
A Responses API completion webhook can carry `status: "completed"` or
`status: "incomplete"`. An incomplete payload is a successful webhook delivery,
not a webhook error. Its body preserves `usage`, `incomplete_details`, and any
partial `output`. Process that payload once and do not keep polling the response
for another status.
## Securing webhooks with a token
To verify that incoming requests are from Sail, set `webhook_token` in the `metadata`. Sail will send the value of `webhook_token` as a Bearer token in the `Authorization` header of the webhook POST.
```python theme={null}
response = client.responses.create(
model="zai-org/GLM-5.3",
input="Summarize this document.",
background=True,
metadata={
"completion_webhook": "https://your-server.com/sail-completion",
"webhook_token": "your-secret-token",
},
)
```
Your server can check `Authorization: Bearer your-secret-token` and reject requests that don't match.
## Delivery behavior
* **Duplicates:** Sail may occasionally deliver the same webhook more than once. Log the response `id` from the webhook body and ignore events you have already processed.
* **Retries:** Sail retries failed deliveries (a non-2xx status or a network error) in rounds. A round makes up to **3** attempts back to back within a **30-second** budget, and failed rounds are repeated with increasing delays of up to a few minutes, for at most **20** rounds. A persistently failing endpoint can receive up to 60 requests for one response. Respond with a **2xx** status as soon as you have accepted the payload so that Sail stops retrying.
* **Best-effort:** Webhook failures are logged but do not affect the response or the API. The response remains available via `GET /v1/responses/{response_id}` even if the webhook never succeeds.
## Full example
Here's a full, end-to-end example using ngrok:
**1. Start a local webhook listener** that prints the payload and returns 200:
```bash theme={null}
python -c "
from http.server import HTTPServer, BaseHTTPRequestHandler; import json
class H(BaseHTTPRequestHandler):
def do_POST(self):
print(json.dumps(json.loads(self.rfile.read(int(self.headers['Content-Length']))), indent=2))
self.send_response(200); self.end_headers()
HTTPServer(('127.0.0.1', 8765), H).serve_forever()
"
```
**2. In a second terminal, expose it with ngrok:**
```bash theme={null}
ngrok http 8765
```
Copy the `https://xxxx.ngrok-free.app` forwarding URL from the output.
**3. In a third terminal, create a response with the webhook:**
```bash theme={null}
curl -X POST https://api.sailresearch.com/v1/responses \
-H "Authorization: Bearer YOUR_SAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "zai-org/GLM-5.3",
"input": "What is 2+2? Reply with just the number.",
"background": true,
"metadata": {
"completion_webhook": "https://xxxx.ngrok-free.app"
}
}'
```
When the response completes, Sail POSTs the full payload to your listener.