Skip to main content
Two patterns you’ll need beyond the quickstart: multi-agent within one Voyage and attaching subprocesses to a parent Voyage.

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.
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},
)
# (Or wrap everything below in `with sail.voyage.run(...)` — create() is
# used here so the Sailbox can be created first and terminated before the
# terminal event.)

with voyage.agent("GitHub", role="source_control"):
    with voyage.span("clone"):
        sb.exec(
            "git clone --depth 1 https://github.com/public/repo.git /tmp/repo",
            timeout=120,
        ).wait()

with voyage.agent("TestRunner", role="executor"):
    with voyage.span("unit-tests"):
        sb.exec("cd /tmp/repo && just test-backend-unit", timeout=600).wait()

with voyage.agent("Reviewer", role="reviewer"):
    with voyage.span("draft-review"):
        response = sail.inference.responses.create(
            model="zai-org/GLM-5",
            input="Review the diff in /tmp/repo...",
            background=False,
            timeout=120,
        )
        voyage.event("review.drafted", payload={"response_id": response["id"]})

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.

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:
    with voyage.agent("Orchestrator", role="planner"):
        with voyage.span("spawn-workers"):
            subprocess.run(
                ["python", "worker.py"],
                env={**os.environ, **voyage.child_env()},
                check=True,
            )
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 — joins the parent's Voyage.
voyage = sail.voyage.attach()

with voyage.agent("Worker", role="executor"):
    with voyage.span("do-work"):
        voyage.event("worker.tick", payload={"step": 1})

# Note: do NOT call voyage.complete() in the child.
# The parent owns terminal status. First-terminal-wins.

Common cross-pattern pitfalls

  • Don’t complete the Voyage from inside an agent block. Call voyage.complete() at the top level, after all agent contexts have exited.
  • Don’t reuse a Voyage across logical tasks. One Voyage per task; if the agent does N tasks, that’s N Voyages.
  • Don’t put secrets in event payloads. Sail has redaction at the SQL ingestion layer, but the safest pattern is to summarize / hash before storing.
  • Don’t 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