> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sailresearch.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Voyages

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

<Note>
  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.
</Note>

<h2 id="sail-voyage-run">
  sail.voyage.run
</h2>

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

<h2 id="sail-voyage-create">
  sail.voyage.create
</h2>

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

<h2 id="sail-voyage-attach">
  sail.voyage.attach
</h2>

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