How do I trace a multi-agent workflow built with the Claude Agent SDK?
Rius traces a multi-agent workflow built with the Claude Agent SDK as one trace per run, with each agent as its own named span. You wrap each agent in observe with the AGENT kind and a generation, then call them from a root span, and Rius nests them in order. The Claude Code runtime that the SDK drives runs in a separate process, so each agent’s turn arrives as one generation with model, tokens and cost, not as inner steps.
How it works
The Claude Agent SDK drives the Claude Code runtime in its own process, so no model client runs in yours and auto-instrumentation has nothing to hook. Coverage is manual and has two pieces:
- An
observewrapper withkind=SpanKind.AGENTturns a function into an AGENT span. Thenameyou give it is the name the span shows in the trace. - A manual generation around each
query()loop is filled from the run’sResultMessagewith token usage, the real model id and the answer. Rius computes cost from that on the server.
Spans nest through parent context, the standard OpenTelemetry way. If your orchestrator is an observe function that calls other observe functions, the called agents become child spans of the orchestrator’s root span, all in one trace. The root span’s duration and status are the run’s. Spans carry a kind such as AGENT or LLM, which is how the console classifies them. Concepts explains traces, spans and generations in full.
A run with two agents looks like this:
handle-task AGENT
├─ researcher AGENT
│ └─ researcher_turn LLM claude-haiku-4-5, tokens + cost
└─ writer AGENT
└─ writer_turn LLM claude-haiku-4-5, tokens + costSet it up
- Install the SDK and the Claude Agent SDK, and make sure the
claudeCLI is installed and authenticated. The Claude Agent SDK integration page covers this setup in full.
pip install glassflow-rius claude-agent-sdk- Put the generation code in one helper, then give each agent its own
observefunction with its own name. The orchestrator is the root span and calls the agents in order. Each generation takes the prompt as itsinput, so the handoff is visible in the trace.
import asyncio
import rius
from claude_agent_sdk import (
AssistantMessage, ClaudeAgentOptions, ResultMessage, TextBlock, query,
)
from rius import SpanKind
rius.init(api_key="ri_...", service_name="my-agent")
MODEL = "claude-haiku-4-5"
async def run_turn(label: str, prompt: str) -> str:
options = ClaudeAgentOptions(model=MODEL, max_turns=1)
parts = []
with rius.start_as_current_generation(
label, model=MODEL, provider="anthropic", input=prompt
) as gen:
async for message in query(prompt=prompt, options=options):
if isinstance(message, AssistantMessage):
parts.extend(
b.text for b in message.content if isinstance(b, TextBlock)
)
elif isinstance(message, ResultMessage):
usage = message.usage or {}
gen.set_usage(
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
cache_read_input_tokens=usage.get("cache_read_input_tokens"),
cache_write_input_tokens=usage.get("cache_creation_input_tokens"),
)
models = list((message.model_usage or {}).keys())
if models:
gen.set_response_model(models[0])
answer = "".join(parts)
gen.set_output(answer)
return answer
@rius.observe(name="researcher", kind=SpanKind.AGENT)
async def researcher(task: str) -> str:
return await run_turn("researcher_turn", f"List the key facts about: {task}")
@rius.observe(name="writer", kind=SpanKind.AGENT)
async def writer(notes: str) -> str:
return await run_turn("writer_turn", f"Write a short summary from these notes:\n{notes}")
@rius.observe(name="handle-task", kind=SpanKind.AGENT)
async def handle_task(task: str) -> str:
notes = await researcher(task)
return await writer(notes)
asyncio.run(handle_task("why collectors drop spans"))-
Use a full model id such as
claude-haiku-4-5, not an alias such ashaiku, in both the CLI option and the generation. Cost is computed from the requested model, and an alias gives a generation with no cost. Keepprovider="anthropic"so the cache token counts are added to the input count for you. -
Scope each task with
rius.session()and use the SDK’s ownsession_idas the Rius session id if you resume conversations. Turns of one conversation then group together. -
Run one task and open the trace in the console. You should see the root span, one AGENT span per agent, and a generation under each with a model id, tokens and cost.
What you get
- One trace per run. The orchestrator’s span is the root, so its duration and status are the run’s latency and outcome. The waterfall highlights the path from the root to a failing span.
- Each agent by name. The spans are called researcher and writer, or whatever you pass to
name, so you can find an agent in the trace at a glance. - Handoffs you can read. Sibling agent spans appear in the order they ran. Because each generation carries its input and output, you can compare what one agent returned with what the next received.
- Tokens and cost per agent. Each generation reports its tokens and a list-price cost estimate in US dollars, so you can see which agent spends the most.
- Sessions. Traces from the turns of one conversation group under one session.
The Claude Code runtime’s own tool calls, sub-turns and file operations produce no spans. The generation records each turn’s totals only, so a handoff inside the runtime does not appear. Handoffs between the agent functions you wrap yourself do.
When something else fits
- If you need every step inside an agent as its own span, use a framework whose integration records native spans, such as Pydantic AI or AutoGen, which emit their own agent and tool spans. The integrations overview lists what each one captures.
- If your agents already run on a framework with an integration, use that page and skip the manual wrappers.