Multi-agent

Hand off between agents, route through a supervisor, or pipeline them in sequence or parallel — and get the whole trajectory back as one governed, correlated tree: one parent run id, one audit chain, per-agent budgets and spend attribution.

Quickstart — handoff

An agent transfers control to a named peer via a synthetic transfer_to_<peer> tool. The conversation is canonical (provider-agnostic), so handoff works across providers — an OpenAI planner hands off to an Anthropic writer with no rewrite:

from cendor.sdk import Agent, run

writer = Agent(name="writer", model="claude-opus-4-8", instructions="Write the brief.")
planner = Agent(
    name="planner", model="gpt-4o",
    instructions="Plan, then hand off to the writer.",
    handoffs=["writer"],          # or handoffs=[handoff("writer")]
)

# A list means a handoff team: the first agent is the entry point, the rest are reachable peers.
result = run([planner, writer], "Research X and write a brief")
print(result.output)              # the writer's final answer
print(result.agents)              # ["planner", "writer"]
import { Agent, run } from '@cendor/sdk';

const writer = new Agent({ name: 'writer', model: 'claude-opus-4-8',
                           instructions: 'Write the brief.' });
const planner = new Agent({
  name: 'planner', model: 'gpt-4o',
  instructions: 'Plan, then hand off to the writer.',
  handoffs: ['writer'],           // or handoffs: [handoff('writer')]
});

// An array means a handoff team: the first agent is the entry point, the rest are reachable peers.
const result = await run([planner, writer], 'Research X and write a brief');
console.log(result.output);       // the writer's final answer
console.log(result.agents);       // ["planner", "writer"]

Handoff runs stream too — run.stream([...]) / run.astream([...]) yield events through to the terminal RunComplete, same as single-agent streaming.

Core concepts

One run, one tree

Every multi-agent run gets one parent run_id. Each agent segment runs under a nested child trace id ({run_id}:{agent}#i), so:

ConceptWhere it lives
Parent run idresult.trace_id
Per-agent child idstep.trace_id (starts with the parent)
Which agent produced a stepstep.agent
Audit correlationone decision() per agent segment on the shared AuditLog
Spend attributiontrack(agent=…) per segment → report(group_by=["agent"])

Supervisor / router

A coordinator agent routes to sub-agents by handoff:

from cendor.sdk import Agent, supervisor, AuditLog

coordinator = Agent(name="coordinator", model="gpt-4o", instructions="Route to a specialist.")
researcher  = Agent(name="researcher",  model="gpt-4o", instructions="Do the research.")
writer      = Agent(name="writer",      model="claude-opus-4-8", instructions="Write it up.")

log = AuditLog(system="research-team", risk_tier="high", path="team.jsonl")
result = supervisor(coordinator, [researcher, writer], "Investigate X and write it up", audit=log)
# One correlated audit trail: a decision per agent segment, every llm_call/tool_call chained.
import { Agent, supervisor, AuditLog } from '@cendor/sdk';

const coordinator = new Agent({ name: 'coordinator', model: 'gpt-4o',
                                instructions: 'Route to a specialist.' });
const researcher  = new Agent({ name: 'researcher', model: 'gpt-4o',
                                instructions: 'Do the research.' });
const writer      = new Agent({ name: 'writer', model: 'claude-opus-4-8',
                                instructions: 'Write it up.' });

const audit = new AuditLog('research-team', { riskTier: 'high', path: 'team.jsonl' });
const result = await supervisor(coordinator, [researcher, writer],
  'Investigate X and write it up', { audit });
// One correlated audit trail: a decision per agent segment, every llm_call/tool_call chained.

Sequential & parallel pipelines

from cendor.sdk import sequential, parallel, parallel_async

# Pipe each agent's output into the next.
result = sequential([drafter, editor, factchecker], "Write about X")
print(result.output)                     # the last agent's output

# Fan out over the same input; result.output is {agent_name: output}.
result = parallel([summarizer_a, summarizer_b], "Summarize this document")

# Real concurrency (async):
result = await parallel_async([a, b, c], "Same task, three takes")
import { sequential, parallel, parallelAsync } from '@cendor/sdk';

// Pipe each agent's output into the next.
let result = await sequential([drafter, editor, factchecker], 'Write about X');
console.log(result.output);              // the last agent's output

// Fan out over the same input; result.output is { [agentName]: output }.
result = await parallel([summarizerA, summarizerB], 'Summarize this document');

// Real concurrency:
result = await parallelAsync([a, b, c], 'Same task, three takes');

Per-agent budgets & attribution

from cendor.sdk import Agent, run, report

# Cap a single agent's spend; the orchestrator enforces it around that agent's segment.
expensive = Agent(name="deep", model="claude-opus-4-8", instructions="Think hard.", max_usd=0.50)
cheap     = Agent(name="fast", model="gpt-4o-mini",     instructions="Be quick.")

run([cheap, expensive], "...")

# Spend is auto-attributed by agent (track(agent=...)):
report(group_by=["agent"]).assert_under(usd=1.00, agent="deep")
import { Agent, run, report } from '@cendor/sdk';

// Cap a single agent's spend; the orchestrator enforces it around that agent's segment.
const expensive = new Agent({ name: 'deep', model: 'claude-opus-4-8',
                              instructions: 'Think hard.', maxUsd: 0.50 });
const cheap     = new Agent({ name: 'fast', model: 'gpt-4o-mini', instructions: 'Be quick.' });

await run([cheap, expensive], '...');

// Spend is auto-attributed by agent (track({ agent: ... })):
report(['agent']).assertUnder(1.00, { agent: 'deep' });

A team-wide cap is just an ordinary budget(...) around the whole run([...]). Long team runs can also checkpoint and resume — see Production hardening.

How it works

%%{init: {"flowchart": {"htmlLabels": false}} }%%
graph TD
    RUN["run([planner, writer], input)<br/>parent run_id"]
    P["planner segment<br/>trace: run_id:planner#0"]
    H["transfer_to_writer<br/>(a synthetic tool call)"]
    W["writer segment<br/>trace: run_id:writer#1"]
    OUT["Result<br/>output · steps · agents · one trace tree"]
    GOV["shared AuditLog + budgets<br/>a decision() per segment · track(agent=...)"]

    RUN --> P --> H --> W --> OUT
    P -.-> GOV
    W -.-> GOV

    classDef seam fill:#2563EB,color:#ffffff,stroke:#1E40AF;
    classDef gov fill:#F43F5E,color:#ffffff,stroke:#E11D48;
    class RUN seam;
    class GOV gov;

Because every segment’s trace_id starts with the parent id, result.steps is one ordered tree — and span_tree(result) exports the same structure as OpenTelemetry gen_ai.* spans.

Plugs into the stack

Multi-turn team memory works exactly like single-agent memory — pass a Session (Memory & sessions). Governance wrappers apply at whichever granularity you choose: around the team (budget/guard), or per agent (max_usd, per-segment audit decisions).

Honest limits

  • Orchestration is explicit. Handoffs go to named peers; there’s no emergent agent discovery — by design, so the audit tree is always closed over a known set.
  • parallel fans out over the same input — it isn’t a task queue or a scheduler; real distributed execution is your infrastructure’s job.
  • A handoff carries the whole canonical conversation. Long trajectories can get expensive per segment — bound them with context_budget or per-agent budgets.