Skip to main content
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.
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:
@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:
# 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.
# 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