# 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.2-FP8` 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.2-FP8" 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() while response.status not in ("completed", "failed", "cancelled"): 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 != "completed": 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) 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 * **Choose a completion window for your agent loop.** The default `standard` window gives a good balance of cost and trajectory time for most agent workloads; reach for `priority` when individual turns are latency-sensitive, or `flex` for background batches where hours-scale queueing is fine. See [Completion windows](/completion-windows) for response time distributions and pricing. * **`background=True`** is recommended for long-running agents. Sail is throughput-optimized, so requests may take longer than a typical low-latency API. Background mode avoids HTTP timeouts and lets you poll for completion. * **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). # 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. # 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 streaming (SSE). # List supported models Source: https://docs.sailresearch.com/api-reference/models-api/list-supported-models /openapi.json get /models # 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} # Completion windows Source: https://docs.sailresearch.com/completion-windows Understanding completion windows and average agent trajectory time Sail optimizes for throughput and cost, not single-turn latency. For agentic workloads, what matters is how long the full trajectory takes from start to finish, not how fast any one request returns. Select a completion window based on the trajectory wall-clock time your workload can tolerate. ## Completion windows at a glance | Window | Avg. turn time | Typical use case | Price vs `asap` | | ---------- | -------------- | ---------------------------------- | --------------- | | `asap` | Immediate | Interactive UIs, human-in-the-loop | — | | `priority` | \~1 min | Latency-sensitive agent loops | \~30-50% lower | | `standard` | \~5 min | Cost-optimized agents | \~45-65% lower | | `flex` | best-effort | Batch processing, evals, offline | \~60-80% lower | Actual turn times will vary based on your specific workload and chosen model. Exact discounts vary by model and by token type (input, output, and cached tokens are discounted differently). For what these rates add up to on a real workload, use the [Agent Cost Calculator](/cost-calculator). ## Completion window details ### `standard` The default completion window for models that support it. Uses Sail's max-efficiency serving stack and targets an average turn time of roughly five minutes on a balanced workload. Most of Sail's prices are quoted at this completion window. ### `priority` Sail's tightest scheduled tier. Targets a shorter average turn time than `standard` (\~1 min vs \~5 min on a balanced workload) in exchange for higher per-token pricing. Use `priority` for latency-sensitive agent loops where each turn directly feeds the next and several extra minutes per turn would meaningfully drag out the trajectory. ### `flex` Schedules work when compute is cheapest, e.g. overnight or off-peak, and does not target a specific response time. Best for batch, non-agentic workloads. Requires `background=True`. ### `asap` Runs immediately on the fastest hardware we have, in a latency-optimized config. ## How to set completion windows Set `metadata.completion_window` on your request: ```py theme={null} response = client.responses.create( model="zai-org/GLM-5.2-FP8", input="Explain the key ideas behind transformers.", background=True, metadata={ "completion_window": "priority" } ) ``` Supported values: `"asap"`, `"priority"`, `"standard"`, and `"flex"`. ### Default behavior Requests that omit `completion_window` default to `standard`. If the `standard` completion window is not available for the model, async requests use the `flex` completion window when it is available, and all other requests use `asap`. If you set `completion_window` explicitly to a window that is not supported for the model, the request is rejected with `400 invalid_request_error`. The error message enumerates the model's actual supported completion windows. You can also check the [pricing](/pricing) page for the up-to-date support matrix. # Data processing agreement Source: https://docs.sailresearch.com/dpa How Sail securely handles customer data 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/). # 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 Run long-horizon agents on Sail. Sail serves trillions of tokens, with support for the best open-source models and your own LoRA fine-tunes. To achieve maximum efficiency for long-horizon agents, we serve traffic at higher latencies in tiers of service called [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.2-FP8", 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.2-FP8", 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.2-FP8", "messages": [ { "role": "user", "content": "What are the top 3 things to do in San Francisco?" } ] }' ```
Run an AI model Run leading open-source AI models with our OpenAI-compatible inference API. Create a Sailbox Give long-horizon agents persistent compute that can run indefinitely. Send requests at scale Use completion windows and background requests for large workloads.
## Intelligence at scale More agents thinking longer and harder, with space to act and explore, can do incredible things:
Trust Center
# 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": "priority", }, 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": "priority"}}, ) ``` ### Constraints on LoRA requests * **`completion_window` cannot be `asap`.** Use `priority`, `standard`, or `flex` — see [Completion Windows](/completion-windows) for what each means. * **`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" } } } ``` [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`. Settings → Apps & Connectors → turn on 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." ## Skills Install the `sailresearch` skill with the [skills CLI](https://github.com/vercel-labs/skills): ```bash theme={null} npx skills add https://docs.sailresearch.com ``` # Migrate to Sail Source: https://docs.sailresearch.com/migrate Drop-in migration from OpenAI-compatible providers to Sail. Sail is a drop-in replacement for OpenAI-compatible inference providers, supporting the OpenAI Responses (`/v1/responses`) and Chat Completions (`/v1/chat/completions`) APIs. Switching from OpenAI, or any OpenAI-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.
```text Migration prompt theme={null} Migrate this project's LLM inference to Sail (https://sailresearch.com). Sail docs — consult these 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 — this is where Sail savings come from. The live https://docs.sailresearch.com/completion-windows and https://docs.sailresearch.com/pricing pages are the source of truth for the available windows and their turn times; read them. If you can't fetch them, fall back to this summary — decide how long each workload can wait for a turn: - asap: a human is actively waiting on each response (interactive UI) - priority (~1 min/turn): latency-sensitive agent loops - standard (~5 min/turn): autonomous agents and pipelines — the default and the right answer for most agentic workloads - flex (best-effort): batch jobs, evals, offline processing; requires background=True on the Responses API If the workload's latency tolerance isn't obvious from the code, ask the user. Set metadata.completion_window explicitly on every call site even when choosing the default, and confirm the chosen window is available for the chosen model on https://docs.sailresearch.com/pricing. 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 (if using the Anthropic SDK, pass it as auth_token, not api_key) - 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) ``` ## Notes * **Synchronous by default.** `responses.create` blocks and returns the completed response, exactly like OpenAI. Sail is throughput-optimized, so requests can run longer than a typical low-latency API — for very long jobs you can optionally pass `background=True` to get an ID back immediately and poll, avoiding HTTP timeouts. See the [Quickstart](/quickstart). * **Pick a completion window** to trade off cost against turnaround. The default `standard` window suits most workloads; reach for `priority` when latency matters or `flex` for cheap background batches. See [Completion windows](/completion-windows). ## Next steps 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 other providers. Email us if you hit anything unexpected. # Models Source: https://docs.sailresearch.com/models All models currently served by Sail
Model Slug Image LoRA Reasoning
Kimi-K2.6
Moonshot AI
Context 262K
Params 1T; 32B active (MoE)
Popular for
coding agentic vision long context
GLM-5.2
Z.ai
Context 1M
Params 753B; 40B active (MoE)
Popular for
coding agentic multilingual
gpt-oss-120b
OpenAI
Context 131K
Params 117B; 5.1B active (MoE)
Popular for
coding math cost-efficiency
Gemma 4 31B IT
Google
Context 256K
Params 31B
Popular for
vision multilingual general chat
Gemma 4 31B IT (NVFP4)
Google
Context 262K
Params 31B
Popular for
vision multilingual general chat
Nemotron 3 Super 120B A12B BF16
NVIDIA
Context 1M
Params 120B; 12B active (MoE)
Popular for
coding agentic multilingual long context general chat
* Each row links the exact Hugging Face checkpoint Sail currently serves. If we offer multiple quantizations, we list them as separate model IDs. * 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 the default 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", "model": "sail/zai-org/GLM-5.2-FP8", // model for heavy lifting "small_model": "sail/openai/gpt-oss-120b", // model for lightweight tasks "provider": { "sail": { "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "https://api.sailresearch.com/v1", "apiKey": "YOUR_SAIL_API_KEY", }, "models": { "zai-org/GLM-5.2-FP8": { "name": "GLM-5.2" }, "openai/gpt-oss-120b": { "name": "gpt-oss-120b" }, "google/gemma-4-31B-it": { "name": "Gemma 4 31B IT" }, }, }, }, } ``` 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 switch between any of the models you listed in your config. ## Tips * **What's next** — see OpenCode's [Usage guide](https://opencode.ai/docs/#usage). * **New models** — when Sail launches a new model, add it under `provider.sail.models`. # Pricing Source: https://docs.sailresearch.com/pricing Per-token pricing for Sail inference
USD · per 1M tokens
Model Window Input Cached Output
Kimi-K2.6
moonshotai/Kimi-K2.6
Priority \$ 0.45 \$ 0.20 \$ 3.00
ASAP \$ 1.00 \$ 0.20 \$ 4.00
GLM-5.2
zai-org/GLM-5.2-FP8
Standard \$ 0.50 \$ 0.12 \$ 2.50
Priority \$ 0.70 \$ 0.18 \$ 3.00
Flex \$ 0.40 \$ 0.08 \$ 1.80
ASAP \$ 1.40 \$ 0.26 \$ 4.40
gpt-oss-120b
openai/gpt-oss-120b
Priority \$ 0.04 \$ 0.02 \$ 0.30
ASAP \$ 0.06 \$ 0.03 \$ 0.40
Gemma 4 31B IT
google/gemma-4-31B-it
Standard \$ 0.12 \$ 0.08 \$ 0.60
Flex \$ 0.06 \$ 0.02 \$ 0.30
ASAP \$ 0.40 \$ 0.20 \$ 0.60
Gemma 4 31B IT (NVFP4)
nvidia/Gemma-4-31B-IT-NVFP4
Standard \$ 0.07 \$ 0.05 \$ 0.40
ASAP \$ 0.14 \$ 0.07 \$ 0.40
Nemotron 3 Super 120B A12B BF16
nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-BF16
Flex \$ 0.25 \$ 0.15 \$ 0.75
* Sail supports four completion windows: `standard`, `priority`, `flex`, and `asap`. See [Completion Windows](/completion-windows) for details. * Not all models support all windows. 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 clients Sail supports the Responses (`/v1/responses`) and Chat Completions API (`/v1/chat/completions`); see the [API support matrix](/support) for the full picture. If you are already using OpenAI or another inference provider, 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 Install the OpenAI SDK and point it at Sail. Create a response with `background=True` so the request returns a response\_id immediately, then poll until completion: ```python Python theme={null} import time 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.2-FP8", input="Explain the key ideas behind transformers.", max_output_tokens=1000, background=True, # return immediately with ID to poll ) print(f"Started {response.id}, waiting ...") while response.status in {"in_progress", "queued"}: time.sleep(1) response = client.responses.retrieve(response.id) if response.status != "completed": raise RuntimeError(f"Error: {response.status}") print(response.output_text) ``` ```bash cURL theme={null} URL=https://api.sailresearch.com/v1/responses AUTH="Authorization: Bearer YOUR_SAIL_API_KEY" # Create the response (returns immediately with an id) RESPONSE_ID=$(curl -s $URL \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "model": "zai-org/GLM-5.2-FP8", "input": "Explain the key ideas behind transformers.", "background": true, "metadata": { "completion_window": "standard" } }' | jq -r '.id') # Poll until completed until [ "$(curl -s $URL/$RESPONSE_ID \ -H "$AUTH" | jq -r '.status')" = "completed" ]; do sleep 1 done curl -s $URL/$RESPONSE_ID -H "$AUTH" | jq ``` ## Next steps * Look at our full list of supported [models](/models) and [pricing](/pricing). * Browse the full [API reference](/api-reference) to see supported endpoints and request fields. * Copy the [migration prompt](/migrate#copy-migration-prompt) for your coding agent. * [Email us](mailto:support@sailresearch.com) if you have questions. # 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 The CLI and the SDKs are separate products. If you only want to drive Sail from code, install an [SDK](/reference/python-sdk). If you already use the Python SDK, you have the CLI too: `pip install sail` puts `sail` on your `PATH`. ### macOS and Linux ```bash theme={null} curl -fsSL https://cli.sailresearch.com/install.sh | sh ``` This installs the latest `sail` into `~/.sail/bin`, a directory only sail writes to. 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. ### Windows ```powershell theme={null} irm https://cli.sailresearch.com/install.ps1 | iex ``` This installs `sail.exe` into `%LOCALAPPDATA%\sail\bin` and puts that directory at the front of your user `PATH`. Restart your shell afterward. ### Verify ```bash theme={null} sail --version ``` ### Installer options Both installers read a few environment variables. | Variable | Effect | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SAIL_CLI_VERSION` | Install a specific version. Default: latest. | | `SAIL_HOME` | Relocate sail's home directory (absolute path); the CLI lives in `$SAIL_HOME/bin`. The CLI and the Python SDK honor the same variable, and the installer persists it for future shells. | | `SAIL_INSTALL_DIR` | Install a copy at this exact directory instead (for provisioning scripts). The `sail` command from a Python environment keeps using `$SAIL_HOME/bin`; `sail update` run from such a copy updates it in place. | Pin a version, 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 ``` ### Update ```bash theme={null} sail update ``` This downloads the latest release and replaces the binary you ran, which is normally the standalone install. Re-running the installer does the same thing, and it works the same however you first got the CLI, including through `pip install sail`. `sail update` updates the CLI itself. Upgrading a Sailbox is a different operation: `sail box upgrade `. The Sail API warns when your CLI version is nearing the end of its support window. The warning includes update instructions. A version past the end of its support window is rejected with an update error before any operation runs. ### One binary, many channels There is one real `sail` CLI per user: the standalone install at `~/.sail/bin/sail` (`%LOCALAPPDATA%\sail\bin\sail.exe` on Windows). The installer writes it, `sail update` replaces it, and upgrading the SDK with `pip install -U sail` keeps it current too. `pip install sail` does not add another copy: in a Python environment, the `sail` command is a small launcher that runs the standalone install, completing the setup automatically the first time you run it, with no separate download. Installing the SDK into any number of Python environments still leaves exactly one CLI to keep current. `sail --version` prints the path of the binary that ran. ## 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 are non-interactive and 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). ## Global options These work on any command. | Option | Effect | | -------- | ---------------------------------------------------------- | | `--json` | Emit machine-readable JSON instead of human-readable text. | | `--help` | Show help for the CLI or a command. | `sail --version` prints the CLI version and the path of the binary that answered (top-level only). Authentication comes from `SAIL_API_KEY`, falling back to the credential stored by `sail auth login`. See [Configuration](/reference/sdk-configuration). ## Authentication ```bash theme={null} sail auth login [--api-key ] # browser login, or store/validate a key (also reads a piped key) sail auth whoami # show the active key sail auth logout # remove the stored key ``` ## 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. | | `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 ` | Create a new Sailbox from a checkpoint (`--name`). | | `sail box upgrade ` | Upgrade the runtime (now if running, else at next wake). | ### `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 `arm`). | | `--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. Billing is by usage, so a bigger size costs no more on its own. `s` gives the fastest cold starts, forks, and resumes, and its lower ceilings cap what a runaway workload can consume. | | `--memory-gib ` | Memory ceiling in whole GiB, within the size's range: 2-64 for `s`, 8-128 for `m`. The size's default when omitted. | | `--disk-gib ` | Disk size in whole GiB, within the size's range: 8-128 for `s`, 32-512 for `m`. The size's default when omitted. | | `--private` | Restrict all access to you (creator-only); an org admin can override some operations with a recorded reason. Requires a user-minted API key. | | `--enable-ssh` | Expose port 22, trust your org's CA, and start sshd. | | `--identity-file ` | Local SSH key to authenticate with (implies `--enable-ssh`). | ### `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. | ## 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). | | `-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). | | `--no-forward-browser` | With `--tty`, keep everything forwarded except the box's browser opens. | | `--background` | Start the command and return once accepted (no output 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` sizing/port flags plus `--name` (default `run-`), `--cwd`, `--timeout`, `--env`, and `--keep` (leave the sailbox running instead of terminating it). ### `sail box shell` ```bash theme={null} sail box shell [--shell ] [--no-forward] [--no-forward-browser] ``` 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.) 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, or `--no-forward-browser` to keep everything but the box's browser opens. `sail box exec --tty` forwards the same way (and takes both flags too). ### `sail box cp` ```bash theme={null} sail box cp ``` Copy a file to or from a sailbox; `:` denotes the remote side. ## 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 a CIDR prefix (e.g. `203.0.113.0/24`), or, for HTTP listeners, a Sail app name. See [Networking](/sailboxes-networking) for the full model. ## 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 source ranges allowed to reach port 22 (repeatable). * **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+. It shares one engine with the [TypeScript](/reference/typescript-sdk) and [Rust](/reference/rust-sdk) SDKs, so behavior matches across languages. ## 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 resolves the live endpoint on demand, waking a sleeping box only when the 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. | | `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 box is in an error state. | | `checkpoint_generation` | `Optional[int]` | Monotonic checkpoint generation counter. | | `started_at` | `Optional[datetime]` | When the box last started, if it ever has. | | `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 box (for a fork or 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. | ### 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_gib: Optional[int] = None, disk_gib: Optional[int] = None, ingress_ports: Optional[List[Union[int, IngressPort]]] = None, volumes: Optional[Mapping[str, Any]] = None, ssh: bool = False, private: bool = False, ) -> Sailbox ``` Custom image definitions are built first; the call then returns once the new box is running or creation has failed. `timeout` (seconds) bounds each create attempt, since creating a Sailbox can block for many minutes while it queues for capacity and boots the VM. The create is retried (reattaching to the same box), so it returns as soon as the box is ready and gives up after roughly three attempts. If the budget is exhausted it raises rather than hanging; the box may still come up server-side. To recover it, find its id with `Sailbox.list(search=name)`, then bind it with `from_id` or terminate it. Pass `0` to leave each attempt unbounded. `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 `). Pass `ssh=True` to get an SSH-ready box in one call. It calls `enable_ssh` on the new box, which trusts your org's SSH certificate authority, starts `sshd`, and exposes guest port 22 as `tcp`. A port-22 entry in `ingress_ports` is then not exposed at create; only its `allowlist` is kept, applied when `enable_ssh` exposes the port. To connect, wire up your machine with `sail box ssh alias ` (or `sail box ssh enable `) and then `ssh .sail`; a plain `ssh` to the raw port will not present your certificate. By default a box is org-wide: any credential in your org can exec, copy files, SSH, or run lifecycle operations on it. Pass `private=True` to restrict all of that to you. An org admin can override that with a recorded reason for exec, files, and pause/resume/terminate/upgrade. SSH, exposing or removing listeners, and fork/checkpoint/restore stay creator-only. 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"` or `"m"` (the default). Each size sets the vCPU count plus default memory and disk. Billing is based on observed usage, so a bigger size costs no more on its own. Choose `"s"` when you want the fastest cold starts, forks, and resumes. Its lower ceilings also cap what a runaway workload can consume, so you don't accidentally use more than you need. `memory_gib` and `disk_gib` tune that size's default memory and disk ceilings in whole GiB, within its range. `image` is the image to boot; omit it for the prebuilt Debian base (an instant create with no 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 box; it wakes transparently on traffic or the next operation. `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 box 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: Optional[str] = None, timeout: Optional[int] = None, ) -> Sailbox ``` This is a new box branched from the checkpoint's state, not a resumption of the original. In-flight `Sailbox.exec()` sessions are reaped in the child: a command still running when the checkpoint was taken does not resume here. Its on-disk effects up to the checkpoint are preserved, but the command itself is not continued. Start fresh execs on the new box. `name` sets the new box's display name; `timeout` (seconds) bounds the call, must be positive when given, and defaults to the server's bound. ### fork Create a new running Sailbox from this one's live state. ```python theme={null} def fork(*, name: Optional[str] = None, timeout: Optional[int] = None) -> Sailbox ``` The child copies this box's memory and writable disk as they are now, in one call, so it branches from the parent's current state while the parent keeps running. The copy is transient: no durable checkpoint is taken. To branch from a saved point in time instead, take a durable `checkpoint` and start boxes from it with `from_checkpoint`, which works even after the parent is gone. Like `from_checkpoint`, the child is a new independent box: commands still running in the parent do not continue in the child (their on-disk effects up to the fork are preserved); start fresh execs on the child. `name` sets the child's display name; `timeout` (seconds) bounds the call, must be positive when given, and defaults to the server's bound. ### terminate Permanently terminate this Sailbox. ```python theme={null} def terminate() -> None ``` Idempotent: terminating a box 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 explicit resume wakes it. ```python theme={null} def sleep() -> None ``` ### 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 return a handle to their existing checkpoint. `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 it will be garbage collected. ### 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 box. 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 (unflushed application state gets power-loss semantics). A paused or sleeping Sailbox is upgraded without waking. The upgrade is recorded and applies automatically 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; already-up-to-date Sailboxes report `True` without rebooting. ### resume Resume this Sailbox through the public API. ```python theme={null} def resume() -> Sailbox ``` ### listener Fetch one listener by guest port without waking the box. ```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 source IP CIDRs (e.g. `["203.0.113.0/24"]`) or Sail app names may connect (app names on `"http"` listeners only; `"tcp"` allowlists must be CIDR prefixes). Re-exposing a port under the same protocol updates its `allowlist` to the value you pass. 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 box, so wait only once the box 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`. ### 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; `create(ssh=True)` is sugar for calling this right after create. Safe to re-run: the box'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 CIDRs 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 box 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 box. 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, check: bool = False, idempotency_key: Optional[str] = None, ) -> 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`). The result's stdout/stderr are the buffered output (capped, drop-oldest); for unbounded output, stream it live via `exec` instead. `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. ### 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: bool = False, term: Optional[str] = None, cols: int = 0, rows: int = 0, env: Optional[Mapping[str, str]] = None, args: Optional[Union[List[Any], Tuple[Any, ...]]] = None, kwargs: Optional[Mapping[str, Any]] = None, ) -> Union[ExecProcess, Any] ``` For shell commands, returns a `ExecProcess` immediately after the backend accepts the command: iterate `proc.stdout` / `proc.stderr` for live output and call `proc.wait()` for the result. `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 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. `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. ### 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, timeout: Optional[int] = None, no_forward: bool = False, no_forward_browser: 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 box, without a separate SSH server. `shell` overrides the login shell (default `$SHELL` or `/bin/bash`); it is ignored when `command` is given. While attached, several local conveniences are forwarded: the box's browser opens and localhost servers reach your machine, files dragged onto the terminal upload into the box 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 box's clipboard, and text copied inside the box 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), or `no_forward_browser=True` to keep everything forwarded except browser opens. ## 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 a handle pinned to its image\_id. ```python theme={null} def build(*, timeout: int = 1800) -> 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. ## 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. ### builtin\_debian Prebuilt Debian base, matching the Sailbox host architecture. ```python theme={null} builtin_debian: ImageDefinition ``` ### debian\_amd64 Debian base for x86-64, pinned to your local Python version. ```python theme={null} debian_amd64: ImageDefinition ``` ### debian\_amd Alias for `debian_amd64`. ```python theme={null} debian_amd: ImageDefinition ``` ### builtin\_debian\_amd64 Debian base for x86-64, without Python-version pinning. ```python theme={null} builtin_debian_amd64: ImageDefinition ``` ### debian\_arm64 Debian base for arm64, pinned to your local Python version. ```python theme={null} debian_arm64: ImageDefinition ``` ### debian\_arm Alias for `debian_arm64`. ```python theme={null} debian_arm: ImageDefinition ``` ### builtin\_debian\_arm64 Debian base for arm64, without Python-version pinning. ```python theme={null} builtin_debian_arm64: ImageDefinition ``` ### devbox\_amd64 Devbox base for x86-64: Debian plus a baked development toolchain. ```python theme={null} devbox_amd64: ImageDefinition ``` ### devbox\_arm64 Devbox base for arm64: Debian plus a baked development toolchain. ```python theme={null} devbox_arm64: ImageDefinition ``` ### builtin\_devbox\_amd64 Alias for `devbox_amd64`. ```python theme={null} builtin_devbox_amd64: ImageDefinition ``` ### builtin\_devbox\_arm64 Alias for `devbox_arm64`. ```python theme={null} builtin_devbox_arm64: ImageDefinition ``` ## ExecProcess A command running in a Sailbox. Returned by `Sailbox.exec`. `stdout`/`stderr` iterate live output as it arrives, streamed into bounded buffers and resuming if the stream breaks; `wait()` returns the authoritative final result even when the stream cannot be resumed. The command runs inside the Sailbox independent of this handle and the connection that launched it, so closing the handle or losing the network leaves it running and its result retrievable through `wait()`. ### exec\_request\_id Stable server-assigned identifier of this exec. ```python theme={null} exec_request_id: str ``` ### stdout Live stdout iterator yielding `str` chunks (incrementally decoded UTF-8; use `stdout_bytes` for the raw byte stream). Ends when the stream ends; a reader that falls more than the buffer cap behind skips the dropped head. Each access returns a fresh iterator that replays the retained output from the beginning before following live. ```python theme={null} stdout: Iterator[str] ``` ### stderr Live stderr iterator; same semantics as `stdout`. Empty for a pty exec, which merges stderr onto stdout. ```python theme={null} stderr: Iterator[str] ``` ### output Live merged terminal output for a pty exec (alias of `stdout`). ```python theme={null} output: Iterator[str] ``` ### stdout\_bytes Live stdout iterator yielding raw `bytes` chunks, exactly as the command wrote them (escape sequences and binary payloads included). ```python theme={null} stdout_bytes: Iterator[bytes] ``` ### stderr\_bytes Raw `bytes` twin of `stderr`. ```python theme={null} stderr_bytes: Iterator[bytes] ``` ### output\_bytes Raw `bytes` twin of `output` (alias of `stdout_bytes`). ```python theme={null} output_bytes: Iterator[bytes] ``` ### stdin Stdin writer for an `open_stdin=True` exec; raises `sail.InvalidArgumentError` otherwise. ```python theme={null} stdin: StdinWriter ``` ### exit\_code Exit code if the live stream delivered it, else None. ```python theme={null} exit_code: Optional[int] ``` None does not mean still-running: if the stream broke the exit code may not arrive here, so `wait()` is authoritative. An exec whose host was lost before it produced a real exit code raises `SailboxHostLostError` here, exactly as `wait()` does. ### 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 Release the live stream without touching the remote command. ```python theme={null} def close() -> None ``` ### wait Wait for the exec to complete and return its buffered result. ```python theme={null} def wait(*, stop: Optional[threading.Event] = None) -> Optional[ExecResult] ``` If the live stream delivers a clean exit it resolves immediately; otherwise it fetches the authoritative result from the server. `result.stdout`/`result.stderr` are always the full capped tail, independent of how much was consumed via live iteration. 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(...)`. `stdout` and `stderr` are async iterators over live output chunks (`async for chunk in proc.stdout`; chunks are arbitrary slices of the stream, not lines), and `await proc.wait()` returns the buffered result. Like the sync handle, the command runs detached inside the Sailbox, so dropping this handle leaves it running and its result retrievable through `wait()`. Cancelling the task awaiting `wait()` stops waiting but leaves the command running; call `await proc.cancel()` to signal the command itself. ### exec\_request\_id Stable server-assigned identifier of this exec. ```python theme={null} exec_request_id: str ``` ### stdout Live stdout async iterator yielding `str` chunks (incrementally decoded UTF-8; use `stdout_bytes` for raw bytes). Each access returns a fresh iterator that replays the retained output from the start, then follows live. ```python theme={null} stdout: AsyncIterator[str] ``` ### stderr Live stderr async iterator; same semantics as `stdout`. Empty for a pty exec, which merges stderr onto stdout. ```python theme={null} stderr: AsyncIterator[str] ``` ### output Live merged terminal output for a pty exec (alias of `stdout`). ```python theme={null} output: AsyncIterator[str] ``` ### stdout\_bytes Live stdout async iterator yielding raw `bytes` chunks, exactly as the command wrote them. ```python theme={null} stdout_bytes: AsyncIterator[bytes] ``` ### stderr\_bytes Raw `bytes` twin of `stderr`. ```python theme={null} stderr_bytes: AsyncIterator[bytes] ``` ### output\_bytes Raw `bytes` twin of `output` (alias of `stdout_bytes`). ```python theme={null} output_bytes: AsyncIterator[bytes] ``` ### stdin Stdin writer for an `open_stdin=True` exec; raises `sail.InvalidArgumentError` otherwise. ```python theme={null} stdin: AsyncStdinWriter ``` ### exit\_code Exit code if the live stream delivered it, 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 Release the live stream without touching the remote command. ```python theme={null} def close() -> None ``` ### wait Wait for the exec to complete and return its buffered result. ```python theme={null} async def wait() -> ExecResult ``` `result.stdout`/`result.stderr` are the full capped tail, independent of how much was consumed via live iteration. 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, and test paths. Paths are remote POSIX paths in the guest, accepted as `str` or `PurePosixPath`. ### 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:: for chunk in sb.fs.read\_stream(path): ... async for chunk in sb.fs.read\_stream(path): ... Chunks arrive at a fixed transfer size (currently 1 MiB). 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: Optional[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:: 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:: async with await sb.fs.write\_stream.aio("/logs/run.log") as writer: await writer.write(chunk) ### write Write data to a regular file in the Sailbox. ```python theme={null} def write( path: GuestPath, data: Union[str, bytes, bytearray, memoryview, IOBase], *, create_parents: bool = True, mode: Optional[int] = None, ) -> None ``` Missing parent directories are created by default. ### mkdir Create a directory and any missing parents (like `mkdir -p`); a no-op if it already exists. ```python theme={null} def mkdir(path: GuestPath) -> None ``` ### remove Remove a file or directory tree (like `rm -rf`); a no-op if it is already absent. ```python theme={null} def remove(path: GuestPath) -> None ``` ### exists Whether `path` exists in the guest. Follows symlinks (like `test -e`), so a dangling symlink reports `False` even though `ls` lists it. ```python theme={null} def exists(path: GuestPath) -> 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. ```python theme={null} def ls(path: GuestPath) -> List[DirEntry] ``` ## 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 resolves the box's live endpoint (waking it on demand). 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 ``` ## 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 control-plane request 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`. ## 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`, `SAIL_IMAGEBUILDER_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). ## RetryPolicy Knobs governing the auto-retry path. `max_attempts` counts the initial request, so `max_attempts=3` means at most two retries. `base_delay` and `max_delay` are seconds; the schedule is exponential with full jitter, capped at `max_delay`, and a server-sent `Retry-After` wins (also capped at `max_delay`). Retried mutations carry an `Idempotency-Key` header, so a retry cannot double-apply. **Attributes:** | Attribute | Type | Description | | -------------- | ------- | ------------------------------------------------------------------- | | `max_attempts` | `int` | Attempts including the initial request. | | `base_delay` | `float` | First backoff delay, in seconds. | | `max_delay` | `float` | Backoff cap, in seconds; a server `Retry-After` is capped here too. | | `multiplier` | `float` | Backoff growth factor between attempts. | ## 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. Entries that parse as CIDR prefixes (e.g. `["203.0.113.0/24"]`) match source IPs; other entries are treated as Sail app names whose Sailboxes may connect. App-name entries are supported on `"http"` listeners only. Raw-TCP connections carry no source app identity, so `"tcp"` allowlists must be CIDR prefixes. 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]]` | Source addresses allowed to reach the port; 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]` | RFC3339 UTC time at which the handle expires and is eligible for garbage collection, or `None` for a handle to an existing checkpoint that carries no fresh retention bound (a paused or sleeping source). | | `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 applied immediately (running box); False when deferred to the next wake. | | `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. | ### 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: its buffered output, exit code, whether it was killed on timeout, and flags describing how complete the buffered output is. **Attributes:** | Attribute | Type | Description | | ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `stdout` | `str` | Buffered stdout: a capped, drop-oldest tail of the output. | | `stderr` | `str` | Buffered stderr: a capped, drop-oldest tail of the output. | | `exit_code` | `int` | The command's exit code. | | `timed_out` | `bool` | Whether the command was killed for exceeding its timeout. | | `stdout_truncated` | `bool` | The server output ring overflowed and dropped the oldest bytes of the buffered copy returned here. | | `stderr_truncated` | `bool` | Like `stdout_truncated`, for stderr. | | `stdout_complete` | `bool` | The live stream delivered the output through to the command's exit, so a consumer that streamed it already holds the complete output even when the buffered copy here is a truncated tail. | | `stderr_complete` | `bool` | Like `stdout_complete`, for stderr. | ### GuestPath A path inside the guest: a str or a `PurePosixPath`. Guest paths are remote POSIX paths, independent of the local platform. ### DEFAULT\_LIST\_LIMIT Default page size for `Sailbox.list` and `Sailbox.list_page`, sourced from the shared core. ```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` | RPC status, on exec failures; empty elsewhere. | | `body` | `Optional[Any]` | Parsed response body, on API and creation failures; `None` elsewhere. | ### NotFoundError Raised when a resource (Sailbox, app, volume, or checkpoint) 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`. ### 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, and ingress. It shares one engine with the [Python](/reference/python-sdk) and [TypeScript](/reference/typescript-sdk) SDKs, so behavior matches across languages. ## Install ```toml theme={null} [dependencies] sail-rs = "0.4" # 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` / `resume` / `upgrade`), `checkpoint`, `fork`, one-shot `run` / `run_shell`, streaming `exec` / `exec_shell`, filesystem helpers under `fs()` (one-shot `read` / `write`, streaming `read_stream` / `write_stream`, and `mkdir` / `remove` / `exists` / `ls`), an interactive `shell`, listeners (`expose` / `unexpose` / `listeners` / `listener` / `wait_for_listener` / `ingress_auth_headers`), and SSH (`enable_ssh`). Org-scoped operations live on the `Client`: `list_sailboxes`, `create_from_checkpoint`, volumes, apps, and the image build pipeline. 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: sail.Config and sail.RetryPolicy 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 `SAIL_API_URL`, `SAILBOX_API_URL`, `SAIL_IMAGEBUILDER_URL`, and `SAILBOX_INGRESS_URL` each override one endpoint, for custom or self-hosted stacks. 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()`. ## 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 { Client } from "@sailresearch/sdk"; const client = Client.fromEnv(); // Or configure explicitly: // const client = Client.fromConfig({ apiKey: "sk_..." }); ``` ```rust Rust theme={null} use sail::Client; let client = Client::from_env()?; // Or configure explicitly: // let client = Client::builder("sk_...").build()?; ``` ### 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 always retried. * **429** is retried only when the response includes a valid `Retry-After`. * **500** and other statuses are surfaced immediately, without a retry. In Python, `sail.RetryPolicy` describes this policy, and `sail.DEFAULT_RETRY_POLICY` (the standard policy above) and `sail.NO_RETRY` (a single attempt) are the two built-in instances. They're exported so you can inspect the retry behavior; the SDKs manage retries for you, so there's nothing to configure per call. # 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. It shares one engine with the [Python](/reference/python-sdk) and [Rust](/reference/rust-sdk) SDKs, so behavior matches across languages. ## 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 ``` Prebuilt native binaries are published per platform (Linux, macOS, Windows) and selected automatically at install time. 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 native error surfaces 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), 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` #### 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 box (for a fork or 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 box 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` #### sailboxId ##### Get Signature > **get** **sailboxId**(): `string` The Sailbox's stable identifier. ##### Returns `string` #### startedAt ##### Get Signature > **get** **startedAt**(): `Date` | `undefined` When the current boot started (RFC 3339). ##### 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) 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` ### Methods #### checkpoint() > **checkpoint**(`options?`): `Promise`\<[`SailboxCheckpoint`](#sailboxcheckpoint-1)> Take a checkpoint of this Sailbox. The returned handle carries `expiresAt`, when it becomes eligible for garbage collection. ##### Parameters | Parameter | Type | | --------- | ----------------------------------------- | | `options` | [`CheckpointOptions`](#checkpointoptions) | ##### Returns `Promise`\<[`SailboxCheckpoint`](#sailboxcheckpoint-1)> #### 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 box 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. `options` can 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. 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)> #### fork() > **fork**(`options?`): `Promise`\<[`Sailbox`](#sailbox)> Fork this Sailbox into a new running child in one call. The child copies this Sailbox's memory and writable disk as they are now, branching from its live state while the parent keeps running. The copy is transient: no durable checkpoint is created. To branch from a saved point in time instead, pair [checkpoint](#checkpoint) with [Sailbox.fromCheckpoint](#fromcheckpoint), which works even after the parent is gone. The child is a new independent Sailbox: commands still running in the parent do not continue in the child (their on-disk effects up to the fork are preserved); start fresh execs on the child. ##### Parameters | Parameter | Type | | --------- | ------------------------------------------- | | `options` | [`ForkSailboxOptions`](#forksailboxoptions) | ##### Returns `Promise`\<[`Sailbox`](#sailbox)> #### 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 box. ##### 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-1)). ##### 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/stderr are the buffered output (capped, drop-oldest); for unbounded output, stream it live via [exec](#exec-1) instead. `openStdin`, `pty`, and `background` 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 and a backgrounded one would return the launcher's result, not the command's; use [exec](#exec-1) for those. ##### Parameters | Parameter | Type | | ---------- | -------------------------------- | | `command` | `string` \| readonly `string`\[] | | `options?` | [`RunOptions`](#runoptions) | ##### Returns `Promise`\<[`ExecResult`](#execresult)> #### 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. 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**(): `Promise`\<`void`> Sleep this Sailbox to disk (wakes on traffic). ##### Returns `Promise`\<`void`> #### terminate() > **terminate**(): `Promise`\<`void`> Terminate (delete) this Sailbox (updates [status](#status-1)). ##### 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. Sail may sleep a fully idle box; 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 Sailbox from a checkpoint. ##### 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-1) and [status](#status-1) 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) 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-1), 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). ### 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 custom image definition. Immutable and fluent: each method returns a new `Image`. Local files/dirs are recorded here and hashed + uploaded by the core when the image is resolved to a spec (at [Sailbox.create](#create), or via [toSpec](#tospec)), so chaining stays synchronous. ### Example ```ts theme={null} const image = Image.debian("arm64") .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) 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. ##### 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) 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 arm64). ##### Parameters | Parameter | Type | Default value | | -------------- | ----------------------------------------- | ------------- | | `architecture` | [`ImageArchitecture`](#imagearchitecture) | `"arm64"` | ##### Returns [`Image`](#image) #### devbox() > `static` **devbox**(`architecture?`): [`Image`](#image) The devbox base image (defaults to arm64): a prebuilt Debian base with a baked development layer. 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) | `"arm64"` | ##### Returns [`Image`](#image) *** ## 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. ### Example ```ts theme={null} const proc = await box.exec(["bash", "-lc", "echo hi"]); for await (const line of proc.stdout) process.stdout.write(line); const result = await proc.wait(); console.log(result.exitCode); ``` ### Properties | Property | Modifier | Type | Description | | -------------- | ---------- | --------------------------- | --------------------------------------------------------------- | | `stderr` | `readonly` | [`ExecStream`](#execstream) | Stderr stream: string iteration by default, `.raw()` for bytes. | | `stdout` | `readonly` | [`ExecStream`](#execstream) | Stdout stream: string iteration by default, `.raw()` for bytes. | ### Accessors #### execRequestId ##### Get Signature > **get** **execRequestId**(): `string` The durable exec request id. ##### Returns `string` #### idempotencyKey ##### Get Signature > **get** **idempotencyKey**(): `string` The idempotency key used to launch the command. ##### 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) ### 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` Stop the output pump and detach (does not kill the command). Call this on early exit from streaming a long-running command so the stream is not held until GC. ##### Returns `void` #### closeStdin() > **closeStdin**(): `Promise`\<`void`> Close the command's stdin (send EOF). ##### Returns `Promise`\<`void`> #### poll() > **poll**(): `number` | `null` The exit code if the exit frame has arrived on the stream, else `null`. Throws for a host-lost exec (no real exit code), as [wait](#wait) does. ##### 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)> Await the authoritative result (exit code, buffered output, flags). ##### Returns `Promise`\<[`ExecResult`](#execresult)> #### waitStreamEnded() > **waitStreamEnded**(`timeoutSeconds`): `Promise`\<`boolean`> Wait up to `timeoutSeconds` for the streams to end; returns whether they did. `Infinity` waits indefinitely. ##### Parameters | Parameter | Type | | ---------------- | -------- | | `timeoutSeconds` | `number` | ##### Returns `Promise`\<`boolean`> #### 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. ### 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`>> Collect the whole 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). ##### Returns `AsyncIterableIterator`\<`Buffer`\<`ArrayBufferLike`>> #### text() > **text**(): `Promise`\<`string`> Collect the whole stream into a single string. ##### Returns `Promise`\<`string`> #### toReadable() > **toReadable**(): `Readable` Adapt to a Node `Readable` of string chunks (e.g. to `.pipe()` it). ##### 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, and test paths. ### Methods #### exists() > **exists**(`path`): `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. ##### Parameters | Parameter | Type | | --------- | -------- | | `path` | `string` | ##### Returns `Promise`\<`boolean`> #### ls() > **ls**(`path`): `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. ##### Parameters | Parameter | Type | | --------- | -------- | | `path` | `string` | ##### Returns `Promise`\<[`DirEntry`](#direntry)\[]> #### mkdir() > **mkdir**(`path`): `Promise`\<`void`> Create a directory and any missing parents (like `mkdir -p`); a no-op if it already exists. ##### Parameters | Parameter | Type | | --------- | -------- | | `path` | `string` | ##### 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`): `Promise`\<`void`> Remove a file or directory tree (like `rm -rf`); a no-op if it is already absent. ##### Parameters | Parameter | Type | | --------- | -------- | | `path` | `string` | ##### Returns `Promise`\<`void`> #### write() > **write**(`path`, `data`, `options?`): `Promise`\<`void`> Write bytes (a `string` is encoded as UTF-8) to a guest file, creating it and any missing parent directories (convenience over [writeStream](#writestream-1); pass `createParents: false` to opt out). ##### Parameters | Parameter | Type | | ---------- | ---------------------------------------------------------------------------- | | `path` | `string` | | `data` | `string` \| `Buffer`\<`ArrayBufferLike`> \| `Uint8Array`\<`ArrayBufferLike`> | | `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 RPC 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 the core accepts the bytes. ##### Returns `Writable` #### write() > **write**(`data`): `Promise`\<`void`> Write bytes (a `string` is encoded as UTF-8). The core 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-1)) 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)\[]> *** ## 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 surface over the native core (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`): `Promise`\<[`ImageSpec`](#imagespec)> Resolve an image definition and build it to ready, returning the content-addressed [ImageSpec](#imagespec) to create Sailboxes from. A bare builtin base skips the build; `timeoutSeconds` bounds the whole pipeline (hashing, uploads, and the build). ##### Parameters | Parameter | Type | | ---------------- | ------------------------------------- | | `def` | [`ImageDefinition`](#imagedefinition) | | `timeoutSeconds` | `number` | ##### Returns `Promise`\<[`ImageSpec`](#imagespec)> #### buildSpecToReady() > **buildSpecToReady**(`spec`, `timeoutSeconds`): `Promise`\<[`ImageBuild`](#imagebuild-1)> Build an already-resolved spec to ready (submit + poll), bounded by `timeoutSeconds`. ##### Parameters | Parameter | Type | | ---------------- | ------------------------- | | `spec` | [`ImageSpec`](#imagespec) | | `timeoutSeconds` | `number` | ##### 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)> #### createFromCheckpoint() > **createFromCheckpoint**(`params`): `Promise`\<[`SailboxHandle`](#sailboxhandle)> Create a new Sailbox from a checkpoint. ##### Parameters | Parameter | Type | | --------- | ------------------------------------------------- | | `params` | [`FromCheckpointRequest`](#fromcheckpointrequest) | ##### Returns `Promise`\<[`SailboxHandle`](#sailboxhandle)> #### createSailbox() > **createSailbox**(`req`, `timeoutSeconds?`): `Promise`\<[`SailboxHandle`](#sailboxhandle)> Create a Sailbox. `timeoutSeconds` bounds each create attempt (default 600s); pass `0` for no client-side timeout. Timed-out attempts are retried, reattaching to the same box, so when the overall budget is exhausted the box 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)> #### 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`> #### 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 CIDRs, 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. `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 (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)> #### forkSailbox() > **forkSailbox**(`sailboxId`, `options?`): `Promise`\<[`SailboxHandle`](#sailboxhandle)> Fork a Sailbox into a new running child: a point-in-time copy of its memory and writable disk, with no durable checkpoint created (see [Sailbox.fork](#fork)). ##### Parameters | Parameter | Type | | ----------- | ------------------------------------------- | | `sailboxId` | `string` | | `options` | [`ForkSailboxOptions`](#forksailboxoptions) | ##### Returns `Promise`\<[`SailboxHandle`](#sailboxhandle)> #### getListener() > **getListener**(`sailboxId`, `guestPort`): `Promise`\<[`Listener`](#listener-1)> Fetch one listener by guest port without waking the box. ##### 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)> #### 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`>> #### isBuiltinBaseSpec() > **isBuiltinBaseSpec**(`spec`): `boolean` Whether a spec is a bare builtin base the backend ships prebuilt (no build needed). ##### Parameters | Parameter | Type | | --------- | ------------------------- | | `spec` | [`ImageSpec`](#imagespec) | ##### Returns `boolean` #### listApps() > **listApps**(): `Promise`\<[`AppInfo`](#appinfo)\[]> Every app the current org owns, newest first. ##### Returns `Promise`\<[`AppInfo`](#appinfo)\[]> #### listDir() > **listDir**(`sailboxId`, `path`): `Promise`\<[`DirEntry`](#direntry)\[]> List a directory's immediate entries as structured records. ##### Parameters | Parameter | Type | | ----------- | -------- | | `sailboxId` | `string` | | `path` | `string` | ##### Returns `Promise`\<[`DirEntry`](#direntry)\[]> #### 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)> #### 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`): `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` | ##### 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 box is provisioned. ##### Returns `Promise`\<`string`> #### pathExists() > **pathExists**(`sailboxId`, `path`): `Promise`\<`boolean`> Whether `path` exists in the guest. ##### Parameters | Parameter | Type | | ----------- | -------- | | `sailboxId` | `string` | | `path` | `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`): `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` | ##### Returns `Promise`\<`void`> #### resolveImage() > **resolveImage**(`def`): `Promise`\<[`ImageSpec`](#imagespec)> Resolve an image definition into a content-addressed [ImageSpec](#imagespec): the core 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)> #### 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`): `Promise`\<`void`> Sleep a Sailbox to disk (wakes on traffic). ##### Parameters | Parameter | Type | | ----------- | -------- | | `sailboxId` | `string` | ##### Returns `Promise`\<`void`> #### 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)> #### 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)> #### 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. The core is the single source of truth for endpoint resolution. ### 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. | *** ### 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` | | `imagebuilderUrl?` | `string` | Override the image-build endpoint (`host:port`). | `Omit.imagebuilderUrl` | | `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) * [`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): the create request plus a per-attempt timeout and an optional explicit client. `image` defaults to a Debian base. #### Extends * `Omit`\<[`CreateSailboxRequest`](#createsailboxrequest), `"image"` | `"appId"` | `"volumeMounts"` | `"ingressPorts"`>.[`ClientOptions`](#clientoptions) #### Properties | Property | Type | Description | Overrides | Inherited from | | --------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------- | | `app` | `string` \| [`App`](#app) | The owning app, or its id. | - | - | | `client?` | [`Client`](#client) | Use a specific client instead of the default (env) one. | - | [`ClientOptions`](#clientoptions).[`client`](#client-2) | | `diskGib?` | `number` | Disk size in whole GiB within the size's range; the size's default when omitted. | - | `Omit.diskGib` | | `image?` | [`ImageSpec`](#imagespec) \| [`Image`](#image) | Image spec, or an [Image](#image) builder (built and resolved at create). Defaults to a plain Debian base. | - | - | | `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. | - | - | | `memoryGib?` | `number` | Memory limit in whole GiB within the size's range; the size's default when omitted. | - | `Omit.memoryGib` | | `name` | `string` | The Sailbox name. | - | `Omit.name` | | `private?` | `boolean` | By default a Sailbox is org-wide: any credential in the org can exec, copy files, SSH, or run lifecycle operations on it. `true` restricts all of that to the creating user. An org admin can override that with a recorded reason for exec, files, and pause/resume/terminate/upgrade. SSH, exposing or removing listeners, and fork/checkpoint/restore stay creator-only. Requires a user-scoped API key. | - | `Omit.private` | | `size?` | [`SailboxSize`](#sailboxsize) | Resource size; `"m"` when omitted. | - | [`CreateSailboxRequest`](#createsailboxrequest).[`size`](#size-1) | | `ssh?` | `boolean` | Enable SSH on the new Sailbox after create: trust the org SSH CA, start `sshd`, and expose guest port 22 as TCP once the CA-only daemon owns it (an explicit port-22 ingress entry contributes just its allowlist). Equivalent to calling [Sailbox.enableSsh](#enablessh-1) after create. | `Omit.ssh` | - | | `timeoutSeconds?` | `number` | Bounds each create attempt (default 600s); pass `0` for no client-side timeout. Timed-out attempts are retried, reattaching to the same box, so when the overall budget is exhausted the box may still be coming up server-side: find or terminate it by `name`. | - | - | | `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"`> #### Properties | Property | Type | Description | Inherited from | | --------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | `appId` | `string` | Identifier of the owning app. | `Omit.appId` | | `diskGib?` | `number` | Disk size in whole GiB within the size's range; the size's default when omitted. | `Omit.diskGib` | | `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. | - | | `memoryGib?` | `number` | Memory limit in whole GiB within the size's range; the size's default when omitted. | `Omit.memoryGib` | | `name` | `string` | The Sailbox name. | `Omit.name` | | `private?` | `boolean` | By default a Sailbox is org-wide: any credential in the org can exec, copy files, SSH, or run lifecycle operations on it. `true` restricts all of that to the creating user. An org admin can override that with a recorded reason for exec, files, and pause/resume/terminate/upgrade. SSH, exposing or removing listeners, and fork/checkpoint/restore stay creator-only. Requires a user-scoped API key. | `Omit.private` | | `size?` | [`SailboxSize`](#sailboxsize) | Resource size; `"m"` when omitted. | - | | `ssh?` | `boolean` | Enable SSH on the new Sailbox after create: trust the org SSH CA, start `sshd`, and expose guest port 22 as TCP once the CA-only daemon owns it (an explicit port-22 ingress entry contributes just its allowlist). | `Omit.ssh` | | `volumeMounts?` | readonly [`VolumeMountInput`](#volumemountinput)\[] | NFS volumes to mount. | - | *** ### DeleteVolumeOptions Options for [Volume.delete](#delete). #### 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`. The shape comes from the core, with `type` narrowed to the closed set the SDK emits; the generated declaration widens it to `string`. #### 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. *** ### EnableSshOptions Options for enabling SSH on a Sailbox. #### Properties | Property | Type | Description | | ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowlist?` | readonly `string`\[] | Source CIDRs 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 Options for starting an exec (see the field docs on the generated `ExecStartOptions`). #### Extends * `Omit`\<`native.ExecStartOptions`, `"env"`> #### 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` | | `cols?` | `number` | Initial pty width in columns. | `Omit.cols` | | `cwd?` | `string` | Working directory to run the command in (shell commands only). | `Omit.cwd` | | `env?` | `Readonly`\<`Record`\<`string`, `string`>> | Extra environment for the command. Entries override the guest's defaults 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. | `Omit.idempotencyKey` | | `openStdin?` | `boolean` | Leave stdin open for `writeStdin`. | `Omit.openStdin` | | `pty?` | `boolean` | Allocate a pseudo-terminal. | `Omit.pty` | | `rows?` | `number` | Initial pty height in rows. | `Omit.rows` | | `term?` | `string` | TERM value for the pty. | `Omit.term` | | `timeoutSeconds?` | `number` | Wall-clock limit in seconds before the server kills the command. | `Omit.timeoutSeconds` | *** ### ExecResult The authoritative result of a finished exec. The buffered `stdout`/`stderr` are a capped, drop-oldest tail (the server keeps a bounded ring). To capture the complete output of a large-output command, stream it live and consult the `*Truncated`/`*Complete` flags. #### Properties | Property | Type | Description | | ----------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `exitCode` | `number` | The command's exit code. | | `stderr` | `string` | Buffered stderr (a capped tail; see `stderrTruncated`/`stderrComplete`). | | `stderrComplete` | `boolean` | True if the live stream delivered stderr through to exit (see `stdoutComplete`). | | `stderrTruncated` | `boolean` | True if buffered `stderr` dropped its oldest bytes (ring overflow). | | `stdout` | `string` | Buffered stdout (a capped tail; see `stdoutTruncated`/`stdoutComplete`). | | `stdoutComplete` | `boolean` | True if the live stream delivered stdout through to exit: a consumer that streamed live already has the complete stdout even if `stdout` here is truncated. | | `stdoutTruncated` | `boolean` | True if buffered `stdout` dropped its oldest bytes (ring overflow). | | `timedOut` | `boolean` | Whether the command was killed for exceeding its timeout. | *** ### ExposeOptions Options for [Sailbox.expose](#expose). #### Properties | Property | Type | Description | | ------------------ | ------------------------------------- | ------------------------------------------------------------- | | `allowlist?` | readonly `string`\[] | Source addresses allowed to reach the port; 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. | - | *** ### ForkSailboxOptions Options for [Sailbox.fork](#fork). #### Properties | Property | Type | Description | | ----------------------- | -------- | -------------------------------------------------------------------------------------------- | | `name?` | `string` | Display name for the child Sailbox; the server derives one when omitted. | | `timeoutSeconds?` | `number` | Bound on the fork call in whole seconds (must be positive); omitted uses the server default. | *** ### 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; defaults to the source box's name. | [`FromCheckpointRequest`](#fromcheckpointrequest).[`name`](#name-10) | | `timeoutSeconds?` | `number` | Restore timeout in whole seconds (positive); omitted uses the server default. | [`FromCheckpointRequest`](#fromcheckpointrequest).[`timeoutSeconds`](#timeoutseconds-5) | *** ### 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; defaults to the source box's name. | | `timeoutSeconds?` | `number` | Restore timeout in whole seconds (positive); omitted uses the server default. | *** ### 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. | *** ### ImageArchitecture > **ImageArchitecture** = `"amd64"` | `"arm64"` *** ### ImageBuild The state of a custom image build. #### Extends * `Omit`\<`native.ImageBuild`, `"status"`> #### Properties | Property | Type | Description | Inherited from | | --------------------- | ----------------------------------------- | ----------------------------------------------------------------- | ------------------- | | `errorMessage?` | `string` | Human-readable failure detail; present when `status` is `failed`. | `Omit.errorMessage` | | `imageId` | `string` | The content-addressed image id. | `Omit.imageId` | | `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. | | `timeoutSeconds?` | `number` | Timeout in seconds bounding the whole pipeline: hashing, uploads, and the build (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 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 lets the backend choose. | | `base?` | `string` | Base image to build on: `debian` or `devbox`. | | `env?` | `Record`\<`string`, `string`> | Environment variables baked into the image. | | `pythonVersion?` | `string` | Exact Python version to install as `python3`; unset uses the builder default. | | `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 image plus ordered build steps. #### Extends * `Omit`\<`native.ImageSpec`, `"base"` | `"buildSteps"` | `"architecture"`> #### Properties | Property | Type | Description | Inherited from | | ---------------------- | ----------------------------------------- | ----------------------------------------------------------------------------- | -------------------- | | `architecture?` | [`ImageArchitecture`](#imagearchitecture) | Target CPU architecture; unset lets the backend choose. | - | | `base?` | [`BaseImage`](#baseimage) | Base image to build on. | - | | `buildSteps?` | [`ImageBuildStep`](#imagebuildstep)\[] | Ordered build steps applied on top of the base image. | - | | `env?` | `Record`\<`string`, `string`> | Environment variables baked into the image. | `Omit.env` | | `pythonVersion?` | `string` | Exact Python version to install as `python3`; unset uses the builder default. | `Omit.pythonVersion` | *** ### 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`\[] | Source addresses allowed to reach the port; 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`. *** ### ListSailboxesOptions Options for [Sailbox.list](#list-1): 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-6) | *** ### 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-2) | | `offset?` | `number` | Page offset. | [`ListSailboxesQuery`](#listsailboxesquery).[`offset`](#offset-1) | | `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-2) | | `status?` | [`SailboxStatusFilter`](#sailboxstatusfilter) | Filter by lifecycle status. | [`ListSailboxesQuery`](#listsailboxesquery).[`status`](#status-7) | *** ### 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-2). #### 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. | *** ### 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). *** ### 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` | | `imagebuilderUrl` | `string` | Image-build endpoint (`host:port`). | `Omit.imagebuilderUrl` | | `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"` | `"idempotencyKey"`> #### 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). | `Pick.cwd` | | `env?` | `Readonly`\<`Record`\<`string`, `string`>> | Extra environment for the command. Entries override the guest's defaults 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. | `Pick.idempotencyKey` | | `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` | *** ### 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 handle becomes eligible for garbage collection, if bounded. | - | | `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. #### Extends * `Omit`\<`native.SailboxHandle`, `"status"`> #### Properties | Property | Type | Description | Inherited from | | ----------------- | ----------------------------------- | ----------------- | ---------------- | | `name` | `string` | The Sailbox name. | `Omit.name` | | `sailboxId` | `string` | The Sailbox id. | `Omit.sailboxId` | | `status` | [`SailboxStatus`](#sailboxstatus-1) | - | - | *** ### SailboxInfo A read snapshot of a Sailbox (get / list). Timestamps are RFC 3339 strings. #### Extends * `Omit`\<`native.SailboxInfo`, `"status"`> #### Properties | Property | Type | Description | Inherited from | | ---------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------- | | `appId` | `string` | Identifier of the owning app. | `Omit.appId` | | `appName` | `string` | Name of the owning app. | `Omit.appName` | | `architecture` | `string` | CPU architecture (for example `arm64`). | `Omit.architecture` | | `checkpointGeneration` | `number` | Monotonic checkpoint generation counter. | `Omit.checkpointGeneration` | | `cpuRequestedVcpu` | `number` | Requested CPU, in vCPUs. | `Omit.cpuRequestedVcpu` | | `cpuUsedVcpu` | `number` | Current CPU usage, in vCPUs. | `Omit.cpuUsedVcpu` | | `createdAt` | `string` | When the Sailbox was created (RFC 3339). | `Omit.createdAt` | | `createdByUserId?` | `string` | The user whose credential created this box (for a fork or restore, the user who ran it). Absent for service-key creates. | `Omit.createdByUserId` | | `deprecation?` | `SailboxDeprecation` | Actionable runtime deprecation notice, when an upgrade is needed. | `Omit.deprecation` | | `diskRequestedBytes` | `number` | Requested disk, in bytes. | `Omit.diskRequestedBytes` | | `diskUsedBytes` | `number` | Current disk usage, in bytes. | `Omit.diskUsedBytes` | | `errorMessage?` | `string` | Human-readable error detail when the box is in an error state. | `Omit.errorMessage` | | `guestSchemaVersion?` | `number` | The Sailbox runtime schema version the box last booted with. | `Omit.guestSchemaVersion` | | `imageId` | `string` | Identifier of the image the Sailbox was created from. | `Omit.imageId` | | `lastCheckpointedAt?` | `string` | When the most recent checkpoint was taken, if any (RFC 3339). | `Omit.lastCheckpointedAt` | | `memoryMib` | `number` | Configured memory, in MiB. | `Omit.memoryMib` | | `memoryRequestedBytes` | `number` | Requested memory, in bytes. | `Omit.memoryRequestedBytes` | | `memoryUsedBytes` | `number` | Current memory usage, in bytes. | `Omit.memoryUsedBytes` | | `name` | `string` | The Sailbox name. | `Omit.name` | | `sailboxId` | `string` | The Sailbox id. | `Omit.sailboxId` | | `startedAt?` | `string` | When the box last started, if it ever has (RFC 3339). | `Omit.startedAt` | | `stateDiskSizeGib` | `number` | Configured state-disk size, in GiB. | `Omit.stateDiskSizeGib` | | `status` | [`SailboxStatus`](#sailboxstatus-1) | - | - | | `updatedAt` | `string` | When the Sailbox was last updated (RFC 3339). | `Omit.updatedAt` | | `vcpuCount` | `number` | Configured number of vCPUs. | `Omit.vcpuCount` | | `visibility?` | `string` | `"private"` when access is restricted to the creator; absent/`"org"` is the default org-wide access. | `Omit.visibility` | *** ### 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"` 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. *** ### ShellOptions Options for [Sailbox.shell](#shell-1). #### Properties | Property | Type | Description | | ------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cwd?` | `string` | Working directory for the session. | | `noForward?` | `boolean` | While attached, the box'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 box's clipboard and in-box copies come 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. | | `noForwardBrowser?` | `boolean` | Set `true` to keep everything forwarded except browser opens. Ignored when `noForward` is set. | | `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. | *** ### 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 applied immediately (running box); false when deferred to the next wake. | `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. | ## 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 RPC 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) #### Constructors ##### Constructor > **new ApiError**(`message`, `details?`): [`ApiError`](#apierror) ###### Parameters | Parameter | Type | | --------- | ------------------ | | `message` | `string` | | `details` | `SailErrorDetails` | ###### Returns [`ApiError`](#apierror) ###### Overrides [`SailError`](#sailerror).[`constructor`](#constructor-14) #### 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-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | | `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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-11) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-11) | | `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 RPC 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-11) | | `rpcStatus` | `readonly` | `string` | RPC status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-14), 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### InternalError An unexpected internal SDK/core 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-14) #### 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-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | | `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-11) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-11) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-11) | | `rpcStatus` | `readonly` | `string` | RPC status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-14), 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | | `rpcStatus` | `readonly` | `string` | RPC status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-14), 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-11) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-11) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-11) | | `rpcStatus` | `readonly` | `string` | RPC status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-14), 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-11) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailboxExecutionError`](#sailboxexecutionerror).[`code`](#code-11) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-11) | | `rpcStatus` | `readonly` | `string` | RPC status classifying the failure (for example `"unavailable"`), empty when the failure carries no status. Distinct from [code](#code-14), which stays the taxonomy discriminator on every SailError. | [`SailboxExecutionError`](#sailboxexecutionerror).[`rpcStatus`](#rpcstatus-2) | *** ### 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) | *** ### 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-14) #### Properties | Property | Modifier | Type | Description | Inherited from | | ----------------- | ---------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | `code` | `readonly` | `string` | Stable, language-neutral error code (e.g. `"NotFound"`). | [`SailError`](#sailerror).[`code`](#code-14) | | `retryable` | `readonly` | `boolean` | Advisory: `true` when retrying the same call may succeed (a transport failure, HTTP 429 or a gateway 5xx, a transient RPC 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-14) |
# 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: "standard"` when the field is omitted. If you set it explicitly, it must be either `"standard"` or `"flex"` — the low-latency `"asap"` and `"priority"` tiers are rejected for batch items. For latency-sensitive 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.2-FP8", "max_output_tokens": 2048, "input": [{"role": "user", "content": f"What is a fun fact about the number {i}?"}], "metadata": {"completion_window": "standard"}, }, } 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 completed = {} 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": completed[response_id] = response 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 = completed[response_id] 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} completed 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.2-FP8") 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. # Sailbox 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. ```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, and timestamps (see [snapshot fields](#sailboxinfo)). 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_gib: int | None = None, disk_gib: int | None = None, ingress_ports: list[int | IngressPort] | None = None, volumes: Mapping[str, Volume | str] | None = None, ssh: bool = False, private: bool = False, ) -> 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"; memoryGib?: number; diskGib?: number; ingressPorts?: (number | IngressPortInput)[]; volumes?: Record; ssh?: boolean; private?: boolean; }): 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; Sailbox billing uses your actual CPU, memory, and disk consumption. `memory_gib` and `disk_gib` optionally tune the size's ceilings, in whole GiB within the size's range; raising a ceiling costs nothing on its own, and lowering one caps what the box can consume. 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 (instant create). For `@sail.function`, pin your interpreter with `image=sail.Image.debian_arm64`. | | `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"` or `"m"` (the platform default). Each size sets the vCPU count plus default memory and disk; `"s"` gives the fastest cold starts, forks, and resumes, and caps what a runaway workload can consume. | | `memory_gib` | `None` | Memory ceiling in whole GiB, within the size's range: 2-64 for `"s"`, 8-128 for `"m"`. The size's default when omitted. | | `disk_gib` | `None` | Disk size in whole GiB, within the size's range: 8-128 for `"s"`, 32-512 for `"m"`. The size's default when omitted. | | `ingress_ports` | `None` | 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). | | `ssh` | `False` | `True` to make the box SSH-ready in one call (trusts your org's SSH CA and starts `sshd`). See below. | | `private` | `False` | By default a box is org-wide: anyone in your org with an API key can exec, copy files, SSH, or run lifecycle operations on it. `True` restricts all of that to you, the creating user. Requires an API key minted by your user. See below. | 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 box, 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. `ssh=True` makes the box SSH-ready in one call: it runs [`enable_ssh`](#sailbox-enable-ssh) on the new box, 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. By default a box is org-wide: any credential in your org can exec, copy files, SSH into, or run lifecycle operations on it. `private=True` restricts all of that to you: only your credential can operate the box (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, and pause/resume/terminate/upgrade operations by setting `SAIL_OWNER_OVERRIDE_REASON` (or the `X-Sail-Owner-Override-Reason` header on raw HTTP calls); exposing or removing listeners, and fork, 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 box's sshd accepts only its creator's certificates. From the CLI, pass `--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 box; operations resume it on demand. 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 = False, term: str | None = None, cols: int = 0, rows: int = 0, env: Mapping[str, str] | None = None, idempotency_key: str | None = None, args: list | tuple | None = None, 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; term?: string; cols?: number; rows?: number; env?: Record; idempotencyKey?: string; }): 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: iterate its stdout/stderr for live output and call `wait()` for the buffered result. 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. 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: `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`. | | `term` | `None` | `$TERM` for a `pty` command. Defaults to `xterm-256color`. | | `cols` / `rows` | `0` | Initial `pty` window. Default 80x24. | | `env` | `None` | Extra environment variables for the command. Entries override the guest defaults (including `LANG`) 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. | | `idempotency_key` | `None` | Defaults to a generated key, so retrying the initial submission won't double-launch the command. | ```python Python theme={null} result = sb.exec("echo hi", timeout=5).wait() print(result.stdout, result.exit_code) # Live output (chunks are arrival-sized, not line-split): proc = sb.exec("for i in 1 2 3; do echo $i; sleep 1; done") for chunk in proc.stdout: print(chunk, end="") proc.wait() # 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); // Live output (chunks are arrival-sized, not line-split): 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); } await proc.wait(); // 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 paused or sleeping Sailbox wakes it. *** ## 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, check: bool = False, idempotency_key: str | None = None, ) -> ExecResult ``` ```typescript TypeScript theme={null} run(command: string | readonly string[], options?: { timeoutSeconds?: number; cwd?: string; env?: Record; check?: boolean; idempotencyKey?: string; 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). 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. In TypeScript, aborting `signal` force-cancels the remote command and rejects. The result's stdout and stderr are the buffered output, capped with the oldest bytes dropped first. For unbounded output, or to read output as it happens, stream it live with [`exec`](#exec). The interactive and detached `exec` options (`open_stdin`, `pty`, `background`) 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, timeout: int | None = None, no_forward: bool = False, no_forward_browser: bool = False, ) -> int ``` ```typescript TypeScript theme={null} shell(command?: string, options?: { shell?: string; term?: string; cwd?: string; timeoutSeconds?: number; noForward?: boolean; noForwardBrowser?: 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 box, without running an SSH server. `shell` overrides the login shell (default `$SHELL`, else `/bin/bash`); it is ignored when `command` is given. While the session is open, browser opens and localhost servers in the box are forwarded to your machine. When a program in the box 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 box starts on `localhost` keeps serving inside the box 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 box 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 box's clipboard, and text copied inside the box 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. Pass `no_forward_browser=True` (TS `noForwardBrowser`) to keep everything forwarded except browser opens. 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`. ***

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 | None = None, ) -> None ``` ```typescript TypeScript theme={null} write(path: string, data: Buffer | Uint8Array | string, options?: { createParents?: boolean; mode?: number; }): 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. | Parameter | Default | Description | | ---------------- | -------- | -------------------------------------------------------------------- | | `path` | required | Absolute destination path. | | `data` | required | Bytes or a string. Python also streams file-like objects in chunks. | | `create_parents` | `True` | Create missing parent directories. | | `mode` | `None` | POSIX permission bits (0–`0o777`). Defaults to `0o644` when omitted. | ```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\_stream

```python Python theme={null} def write_stream( path: str | PurePosixPath, *, create_parents: bool = True, mode: int | None = None, ) -> FileWriter ``` ```typescript TypeScript theme={null} writeStream(path: string, options?: { createParents?: boolean; mode?: number; }): 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. ```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) -> None def remove(path: str | PurePosixPath) -> None def exists(path: str | PurePosixPath) -> bool ``` ```typescript TypeScript theme={null} mkdir(path: string): Promise remove(path: string): Promise exists(path: string): Promise ``` ```rust Rust theme={null} pub async fn mkdir(&self, path: &str) -> Result<(), SailError> pub async fn remove(&self, path: &str) -> Result<(), SailError> pub async fn exists(&self, path: &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. ```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) -> list[DirEntry] ``` ```typescript TypeScript theme={null} ls(path: string): Promise ``` ```rust Rust theme={null} pub async fn ls(&self, path: &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. ```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); } ``` *** ## 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, SailError> ``` Prepares this box 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 CIDRs 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. `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 it becomes eligible for garbage collection (or `None` for a handle to an existing checkpoint that carries no fresh retention bound). *** ## from\_checkpoint ```python Python theme={null} @classmethod def from_checkpoint( checkpoint_id: str, *, name: str | None = None, 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: Option<&str>, timeout: Option, ) -> Result ``` Creates a new running Sailbox 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`, when set, must be `> 0`. A command still running when the checkpoint was taken does not resume in the new Sailbox. Its filesystem changes up to the checkpoint are kept, but the command itself does not continue. Start any commands you need again on the new Sailbox. *** ## fork ```python Python theme={null} def fork( *, name: str | None = None, timeout: int | None = None, ) -> Sailbox ``` ```typescript TypeScript theme={null} fork(options?: { name?: string; timeoutSeconds?: number; }): Promise ``` ```rust Rust theme={null} pub async fn fork(&self, options: ForkOptions) -> Result ``` Forks the Sailbox into a new running child in one call. The child copies the parent's memory and writable disk as they are now, branching from its live state while the parent keeps running. The copy is transient, with no durable checkpoint. To branch from a saved point in time instead, take a [`checkpoint`](#checkpoint) and start children from it with [`from_checkpoint`](#from_checkpoint). A common use is fan-out: prepare one Sailbox (install dependencies, warm caches, load a repository), then fork it once per task. ```python theme={null} base = sail.Sailbox.create(app=app, name="warm-base") base.run("git clone https://github.com/acme/repo /workspace && cd /workspace && npm ci") workers = [base.fork(name=f"task-{i}") for i in range(8)] ``` Like [`from_checkpoint`](#from_checkpoint), the child gets a fresh network identity: TCP connections are reset, and ingress ports are not inherited. A command still running in the parent does not continue in the child. The parent keeps running unchanged. Use [`checkpoint`](#checkpoint) plus [`from_checkpoint`](#from_checkpoint) instead when you want a durable snapshot to create Sailboxes from later; `fork` requires the parent to exist at the moment of the call. *** ## 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 box. 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() -> None def resume() -> Sailbox def terminate() -> None ``` ```typescript TypeScript theme={null} pause(): Promise sleep(): Promise resume(): Promise terminate(): Promise ``` ```rust Rust theme={null} pub async fn pause(&self) -> Result<(), SailError>; pub async fn sleep(&self) -> Result<(), SailError>; pub async fn resume(&self) -> Result<(), SailError>; pub async fn terminate(&self) -> Result<(), SailError>; ``` * **`pause`** checkpoints and pauses the Sailbox in memory until it is explicitly resumed or an operation wakes it. * **`sleep`** checkpoints the Sailbox to disk; inbound traffic, an operation, or an explicit `resume` wakes it. * **`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, SailError> ``` * **`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. *** ## 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. |

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` | CIDR prefixes or Sail app names allowed to connect. Empty means public. | CIDR 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 CIDR prefixes). 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, independent of this handle and the connection that launched it. Closing the handle or losing the network leaves the command running, and its result stays retrievable through `wait()`. **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). A reader that falls more than \~1 MiB behind skips the dropped head, keeping the tail. If the output stream breaks mid-run, live iteration ends early; `wait()` still returns the full result by reattaching to the command, but the live tail does not resume. **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()`** resolves the exec and returns its [result](#sailboxexecresult) with the full (capped) output, independent of how much was consumed via live iteration. 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 if it already arrived on the output stream, else nothing. A broken stream never sees the exit, so `wait()` is the authoritative answer. **`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()`** releases the output stream without touching the remote run. The command keeps going, and a later `wait()` reattaches to it. **`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` | Captured standard output. | | `stderr` | `str` | Captured standard error. | | `exit_code` | `int` | Process exit code. | | `timed_out` | `bool` | The command hit its `timeout` budget and was killed. | | `stdout_truncated` | `bool` | `stdout` exceeded the buffer cap; only the tail was kept. | | `stderr_truncated` | `bool` | `stderr` exceeded the buffer cap; only the tail was kept. | | `stdout_complete` | `bool` | The live stream delivered stdout through to the exit, so a live reader already holds the full output even if the buffered copy was truncated. | | `stderr_complete` | `bool` | Same, for stderr. |

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 box; `None` for service-key creates. | | `visibility` | `str \| None` | `"private"` for creator-restricted boxes; `None`/`"org"` otherwise. | 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` | | 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. 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, and common developer tools (`jq`, `gh`, `fd`, `fzf`, `uv`, `mise`, `tmux`, `git-lfs`, and more). They 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. 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` (aliases `debian_arm` / `debian_amd`) pin the image to your local Python version so `@sail.function` can deserialize local bytecode. ## 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::{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)) .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) -> ImageDefinition ``` ```typescript TypeScript theme={null} build(options?: { timeoutSeconds?: number }): Promise ``` ```rust Rust theme={null} pub async fn build_image_definition( &self, // Client def: &ImageDefinition, timeout: Duration, ) -> 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`. 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. **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. | # 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 specifically designed for 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: not only is our pricing >70% less than other providers, Sailboxes 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. Because billing follows actual usage, a higher ceiling costs nothing on its own. * Pause, resume, and fork 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. * Automatically sleep Sailboxes while waiting on Sail inference calls. ## Provider comparison | Provider | vCPU price | Sleep during inference | Max runtime | Memory snapshots | Local NVMe disks | Docker-in-Docker | Start/resume time | | -------------- | ------------------------------- | ---------------------- | -------------- | ---------------- | ---------------- | ---------------- | ----------------- | | Sailboxes | \$0.015/actively-used vCPU-hour | Yes | No fixed limit | Yes | Yes | Yes | \<3s | | Modal | \$0.071/reserved-vCPU-hour | No | 24 hours | Alpha | No | Alpha | \<500ms | | E2B | \$0.0504/reserved-vCPU-hour | No | 24 hours | Yes | No | Yes | \<1s | | Vercel Sandbox | \$0.128/reserved-vCPU-hour | No | 5 hours | No | Yes | Yes | \<1s | | Daytona | \$0.0504/reserved-vCPU-hour | No | No fixed limit | Experimental | Yes | Yes | \<500ms | | Cloudflare | \$0.072/actively-used vCPU-hour | No | No fixed limit | No | Yes | Yes | \<3s | ## 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. * [Pricing](/sailboxes-pricing): understand observed-usage billing dimensions and per-hour rates. * [Images](/sailboxes-images): choose a base image and build custom images with packages, commands, environment variables, and local files. * [Networking](/sailboxes-networking): expose HTTP services, raw TCP ports, and SSH from a Sailbox. * [Filesystem](/sailboxes-filesystem): read and write files at runtime, stream large files, and decide what belongs in an image. * [Lifecycle](/sailboxes-lifecycle): checkpoint, start from checkpoint, pause, sleep, resume, upgrade, and terminate Sailboxes. # Filesystem Source: https://docs.sailresearch.com/sailboxes-filesystem Read, write, and stream files in a running Sailbox Each Sailbox has a writable state disk. Runtime filesystem APIs operate on that writable disk; checkpoints preserve it across pause, sleep, resume, cloned children, and recovery. The examples below assume a running Sailbox `sb`. ## Write files Upload bytes or strings into the Sailbox filesystem. Paths must be absolute. Missing parent directories are created by default. ```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 { create_parents: true, mode: None, }, ) .await?; ``` Pass a mode to set POSIX permission bits. When omitted, writes default to `0o644`. ```python Python theme={null} sb.fs.write("/workspace/private.txt", "secret\n", mode=0o600) ``` ```typescript TypeScript theme={null} await sb.fs.write("/workspace/private.txt", "secret\n", { mode: 0o600 }); ``` ```rust Rust theme={null} use sail::WriteOptions; sb.fs().write( "/workspace/private.txt", b"secret\n", WriteOptions { create_parents: true, mode: Some(0o600), }, ) .await?; ``` Disable parent creation if you want writes to fail when parent directories are missing: ```python Python theme={null} sb.fs.write("/workspace/data/input.txt", "hello\n", create_parents=False) ``` ```typescript TypeScript theme={null} await sb.fs.write("/workspace/data/input.txt", "hello\n", { createParents: false, }); ``` ```rust Rust theme={null} use sail::WriteOptions; sb.fs().write( "/workspace/data/input.txt", b"hello\n", WriteOptions { create_parents: false, mode: None, }, ) .await?; ``` ## Read files Fetch a regular file back as bytes: ```python Python theme={null} data = sb.fs.read("/workspace/input.txt") print(data.decode()) ``` ```typescript TypeScript theme={null} const data = await sb.fs.read("/workspace/input.txt"); console.log(data.toString()); ``` ```rust Rust theme={null} let data = sb.fs().read("/workspace/input.txt").await?; println!("{}", String::from_utf8_lossy(&data)); ``` Whole-file reads buffer the full file in memory. For larger files, stream chunks instead: ```python Python theme={null} with open("output.bin", "wb") as out: for chunk in sb.fs.read_stream("/workspace/output.bin"): out.write(chunk) ``` ```typescript TypeScript theme={null} import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; await pipeline( (await sb.fs.readStream("/workspace/output.bin")).toReadable(), createWriteStream("output.bin"), ); ``` ```rust Rust theme={null} use std::io::Write; let mut out = std::fs::File::create("output.bin")?; let reader = sb.fs().read_stream("/workspace/output.bin").await?; while let Some(chunk) = reader.next().await { out.write_all(&chunk?)?; } ``` ## Work with directories The `fs` namespace also covers directory work. `mkdir` creates a directory and any missing parents, `ls` lists a directory's immediate entries as structured records (name, type, size, modified time, mode), `exists` checks a path, and `remove` deletes a file or directory tree: ```python Python theme={null} sb.fs.mkdir("/workspace/results") sb.fs.write("/workspace/results/input.txt", "hello\n") for entry in sb.fs.ls("/workspace/results"): print(entry.name, entry.type, entry.size) if sb.fs.exists("/workspace/results/input.txt"): sb.fs.remove("/workspace/results/input.txt") ``` ```typescript TypeScript theme={null} await sb.fs.mkdir("/workspace/results"); await sb.fs.write("/workspace/results/input.txt", "hello\n"); for (const entry of await sb.fs.ls("/workspace/results")) { console.log(entry.name, entry.type, entry.size); } if (await sb.fs.exists("/workspace/results/input.txt")) { await sb.fs.remove("/workspace/results/input.txt"); } ``` ```rust Rust theme={null} sb.fs().mkdir("/workspace/results").await?; sb.fs() .write("/workspace/results/input.txt", b"hello\n", Default::default()) .await?; for entry in sb.fs().ls("/workspace/results").await? { println!("{} {:?} {}", entry.name, entry.entry_type, entry.size); } if sb.fs().exists("/workspace/results/input.txt").await? { sb.fs().remove("/workspace/results/input.txt").await?; } ``` Archives and shell-native workflows still go through `exec()`. For many small files, create an archive locally, upload it, and unpack it inside the Sailbox: ```python Python theme={null} sb.fs.write("/workspace/project.tar.gz", open("project.tar.gz", "rb")) sb.exec("mkdir -p /workspace/project && tar -xzf /workspace/project.tar.gz -C /workspace/project").wait() ``` ```typescript TypeScript theme={null} import { readFileSync } from "node:fs"; await sb.fs.write("/workspace/project.tar.gz", readFileSync("project.tar.gz")); const untar = await sb.exec( "mkdir -p /workspace/project && tar -xzf /workspace/project.tar.gz -C /workspace/project", ); await untar.wait(); ``` ```rust Rust theme={null} use sail::{ExecOptions, WriteOptions}; let bytes = std::fs::read("project.tar.gz")?; sb.fs().write( "/workspace/project.tar.gz", &bytes, WriteOptions { create_parents: true, mode: None, }, ) .await?; let untar = sb.exec(vec![ "sh".to_string(), "-lc".to_string(), "mkdir -p /workspace/project && tar -xzf /workspace/project.tar.gz -C /workspace/project".to_string(), ], ExecOptions::default(), ) .await?; untar.wait().await?; ``` ## Persist state with checkpoints Runtime writes live on the Sailbox state disk. Checkpoint after important writes if you want recovery and future resumes to start from that point: ```python Python theme={null} sb.fs.write("/workspace/config.json", '{"ready": true}\n') sb.checkpoint() ``` ```typescript TypeScript theme={null} await sb.fs.write("/workspace/config.json", '{"ready": true}\n'); await sb.checkpoint(); ``` ```rust Rust theme={null} use sail::CheckpointOptions; use sail::WriteOptions; sb.fs().write( "/workspace/config.json", b"{\"ready\": true}\n", WriteOptions { create_parents: true, mode: None, }, ) .await?; sb.checkpoint(CheckpointOptions::default()).await?; ``` See [Lifecycle](/sailboxes-lifecycle) for checkpoint, start-from-checkpoint, pause, sleep, and resume behavior. ## Runtime files vs image files Use runtime filesystem APIs for inputs, outputs, logs, generated artifacts, and data that changes per Sailbox. Use [Images](/sailboxes-images) for packages, source files, and static assets that should be present before the VM boots. # Images Source: https://docs.sailresearch.com/sailboxes-images Build Sailbox images with packages, local files, commands, and environment variables Sailbox images define the root filesystem used to start a VM. Start from a Debian base image, then chain build steps to install dependencies, copy local files, run setup commands, and set environment variables. ```python Python theme={null} import sail image = ( sail.Image.debian_arm64 .apt_install("git", "curl") .pip_install("requests") .add_local_dir("./app", "/opt/app", ignore=["*.pyc", "__pycache__/"]) .env({"APP_ENV": "production"}) ) ``` ```typescript TypeScript theme={null} import { Image } from "@sailresearch/sdk"; const image = Image.debian("arm64") .aptInstall("git", "curl") .pipInstall("requests") .addLocalDir("./app", "/opt/app", { ignore: ["*.pyc", "__pycache__/"] }) .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::Arm64, 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, }, ], ..Default::default() }; ``` Image definitions are immutable values: each builder step gives you a new definition, so you can safely reuse a base image across multiple variants. ## Base images Use a Debian base image for the target architecture: ```python Python theme={null} arm_image = sail.Image.debian_arm64 amd_image = sail.Image.debian_amd64 ``` ```typescript TypeScript theme={null} const armImage = Image.debian("arm64"); const amdImage = Image.debian("amd64"); ``` ```rust Rust theme={null} let arm_image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, ..Default::default() }; let amd_image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Amd64, ..Default::default() }; ``` In Python, `sail.Image.debian_arm64` and `debian_amd64` (aliases `debian_arm` / `debian_amd`) pin the image to your local Python version for `@sail.function`. ## Install Python packages Install Python dependencies at build time: ```python Python theme={null} image = sail.Image.debian_arm64.pip_install( "requests", "beautifulsoup4", ) ``` ```typescript TypeScript theme={null} const image = Image.debian("arm64").pipInstall("requests", "beautifulsoup4"); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, steps: vec![ImageDefinitionStep::PipInstall(vec![ "requests".into(), "beautifulsoup4".into(), ])], ..Default::default() }; ``` Package installation happens at image build time, before any Sailbox starts. This is usually faster and more reproducible than installing packages in every new VM with `exec()`. ## Add local files Copy a single local file into the image: ```python Python theme={null} image = sail.Image.debian_arm64.add_local_file( "./config.json", "/etc/demo/config.json", mode=0o600, ) ``` ```typescript TypeScript theme={null} const image = Image.debian("arm64").addLocalFile( "./config.json", "/etc/demo/config.json", { mode: 0o600 }, ); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, steps: vec![ImageDefinitionStep::AddLocalFile { local_path: "./config.json".into(), remote_path: "/etc/demo/config.json".into(), mode: Some(0o600), }], ..Default::default() }; ``` Or copy a directory tree: ```python Python theme={null} image = sail.Image.debian_arm64.add_local_dir( "./app", "/opt/demo-app", ignore=["*.pyc", "__pycache__/"], ) ``` ```typescript TypeScript theme={null} const image = Image.debian("arm64").addLocalDir("./app", "/opt/demo-app", { ignore: ["*.pyc", "__pycache__/"], }); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, steps: vec![ImageDefinitionStep::AddLocalDir { local_path: "./app".into(), remote_path: "/opt/demo-app".into(), ignore: vec!["*.pyc".into(), "__pycache__/".into()], ignore_file: None, }], ..Default::default() }; ``` Remote paths must be absolute POSIX paths. Directory uploads preserve file modes from the local filesystem and skip symlinks. `ignore` accepts gitignore-style patterns, or point at an existing ignore file (such as `.gitignore`) instead. Use image files for source code, static assets, and configuration that should exist before boot. Use [Filesystem](/sailboxes-filesystem) for runtime inputs, outputs, logs, and data that changes per Sailbox. ## Install system packages Install Debian packages with apt: ```python Python theme={null} image = sail.Image.debian_arm64.apt_install( "git", "curl", "openssh-server", ) ``` ```typescript TypeScript theme={null} const image = Image.debian("arm64").aptInstall("git", "curl", "openssh-server"); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, steps: vec![ImageDefinitionStep::AptInstall(vec![ "git".into(), "curl".into(), "openssh-server".into(), ])], ..Default::default() }; ``` ## Run shell commands Run shell commands during the image build: ```python Python theme={null} image = ( sail.Image.debian_arm64 .apt_install("python3") .pip_install("requests") .run_commands("python3 -m pip show requests >/tmp/requests.txt") ) ``` ```typescript TypeScript theme={null} const image = Image.debian("arm64") .aptInstall("python3") .pipInstall("requests") .runCommand("python3 -m pip show requests >/tmp/requests.txt"); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, steps: vec![ ImageDefinitionStep::AptInstall(vec!["python3".into()]), ImageDefinitionStep::PipInstall(vec!["requests".into()]), ImageDefinitionStep::RunCommand( "python3 -m pip show requests >/tmp/requests.txt".to_string(), ), ], ..Default::default() }; ``` Build commands run once while the image is prepared. They do not run each time a Sailbox starts. ## Set environment variables Bake environment variables into Sailboxes created from the image: ```python Python theme={null} image = sail.Image.debian_arm64.env( { "APP_ENV": "demo", "LOG_LEVEL": "info", } ) ``` ```typescript TypeScript theme={null} const image = Image.debian("arm64").env({ APP_ENV: "demo", LOG_LEVEL: "info", }); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, env: HashMap::from([ ("APP_ENV".to_string(), "demo".to_string()), ("LOG_LEVEL".to_string(), "info".to_string()), ]), ..Default::default() }; ``` ## Create a Sailbox from an image Pass the image definition to `Sailbox.create()`: ```python Python theme={null} app = sail.App.find(name="custom-image-demo", mint_if_missing=True) image = ( sail.Image.debian_arm64 .apt_install("git", "curl") .pip_install("requests") .add_local_dir("./app", "/opt/app", ignore=["*.pyc", "__pycache__/"]) .env({"APP_ENV": "custom-image-demo"}) ) sb = sail.Sailbox.create( app=app, image=image, name="custom-image-demo", image_build_timeout=1800, ) ``` ```typescript TypeScript theme={null} const app = await App.find("custom-image-demo", { mintIfMissing: true }); const image = Image.debian("arm64") .aptInstall("git", "curl") .pipInstall("requests") .addLocalDir("./app", "/opt/app", { ignore: ["*.pyc", "__pycache__/"] }) .env({ APP_ENV: "custom-image-demo" }); const sb = await Sailbox.create({ app, name: "custom-image-demo", image, imageBuildTimeoutSeconds: 1800, }); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, env: HashMap::from([("APP_ENV".to_string(), "custom-image-demo".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, }, ], ..Default::default() }; let spec = client .build_image_definition(&image, Duration::from_secs(1800)) .await?; let app = client.find_app("custom-image-demo", /* mint_if_missing */ true).await?; let handle = client .create_sailbox( &CreateSailboxRequest { app_id: app.id, name: "custom-image-demo".to_string(), image: spec, ..Default::default() }, /* timeout */ None, ) .await?; ``` In Python and TypeScript, `Sailbox.create()` uploads any local files, builds the image if it has not already been built, then starts the VM from that image. In Rust, `build_image_definition` runs that same upload-and-build pipeline and returns the built spec to create from. ## Build an image ahead of time Build the image before creating a Sailbox: ```python Python theme={null} image = ( sail.Image.debian_arm64 .apt_install("git") .pip_install("requests") .build(timeout=1800) ) sb = sail.Sailbox.create( app=app, image=image, name="prebuilt-image-demo", ) ``` ```typescript TypeScript theme={null} const spec = await Image.debian("arm64") .aptInstall("git") .pipInstall("requests") .build({ timeoutSeconds: 1800 }); const sb = await Sailbox.create({ app, name: "prebuilt-image-demo", image: spec, }); ``` ```rust Rust theme={null} let image = ImageDefinition { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, steps: vec![ ImageDefinitionStep::AptInstall(vec!["git".into()]), ImageDefinitionStep::PipInstall(vec!["requests".into()]), ], ..Default::default() }; let spec = client .build_image_definition(&image, Duration::from_secs(1800)) .await?; let handle = client .create_sailbox( &CreateSailboxRequest { app_id: app_id.clone(), name: "prebuilt-image-demo".to_string(), image: spec, ..Default::default() }, /* timeout */ None, ) .await?; ``` ## Image caching Sail caches image builds per organization by content. If the base image, build steps, environment variables, and uploaded file contents are unchanged, later Sailboxes can reuse the existing image instead of rebuilding it. A change to any build step or local file content creates a new image. # Lifecycle Source: https://docs.sailresearch.com/sailboxes-lifecycle Checkpoint, start from checkpoint, pause, sleep, resume, upgrade, and terminate Sailboxes Sailboxes preserve their writable disk, in-memory state, and in-flight network requests across checkpoints and resumes. The examples below assume a running Sailbox `sb`. ```python Python theme={null} checkpoint = sb.checkpoint() # Create a durable checkpoint handle child = sail.Sailbox.from_checkpoint(checkpoint.checkpoint_id, name="rollout-1") sb.pause() # Checkpoint and pause until explicit resume sb.sleep() # Checkpoint and sleep until network ingress, exec, or resume sb.resume() # Resume a paused or sleeping Sailbox sb.upgrade() # Update the Sailbox runtime by rebooting on the same disk sb.terminate() # Permanently destroy the Sailbox ``` ```typescript TypeScript theme={null} const checkpoint = await sb.checkpoint(); // Create a durable checkpoint handle const child = await Sailbox.fromCheckpoint({ checkpointId: checkpoint.checkpointId, name: "rollout-1", }); await sb.pause(); // Checkpoint and pause until explicit resume await sb.sleep(); // Checkpoint and sleep until network ingress, exec, or resume await sb.resume(); // Resume a paused or sleeping Sailbox await sb.upgrade(); // Update the Sailbox runtime by rebooting on the same disk await sb.terminate(); // Permanently destroy the Sailbox ``` ```rust Rust theme={null} use sail::CheckpointOptions; // Create a durable checkpoint handle let checkpoint = sb.checkpoint(CheckpointOptions::default()).await?; let child = client .create_from_checkpoint(&checkpoint.checkpoint_id, Some("rollout-1"), /* timeout */ None) .await?; sb.pause().await?; // Checkpoint and pause until explicit resume sb.sleep().await?; // Checkpoint and sleep until ingress, exec, or resume sb.resume().await?; // Resume a paused or sleeping Sailbox sb.upgrade().await?; // Update the Sailbox runtime sb.terminate().await?; // Permanently destroy the Sailbox ``` 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. ## Checkpoint `checkpoint()` creates a durable checkpoint handle. Running Sailboxes are snapshotted first. Paused and sleeping Sailboxes return a handle to their existing checkpoint without waking. ```python Python theme={null} sb.exec("python3 setup.py").wait() checkpoint = sb.checkpoint(name="after-setup", ttl_seconds=30 * 86400) print(checkpoint.expires_at) ``` ```typescript TypeScript theme={null} await (await sb.exec("python3 setup.py")).wait(); const checkpoint = await sb.checkpoint({ name: "after-setup", ttlSeconds: 30 * 86400, }); console.log(checkpoint.expiresAt); ``` ```rust Rust theme={null} use sail::CheckpointOptions; use std::time::Duration; use sail::ExecOptions; let setup = sb .exec( vec!["python3".to_string(), "setup.py".to_string()], ExecOptions::default(), ) .await?; setup.wait().await?; let checkpoint = sb .checkpoint(CheckpointOptions { name: Some("after-setup".to_string()), ttl: Some(Duration::from_secs(30 * 86400)), }) .await?; println!("{:?}", checkpoint.expires_at); ``` Call `checkpoint()` after important setup, such as installing packages, fetching remote data, or writing files. On host failure, Sail restores from the most recent completed checkpoint and does not replay commands that ran before that checkpoint. `name` labels the handle. The TTL, when set, 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 the expiry time (`expires_at`), when it becomes eligible for garbage collection. ## Start From Checkpoint Create a separate running Sailbox from a durable checkpoint handle: ```python Python theme={null} checkpoint = sb.checkpoint() child = sail.Sailbox.from_checkpoint( checkpoint.checkpoint_id, name="experiment-1", ) ``` ```typescript TypeScript theme={null} const checkpoint = await sb.checkpoint(); const child = await Sailbox.fromCheckpoint({ checkpointId: checkpoint.checkpointId, name: "experiment-1", }); ``` ```rust Rust theme={null} use sail::CheckpointOptions; let checkpoint = sb.checkpoint(CheckpointOptions::default()).await?; let child = client .create_from_checkpoint(&checkpoint.checkpoint_id, Some("experiment-1"), /* timeout */ None) .await?; ``` The child gets new Sail identity and networking. Active TCP connections are reset in the child. A child starts with no inherited ingress, so add the ports the child should publish with [`expose`](/sailboxes-networking#add-or-remove-ports-at-runtime). Sleeping and paused Sailboxes can be cloned too: `checkpoint()` returns the existing checkpoint handle without waking the parent. Starting multiple children from the same checkpoint reuses the same checkpoint artifacts, so the second and later children avoid re-checkpointing the parent. ## Fork `fork()` clones a Sailbox in one call, without creating a durable checkpoint first: ```python Python theme={null} child = sb.fork(name="experiment-1") ``` ```typescript TypeScript theme={null} const child = await sb.fork({ name: "experiment-1" }); ``` ```rust Rust theme={null} use sail::ForkOptions; let child = sb .fork(ForkOptions { name: Some("experiment-1".into()), ..Default::default() }) .await?; ``` The child is a point-in-time copy of the parent's memory and writable disk, and the parent keeps running unchanged. A sleeping or paused parent forks without waking. The child is a new Sailbox with its own identity: open TCP connections are reset in the child, and it starts with no inherited ingress ports. Add the ports it should publish with [`expose`](/sailboxes-networking#add-or-remove-ports-at-runtime). The two clone paths differ in what you keep. `fork` is one call and keeps nothing: the child starts running, and there is no artifact to manage or reuse. `checkpoint` returns a durable handle you can start any number of Sailboxes from later with `from_checkpoint`, even after the parent is terminated. So for fan-out, set up one Sailbox (install dependencies, load your data), then `fork` it once per task. Take a `checkpoint` when you want to keep that state around to boot from later. ## Pause `pause()` checkpoints the Sailbox and powers it down until you explicitly resume it: ```python Python theme={null} sb.pause() sb.resume() ``` ```typescript TypeScript theme={null} await sb.pause(); await sb.resume(); ``` ```rust Rust theme={null} sb.pause().await?; sb.resume().await?; ``` Use pause when you want to preserve state but do not want the Sailbox to wake on network traffic. ## Sleep `sleep()` checkpoints the Sailbox and powers it down until network ingress, exec, or an explicit resume wakes it: ```python Python theme={null} sb.sleep() ``` ```typescript TypeScript theme={null} await sb.sleep(); ``` ```rust Rust theme={null} sb.sleep().await?; ``` Use sleep for idle services that should wake when they receive traffic. ## Resume `resume()` restores a paused or sleeping Sailbox: ```python Python theme={null} sb = sb.resume() ``` ```typescript TypeScript theme={null} await sb.resume(); ``` ```rust Rust theme={null} sb.resume().await?; ``` `exec` and file operations wake a sleeping Sailbox automatically, so binding an existing box by id needs no explicit resume in any language. ## Upgrade `upgrade()` reboots the Sailbox on its same disk onto the latest in-guest Sail agent, picking up new features, fixes, and performance improvements without recreating the Sailbox: ```python Python theme={null} result = sb.upgrade() print(result.applied) ``` ```typescript TypeScript theme={null} const { applied } = await sb.upgrade(); ``` ```rust Rust theme={null} let outcome = sb.upgrade().await?; println!("applied now: {}", outcome.applied); ``` ```bash theme={null} sail box upgrade ``` The filesystem is fully preserved; running processes stop and the Sailbox boots fresh, like a machine reboot. Restart any long-running services afterwards. On a running Sailbox the upgrade applies immediately and `applied` is true. On a paused or sleeping Sailbox the upgrade is recorded without waking it and `applied` is false; it applies automatically the next time the Sailbox wakes. A Sailbox that is already on the current runtime version reports true without rebooting. A Sailbox whose runtime is too old for Sail to resume safely is upgraded automatically the next time it wakes, as if `upgrade()` had been called on it first. Before a runtime version reaches that automatic-upgrade cutoff, `get` and `list` return a `deprecation` notice with a deadline and upgrade instructions. The CLI and Python/TypeScript SDKs also surface the first such notice as a warning once per process (Python emits `SailDeprecationWarning` through the `warnings` module); Rust callers can install a callback with `sail::set_notice_handler`. Treat it as advance notice to schedule `upgrade()` on your own terms before the deadline; it is not an immediate failure. ## Terminate `terminate()` permanently destroys the Sailbox: ```python Python theme={null} sb.terminate() ``` ```typescript TypeScript theme={null} await sb.terminate(); ``` ```rust Rust theme={null} sb.terminate().await?; ``` Termination is not reversible. Use `pause()` or `sleep()` when you want to keep the VM state for later. # Networking Source: https://docs.sailresearch.com/sailboxes-networking Expose HTTP services, raw TCP ports, and SSH from a Sailbox Sailboxes are closed to inbound traffic by default for security. ## Inbound access control To expose a service at creation time, pass the guest port in the create request, start a process that listens on that port, and wait for the listener endpoint to become ready: ```python Python theme={null} import sail app = sail.App.find(name="web-demo", mint_if_missing=True) sb = sail.Sailbox.create( app=app, name="web-demo", ingress_ports=[3000], ) sb.exec("python3 -m http.server 3000", background=True).wait() print(sb.wait_for_listener(3000, timeout=60).endpoint.url) ``` ```typescript TypeScript theme={null} import { App, Sailbox } from "@sailresearch/sdk"; const app = await App.find("web-demo", { mintIfMissing: true }); const sb = await Sailbox.create({ app, name: "web-demo", ingressPorts: [{ guestPort: 3000, protocol: "http" }], }); await ( await sb.exec("python3 -m http.server 3000", { background: true }) ).wait(); const listener = await sb.waitForListener(3000, { timeoutSeconds: 60 }); if (listener.endpoint?.kind === "http") { console.log(listener.endpoint.url); } ``` ```rust Rust theme={null} use sail::{ BaseImage, CreateSailboxRequest, ExecOptions, ImageArchitecture, ImageSpec, IngressPort, IngressProtocol, }; let app = client.find_app("web-demo", /* mint_if_missing */ true).await?; let sb = client .create_sailbox( &CreateSailboxRequest { app_id: app.id, name: "web-demo".into(), image: ImageSpec { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, ..Default::default() }, ingress_ports: vec![IngressPort { guest_port: 3000, protocol: IngressProtocol::Http, allowlist: Vec::new(), }], ..Default::default() }, /* timeout */ None, ) .await?; let serve = sb .exec_shell( "python3 -m http.server 3000", ExecOptions { background: true, ..Default::default() }, ) .await?; serve.wait().await?; use sail::{ListenerEndpoint, WaitForListenerOptions}; let listener = sb .wait_for_listener(3000, WaitForListenerOptions::default()) .await?; if let Some(ListenerEndpoint::Http { url }) = listener.endpoint() { println!("{url}"); } ``` You can also add and remove ports on a running Sailbox with `expose`/`unexpose` (see [Add or remove ports at runtime](#add-or-remove-ports-at-runtime)). Sail supports two inbound protocols: * HTTP, which returns a public HTTPS URL and supports HTTP and WebSocket traffic. * Raw TCP, which returns a public `host` and `port` for protocols such as SSH, Postgres, or custom TCP servers. Terminating a Sailbox also removes all of its listeners. ## HTTP and WebSocket services Expose a guest port over HTTP (in Python, a bare port number in `ingress_ports` means HTTP). The listener resolves to a routable HTTPS URL. ```python Python theme={null} import sail app = sail.App.find(name="web-demo", mint_if_missing=True) sb = sail.Sailbox.create( app=app, name="web-demo", ingress_ports=[3000], ) sb.exec("mkdir -p /srv/app && echo hello > /srv/app/index.html").wait() sb.exec("python3 -m http.server 3000", background=True, cwd="/srv/app").wait() listener = sb.wait_for_listener(3000, timeout=60) print(listener.endpoint.url) ``` ```typescript TypeScript theme={null} import { App, Sailbox } from "@sailresearch/sdk"; const app = await App.find("web-demo", { mintIfMissing: true }); const sb = await Sailbox.create({ app, name: "web-demo", ingressPorts: [{ guestPort: 3000, protocol: "http" }], }); await ( await sb.exec("mkdir -p /srv/app && echo hello > /srv/app/index.html") ).wait(); await ( await sb.exec("python3 -m http.server 3000", { background: true, cwd: "/srv/app", }) ).wait(); const listener = await sb.waitForListener(3000, { timeoutSeconds: 60 }); if (listener.endpoint?.kind === "http") { console.log(listener.endpoint.url); } ``` ```rust Rust theme={null} use sail::{ BaseImage, CreateSailboxRequest, ExecOptions, ImageArchitecture, ImageSpec, IngressPort, IngressProtocol, }; let app = client.find_app("web-demo", /* mint_if_missing */ true).await?; let sb = client .create_sailbox( &CreateSailboxRequest { app_id: app.id, name: "web-demo".into(), image: ImageSpec { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, ..Default::default() }, ingress_ports: vec![IngressPort { guest_port: 3000, protocol: IngressProtocol::Http, allowlist: Vec::new(), }], ..Default::default() }, /* timeout */ None, ) .await?; let setup = sb .exec_shell( "mkdir -p /srv/app && echo hello > /srv/app/index.html", ExecOptions::default(), ) .await?; setup.wait().await?; let serve = sb .exec_shell( "python3 -m http.server 3000", ExecOptions { cwd: Some("/srv/app".to_string()), background: true, ..Default::default() }, ) .await?; serve.wait().await?; use sail::{ListenerEndpoint, WaitForListenerOptions}; let listener = sb .wait_for_listener(3000, WaitForListenerOptions::default()) .await?; if let Some(ListenerEndpoint::Http { url }) = listener.endpoint() { println!("{url}"); } ``` Use the same URL for WebSocket clients by replacing `https://` with `wss://` when the process inside the Sailbox speaks WebSocket on that port. ```python Python theme={null} ws_url = listener.endpoint.url.replace("https://", "wss://") ``` ```typescript TypeScript theme={null} const wsUrl = listener.endpoint?.kind === "http" ? listener.endpoint.url.replace("https://", "wss://") : undefined; ``` ```rust Rust theme={null} use sail::ListenerEndpoint; let ws_url = match listener.endpoint() { Some(ListenerEndpoint::Http { url }) => url.replace("https://", "wss://"), _ => String::new(), }; ``` ## Raw TCP ports Expose a port as raw TCP instead of HTTP when the protocol is not HTTP-aware or the service already implements its own authentication. The listener resolves to a public `host` and `port` that any TCP client can dial directly. ```python Python theme={null} sb = sail.Sailbox.create( app=app, name="tcp-demo", ingress_ports=[sail.IngressPort(5432, "tcp", allowlist=["203.0.113.0/24"])], ) ``` ```typescript TypeScript theme={null} const sb = await Sailbox.create({ app, name: "tcp-demo", ingressPorts: [ { guestPort: 5432, protocol: "tcp", allowlist: ["203.0.113.0/24"] }, ], }); ``` ```rust Rust theme={null} use sail::{ BaseImage, CreateSailboxRequest, ImageArchitecture, ImageSpec, IngressPort, IngressProtocol, }; let sb = client .create_sailbox( &CreateSailboxRequest { app_id: app.id, name: "tcp-demo".into(), image: ImageSpec { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, ..Default::default() }, ingress_ports: vec![IngressPort { guest_port: 5432, protocol: IngressProtocol::Tcp, allowlist: vec!["203.0.113.0/24".to_string()], }], ..Default::default() }, /* timeout */ None, ) .await?; ``` After the service starts, wait for the endpoint and connect with the matching client: ```python Python theme={null} listener = sb.wait_for_listener(5432, timeout=60) host, port = listener.endpoint.host, listener.endpoint.port print(f"postgres://user:password@{host}:{port}/postgres") ``` ```typescript TypeScript theme={null} const listener = await sb.waitForListener(5432, { timeoutSeconds: 60 }); if (listener.endpoint?.kind === "tcp") { const { host, port } = listener.endpoint; console.log(`postgres://user:password@${host}:${port}/postgres`); } ``` ```rust Rust theme={null} use sail::{ListenerEndpoint, WaitForListenerOptions}; let listener = sb .wait_for_listener(5432, WaitForListenerOptions::default()) .await?; if let Some(ListenerEndpoint::Tcp { host, port }) = listener.endpoint() { println!("postgres://user:password@{host}:{port}/postgres"); } ``` ## SSH access For a quick interactive shell, use [`Sailbox.shell()`](/sailbox-sdk#shell) or `sail box shell`: it streams a terminal over the same channel as `exec`, uses no ingress port, and while open it forwards the box's localhost servers and browser opens to your machine. Set up SSH when you want a standard SSH endpoint: a devbox, your own client, `scp`, or port forwarding. It exposes guest port 22 as raw TCP, which counts against your org's raw-TCP endpoint limit. SSH access is organization-scoped: a box trusts your org's SSH certificate authority, so anyone in the org can connect with a short-lived certificate signed for their key. There are no per-box keys to manage. A private box is the exception: its sshd accepts only its creator's certificates. Enable SSH at create time, or with [`enable_ssh()`](/sailbox-sdk#sailbox-enable-ssh) on a Sailbox that already exists. Both install the org CA as trusted, start `sshd`, and expose port 22. `enable_ssh()` is idempotent; re-run it to bring `sshd` back up if it stops. ```python Python theme={null} import sail sb = sail.Sailbox.create( app=app, name="ssh-demo", ssh=True, ) ``` ```typescript TypeScript theme={null} const sb = await Sailbox.create({ app, name: "ssh-demo", ssh: true, }); ``` ```rust Rust theme={null} use sail::{BaseImage, CreateSailboxRequest, ImageArchitecture, ImageSpec}; let sb = client .create_sailbox( &CreateSailboxRequest { app_id: app.id, name: "ssh-demo".into(), image: ImageSpec { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, ..Default::default() }, ssh: true, ..Default::default() }, /* timeout */ None, ) .await?; ``` To connect, wire up your machine with the `sail box ssh` CLI and use the box's `.sail` shortcut: ```bash theme={null} sail box ssh alias ssh ssh-demo.sail ``` `alias` fetches your certificate and writes the shortcut into your SSH config. The shortcut is what presents the certificate, so alias a box before you connect. Run it on each machine you connect from, whether you or a teammate enabled the box; it only touches local config and never wakes or changes the box. `sail box ssh enable ` enables SSH on an existing box and runs `alias` for you in one step, as does creating a box with `sail box create --enable-ssh`. To restrict which sources may connect, pass an allowlist of CIDR prefixes: `sb.enable_ssh(allowlist=["203.0.113.0/24"])` in Python, `box.enableSsh({ allowlist: ["203.0.113.0/24"] })` in TypeScript, the `allowlist` argument of `enable_ssh` in Rust, or `sail box ssh enable --allowlist 203.0.113.0/24` from the CLI. A new allowlist replaces the current one, so re-running can tighten or relax access. Disabling SSH (`sail box ssh disable`) removes the port-22 listener along with its restriction. The SSH server persists across sleep and resume. An open SSH session drops when the Sailbox sleeps, but reconnecting with `ssh .sail` wakes it and uses the same host key. ## Inspect endpoints Look up one listener when you know the guest port, or list all published ports for a Sailbox: ```python Python theme={null} for listener in sb.listeners(): listener = sb.wait_for_listener(listener.guest_port, timeout=60) if listener.protocol == "http": print(listener.guest_port, listener.endpoint.url) else: endpoint = listener.endpoint print(listener.guest_port, f"{endpoint.host}:{endpoint.port}") ``` ```typescript TypeScript theme={null} for (const { guestPort } of await sb.listeners()) { const listener = await sb.waitForListener(guestPort, { timeoutSeconds: 60 }); switch (listener.endpoint?.kind) { case "http": console.log(guestPort, listener.endpoint.url); break; case "tcp": console.log( guestPort, `${listener.endpoint.host}:${listener.endpoint.port}`, ); break; } } ``` ```rust Rust theme={null} use sail::ListenerEndpoint; for listener in sb.listeners().await? { match listener.endpoint() { Some(ListenerEndpoint::Http { url }) => println!("{} {url}", listener.guest_port), Some(ListenerEndpoint::Tcp { host, port }) => { println!("{} {host}:{port}", listener.guest_port) } None => println!("{} (not routable yet)", listener.guest_port), } } ``` HTTP listeners expose a public URL. TCP listeners expose a public `host` and `port`. ## Add or remove ports at runtime You don't have to declare every port at create time. `expose` publishes a new port on a running Sailbox and `unexpose` removes one, with no guest restart. The listener returned by `expose` carries the resolved endpoint but an `"unknown"` route status: the response confirms configuration, not reachability. `wait_for_listener` confirms the route is live. ```python Python theme={null} # Add an HTTP port and read its URL. sb.expose(8080) print(sb.wait_for_listener(8080, timeout=60).endpoint.url) # Add a CIDR-restricted raw-TCP port, then remove it when you're done. sb.expose(5432, protocol="tcp", allowlist=["203.0.113.0/24"]) sb.unexpose(5432) ``` ```typescript TypeScript theme={null} // Add an HTTP port and read its URL. await sb.expose(8080); const listener = await sb.waitForListener(8080, { timeoutSeconds: 60 }); if (listener.endpoint?.kind === "http") { console.log(listener.endpoint.url); } // Add a CIDR-restricted raw-TCP port, then remove it when you're done. await sb.expose(5432, { protocol: "tcp", allowlist: ["203.0.113.0/24"] }); await sb.unexpose(5432); ``` ```rust Rust theme={null} use sail::{IngressProtocol, ListenerEndpoint, WaitForListenerOptions}; // Add an HTTP port and read its URL once routable. sb.expose(8080, IngressProtocol::Http, &[]).await?; let listener = sb .wait_for_listener(8080, WaitForListenerOptions::default()) .await?; if let Some(ListenerEndpoint::Http { url }) = listener.endpoint() { println!("{url}"); } // Add a CIDR-restricted raw-TCP port, then remove it when you're done. sb.expose(5432, IngressProtocol::Tcp, &["203.0.113.0/24".to_string()]) .await?; sb.unexpose(5432).await?; ``` Re-exposing a port under the same protocol updates its `allowlist` to the value you pass. Use it to tighten or relax a live port. Unexposing a raw-TCP port stops serving it, so it no longer counts against your org's raw-TCP endpoint limit. Its public `host:port` stays owned by your org. Sail may reassign an idle address to another of your Sailboxes; it is never given to another org. If the address has not been reassigned, re-exposing the same guest port reclaims it exactly. Otherwise the re-expose allocates a new address, so read the endpoint from the response instead of assuming the old one. A stale client that dials an old raw-TCP address may therefore reach a different Sailbox in your org, and never another org's. A raw-TCP guest port can't be repurposed to HTTP; use a different guest port. HTTP ports carry no such reservation and are released on `unexpose`. `expose` and `unexpose` work on a paused or sleeping Sailbox without waking it; a later resume serves the new listener. From the CLI: ```bash theme={null} sail box expose 8080 sail box expose 5432 --tcp --allowlist 203.0.113.0/24 sail box unexpose 5432 ``` ## Access controls A raw-TCP port is reachable from the public internet with no platform-side authentication. The in-guest daemon, such as `sshd`, is the only access control, so make sure it requires credentials. To restrict which sources may connect, pass `allowlist`. Entries that parse as CIDR prefixes match source IPs on HTTP and TCP listeners. Other entries are treated as Sail app names for authenticated Sailbox-origin traffic, supported on HTTP listeners: ```python Python theme={null} ingress_ports=[ sail.IngressPort(5432, "tcp", allowlist=["203.0.113.0/24"]), sail.IngressPort(8080, allowlist=["203.0.113.0/24", "admin-tools"]), ] ``` ```typescript TypeScript theme={null} import type { IngressPortInput } from "@sailresearch/sdk"; const ingressPorts: IngressPortInput[] = [ { guestPort: 5432, protocol: "tcp", allowlist: ["203.0.113.0/24"] }, { guestPort: 8080, protocol: "http", allowlist: ["203.0.113.0/24", "admin-tools"], }, ]; ``` ```rust Rust theme={null} use sail::{IngressPort, IngressProtocol}; let ingress_ports = vec![ IngressPort { guest_port: 5432, protocol: IngressProtocol::Tcp, allowlist: vec!["203.0.113.0/24".to_string()], }, IngressPort { guest_port: 8080, protocol: IngressProtocol::Http, allowlist: vec!["203.0.113.0/24".to_string(), "admin-tools".to_string()], }, ]; ``` Connections from outside the listed CIDR prefixes, or from authenticated Sailbox-origin requests whose app name is not listed, fail before reaching the guest. App names do not need to exist when you configure the listener. Cross-organization app-name matches are denied. An empty or omitted `allowlist` means any source may connect. For HTTP requests from one Sailbox to an app-name allowlisted listener, include the SDK-provided source headers. Inside the calling Sailbox, the Python SDK reads its own identity: ```python theme={null} import requests import sail requests.get(listener.endpoint.url, headers=sail.ingress_auth_headers()) ``` From outside a Sailbox, such as an orchestrator or test driving Sailboxes from the host, fetch the same headers for a specific live Sailbox you own (requires an organization-scoped API key): ```python Python theme={null} headers = source_sb.ingress_auth_headers() requests.get(listener.endpoint.url, headers=headers) ``` ```typescript TypeScript theme={null} const headers = await sourceSb.ingressAuthHeaders(); if (listener.endpoint?.kind === "http") { await fetch(listener.endpoint.url, { headers }); } ``` ```rust Rust theme={null} // Attach these header pairs to the HTTP client you use to call the listener. let headers = source_sb.ingress_auth_headers().await?; for (name, value) in &headers { println!("{name}: {value}"); } ``` Raw-TCP connections do not carry source app identity, so app-name entries are rejected on `"tcp"` listeners: a raw-TCP `allowlist` must contain only CIDR prefixes. Exposing a well-known unauthenticated service port, such as Postgres, MySQL, or Redis, as raw TCP without an explicit `allowlist` is rejected. Set source restrictions, or use the all-sources CIDR allowlist to confirm you want it publicly reachable: ```python Python theme={null} ingress_ports=[ sail.IngressPort(5432, "tcp", allowlist=["0.0.0.0/0", "::/0"]), ] ``` ```typescript TypeScript theme={null} import type { IngressPortInput } from "@sailresearch/sdk"; const ingressPorts: IngressPortInput[] = [ { guestPort: 5432, protocol: "tcp", allowlist: ["0.0.0.0/0", "::/0"] }, ]; ``` ```rust Rust theme={null} use sail::{IngressPort, IngressProtocol}; let ingress_ports = vec![IngressPort { guest_port: 5432, protocol: IngressProtocol::Tcp, allowlist: vec!["0.0.0.0/0".to_string(), "::/0".to_string()], }]; ``` ## Sleeping services Sleeping Sailboxes wake on network ingress. If you call `sleep()` on a Sailbox with exposed listeners, the next inbound HTTP, WebSocket, or TCP connection wakes the VM before forwarding traffic to the guest process. ```python Python theme={null} sb.sleep() # A later request to listener.endpoint.url wakes the Sailbox. ``` ```typescript TypeScript theme={null} await sb.sleep(); // A later request to the listener's URL wakes the Sailbox. ``` ```rust Rust theme={null} sb.sleep().await?; // A later request to the listener's URL wakes the Sailbox. ``` Use `pause()` instead when you want to preserve VM state without waking on network traffic. ## Ports and cleanup Ports must be unique within a Sailbox and between `1` and `65535`. Ports `10000`, `10001`, `15001`, and `15002` are reserved by Sail. Port `22` is the SSH port and cannot be exposed as HTTP; expose it as raw TCP instead. Each org can hold a limited number (32) of concurrent raw-TCP endpoints. The limit counts actively-exposed endpoints, so `unexpose` frees a slot. An idle `host:port` stays owned by your org, even after the Sailbox that used it terminates. Sail may reassign it to another of your Sailboxes; another org never receives it. Contact us to raise your limit if you need more concurrent endpoints. # Sailbox Pricing Source: https://docs.sailresearch.com/sailboxes-pricing Observed usage billing dimensions and rates for Sailboxes Sailboxes charge only for the CPU, memory, and disk a Sailbox actually uses while running. If a Sailbox with a 4 vCPU maximum is using 0.01 vCPU, you are charged for 0.01 vCPU. You are not charged while a Sailbox is sleeping, paused, checkpointing, or cold-starting. Every Sailbox creation also has a one-time charge based on the selected size. You pick a Sailbox size at create time: `s` or `m`. Each size sets the vCPU count plus a default memory and disk ceiling: | 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) | Pass `memory_gib` or `disk_gib` at create time to set your memory or disk ceiling, up to the max allowable ceiling. Because billing is by observed usage, a bigger size or higher ceiling does not on its own increase your cost; it just allows your workload to spend more. The default size is `m`. Choose `s` when you want the fastest cold starts, forks, and resumes. Its lower ceilings also cap what a runaway workload can consume, so you don't accidentally use more than you need. | 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 | # Quickstart Source: https://docs.sailresearch.com/sailboxes-quickstart Start your first Sailbox and run code inside it Start a Sailbox, run code inside it, inspect the filesystem, and clean it up.
1

Create a Sail account

Sign up at the [Sail dashboard](https://app.sailresearch.com).
2

Install the Sail CLI

```bash theme={null} # macOS and Linux curl -fsSL https://cli.sailresearch.com/install.sh | sh # Windows PowerShell irm https://cli.sailresearch.com/install.ps1 | iex ```
3

Create and connect to your first Sailbox

```bash theme={null} $ sail shell # open the Sailbox TUI sail ▶ create --app sailbox-quickstart --name quickstart # create a Sailbox sail ▶ connect # connect to the Sailbox as root root@quickstart:~# ... root@quickstart:~# exit # return to sail shell sail ▶ top # view live Sailbox usage sail ▶ terminate # terminate the Sailbox sail ▶ exit # close sail shell ```
1

Create a Sail account

Sign up at the [Sail dashboard](https://app.sailresearch.com).
2

Install the Python SDK

```bash theme={null} pip install sail # or uv add sail ```
3

Authenticate

```bash theme={null} sail auth login ```
4

Create and run a Sailbox

Create `quickstart.py`: ```python theme={null} import sail app = sail.App.find(name="sailbox-quickstart", mint_if_missing=True) sb = sail.Sailbox.create(app=app, name="quickstart") result = sb.run("python3 -c 'print(\"hello world\")'") print(result.stdout) sb.fs.write("/tmp/note.txt", "from my machine") print(sb.fs.read("/tmp/note.txt").decode()) sb.terminate() ```
5

Run it

```bash theme={null} python quickstart.py ```
1

Create a Sail account

Sign up at the [Sail dashboard](https://app.sailresearch.com).
2

Install the TypeScript SDK

```bash theme={null} npm install @sailresearch/sdk npm install --save-dev tsx ```
3

Install the Sail CLI

```bash theme={null} # macOS and Linux curl -fsSL https://cli.sailresearch.com/install.sh | sh # Windows PowerShell irm https://cli.sailresearch.com/install.ps1 | iex ```
4

Authenticate

```bash theme={null} sail auth login ```
5

Create and run a Sailbox

Create `quickstart.ts`: ```typescript theme={null} import { App, Sailbox } from "@sailresearch/sdk"; async function main() { const app = await App.find("sailbox-quickstart", { mintIfMissing: true }); const sb = await Sailbox.create({ app, name: "quickstart" }); try { const hello = await sb.run(["python3", "-c", "print('hello world')"]); console.log(hello.stdout); await sb.fs.write("/tmp/note.txt", "from my machine"); console.log((await sb.fs.read("/tmp/note.txt")).toString()); } finally { await sb.terminate(); } } main().catch((error) => { console.error(error); process.exitCode = 1; }); ```
6

Run it

```bash theme={null} npx tsx quickstart.ts ```
1

Create a Sail account

Sign up at the [Sail dashboard](https://app.sailresearch.com).
2

Install the Rust SDK

```bash theme={null} cargo new hello-sail && cd hello-sail cargo add sail-rs cargo add tokio --features macros,rt-multi-thread ```
3

Install the Sail CLI

```bash theme={null} # macOS and Linux curl -fsSL https://cli.sailresearch.com/install.sh | sh # Windows PowerShell irm https://cli.sailresearch.com/install.ps1 | iex ```
4

Authenticate

```bash theme={null} sail auth login ```
5

Create and run a Sailbox

Replace `src/main.rs` with: ```rust theme={null} use sail::{ BaseImage, Client, CreateSailboxRequest, ImageArchitecture, ImageSpec, RunOptions, SailError, WriteOptions, }; #[tokio::main] async fn main() -> Result<(), SailError> { let client = Client::from_env()?; let app = client .find_app("sailbox-quickstart", /* mint_if_missing */ true) .await?; let sb = client .create_sailbox( &CreateSailboxRequest { app_id: app.id, name: "quickstart".into(), image: ImageSpec { base: Some(BaseImage::Debian), architecture: ImageArchitecture::Arm64, ..Default::default() }, ..Default::default() }, /* timeout */ None, ) .await?; let result = async { let hello = sb .run( ["python3", "-c", "print('hello world')"], RunOptions::default(), ) .await?; print!("{}", hello.stdout); sb.fs() .write("/tmp/note.txt", b"from my machine", WriteOptions::default()) .await?; let note = sb.fs().read("/tmp/note.txt").await?; println!("{}", String::from_utf8_lossy(¬e)); Ok::<(), SailError>(()) } .await; let terminate_result = sb.terminate().await; result?; terminate_result?; Ok(()) } ```
6

Run it

```bash theme={null} cargo run ```
## Next steps * Build custom dependencies into the root filesystem with [Images](/sailboxes-images). * Learn runtime file transfer in [Filesystem](/sailboxes-filesystem). * Expose HTTP, raw TCP, and SSH with [Networking](/sailboxes-networking). * Checkpoint, clone from checkpoints, pause, and sleep VMs in [Lifecycle](/sailboxes-lifecycle). # Tinker Source: https://docs.sailresearch.com/sdk-tinker Use Sail inference inside Tinker RL/training loops: sail.SailTokenCompleter and Tinker checkpoint signed URL helpers 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="priority", ) # 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 = "priority", 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 `"priority"`. `"asap"` is not supported by `SailTokenCompleter`; use `"priority"`, `"standard"`, or `"flex"` where the model supports them. | | `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="priority", tinker_lora_signed_url=signed_url, adapter_config={"r": 16, "alpha": 32}, ) ``` # 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`), `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 | | **Image input** | `input_image` content blocks on [multimodal models](/models). Non-multimodal models accept text only. | | **Output logprobs** | `include: ["message.output_text.logprobs"]` returns one logprob per output token (best effort; omitted when unavailable). | ### 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 input is supported on multimodal models (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
OpenAI SDK compatible API reference
### Supported features | Feature | Details | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Core parameters** | `model`, `messages`, `max_completion_tokens`, `temperature`, `top_p`, `user` | | **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`) | | **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) | | **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. | ### Not yet supported | Feature | Notes | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **Multiple choices** | `n` must be `1`. | | **Multimodal content** | Audio (`input_audio`) content parts are not supported. Image (`image_url`) input is supported on multimodal models (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` is either `"stop"` or `"tool_calls"`. Other values like `"length"` and `"content_filter"` are not returned. * `system_fingerprint` and `service_tier` are not included in responses. * `logprobs` is always `null`. *** ## Messages API
Anthropic Messages format Anthropic 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 `tool_use`/`tool_result` content blocks round-trip. | | **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`, …) | | **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. | | **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). | | **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 — `"end_turn"` normally, `"tool_use"` when the model calls a tool. * 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). ### Compatibility notes * Sail uses `Authorization: Bearer ` for authentication. The Anthropic `x-api-key` header is not supported. When using the Anthropic SDK, pass your key via `auth_token` instead of `api_key`: ```python theme={null} from anthropic import Anthropic client = Anthropic( auth_token="YOUR_SAIL_API_KEY", base_url="https://api.sailresearch.com", ) ``` * The `anthropic-version` header is not required or checked. * Error responses use the OpenAI-style error envelope format. *** ## 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. 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** — set `metadata.completion_window` to `"asap"`, `"priority"`, `"standard"`, or `"flex"` to control scheduling and pricing. See [Completion Windows](/completion-windows) and [Pricing](/pricing). * **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 — 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 (`priority` 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="priority", ) 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` | `"priority"` | [Completion window](/completion-windows) for each request. 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="priority", 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="priority", 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)). * **LoRA requests cannot use the `asap` completion window.** Set `completion_window` to `priority` (the default), `standard`, or `flex` when the selected model supports that window (see [Completion Windows](/completion-windows)). * **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. In our example, `examples/tinker_adapter_config.example.json` is a ready-made one for Kimi K2.6 at rank 32. Passing `ttl_seconds` tells Tinker to expire the checkpoint, so per-step checkpoints clean themselves up instead of piling up. ```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="priority", 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("examples/tinker_adapter_config.example.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`. ## 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. # 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": { "priority": 0.78, "asap": 0.12, "standard": 0.09, "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.2-FP8": { "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": { "priority": 10432.31, "standard": 366.8 } } ], "models": [ { "model": "zai-org/GLM-5.2-FP8", "total": 10657.96, "tokens": 439626559, "input_tokens": 432900739, "output_tokens": 6725820, "cached_tokens": 419001344, "slas": { "priority": 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)) ``` ### 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, "model": "moonshotai/Kimi-K2.6", "sla": "priority" } ], "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, "model": "moonshotai/Kimi-K2.6", "sla": "priority" } ] } ``` | 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 (e.g. `sk_QpCO`); `null` for deleted keys. | ### 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.2-FP8", "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`, `standard`, `flex` (legacy: `15m`, `15min`, `24h`, `24hr`) | 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": { "priority": { "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`, `standard`, `flex` | 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 the full trajectory. The SDK returns the link from `sail.voyage.dashboard_url()`, and the API returns it as `dashboard_url`. 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.2-FP8", 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.2-FP8", 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.2-FP8", 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.2-FP8", 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.2-FP8", 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. * **Do not open more than one Voyage per controller process** unless you intentionally have parallel-independent tasks. The SDK has a process- global "current Voyage." Multiple Voyages confuse 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.2-FP8", 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.2-FP8`, 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.2-FP8`; 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.2-FP8"}) 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.

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. ```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 process, and 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 and makes it the current Voyage for the process. `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.2-FP8", 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. ```python theme={null} import sail resp = sail.inference.responses.create( model="zai-org/GLM-5.2-FP8", input="Say hello in one sentence.", ) chat = sail.inference.chat.completions.create( model="zai-org/GLM-5.2-FP8", messages=[{"role": "user", "content": "hello"}], ) ``` ## 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. | 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. | ## 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.2-FP8", 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.2-FP8", 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.2-FP8", 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 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.2-FP8", input="...") # auto-attributed ``` `wrap_openai` wraps `responses.create`, `responses.retrieve`, and `chat.completions.create` in place (whichever exist), is idempotent, and follows the current Voyage per call. Pass `voyage=` to pin one. 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.2-FP8", "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.2-FP8", 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.2-FP8", "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 JSON object returned by `GET /v1/responses/{response_id}` ## 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.2-FP8", 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 delivery up to **3 times** (e.g. non-2xx status or network errors). Respond with a **2xx** status as soon as you have accepted the payload so that Sail stops retrying. * **Timeout:** Each attempt has a **30-second** timeout. If the request times out or fails, the next attempt is made. * **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.2-FP8", "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.