A Triage-and-Handoff Agent
Lesson 08 · Building Your Own Agents · ~12 min read
What makes an agent shape “triage-and-handoff”
Working definition: a triage-and-handoff agent has one front-door agent whose sole job is to classify the incoming request, and two or more specialist agents who actually handle distinct sub-domains using their own small toolsets and instructions. The front door doesn’t solve anything — it just decides who should. Contrast that shape with three things it isn’t:
- Single-agent multi-tool — Module 2’s research assistant. One agent does both classification and execution, often behind a much larger system prompt that has to cover every sub-domain at once.
- Fixed pipelines — LangGraph’s sweet spot. Every request flows through the same nodes in the same order, regardless of content.
- Crews — Module 5’s CrewAI sweet spot. Multiple agents collaborate on one task simultaneously, rather than one routing to another and then stepping out of the way.
The OpenAI Agents SDK is at its sweet spot when (a) you have two or more specialists with materially different instructions or toolsets, (b) the choice of specialist depends on the request content, and (c) the request stays with one specialist after routing — no parallel work, no second-opinion loop. The customer-support triage in this lesson ticks all three.
Why this is the OpenAI Agents SDK’s sweet spot
Restating L07 §7’s “pick the SDK when…” list and tying each item back to today’s lesson:
- “Multiple specialists sharing a conversation.” Yes — four agents in total (1 triage + 3 specialists), all routed via handoffs. The whole conversation history travels with each transfer.
-
“Structured output by default.” Yes — via
output_type=Resolutionon each specialist; the caller gets typedResolutionobjects out ofresult.final_outputinstead of a free-text blob to regex-parse. - “Pre/post guardrails as first-class plumbing.” Yes — a PII input guardrail wraps triage. A safety belt that doesn’t bloat the agent code itself.
-
“Already in the OpenAI ecosystem.” Yes — the lessons
standardise on
gpt-4o-mini(cheap, fast, more than sufficient for routing and short tool-using turns).
What would push you off the SDK for a problem like this? Two things. First, if the specialists needed to collaborate in parallel on the same ticket — a billing person and a technical person both contributing — that’s a CrewAI shape, not a handoff shape. Second, if the “routing” was actually a deterministic DAG with strict ordering constraints (always classify, then always enrich, then always summarise), that’s a LangGraph shape, not an agent decision. Neither applies here: the routing is content-driven, and each specialist owns its ticket end-to-end.
The four agents and their toolsets
Walking through each agent in agent.py:
-
triage— the front door. Instructions: classify the ticket and hand off. Tools: none. Handoffs: all three specialists. The intentional zero-tool design forces triage to use atransfer_to_*synthetic tool on its first turn — there’s literally nothing else it can do, which keeps it from drifting into half-answering the user. -
billing— specialist. Instructions: resolve billing tickets. Tools:lookup_invoice(account_id: str, month: str) -> str— returns canned invoice text.issue_refund(invoice_id: str, amount_usd: float) -> str— returns a confirmation string.
-
technical— specialist. Instructions: resolve outage and error tickets. Tools:check_service_status(service: str) -> str— returns a canned status string.open_incident(severity: str, summary: str) -> str— returns a fake incident id.
-
account— specialist. Instructions: resolve auth and account tickets. Tools:lookup_account(email: str) -> str— returns canned account info.reset_password(account_id: str) -> str— returns a confirmation string.
All six specialist tools are mocked deterministic Python — they don’t call real APIs. The lesson is about agent shape; real integrations would live in production code with retries, timeouts, structured logging, and PII redaction beyond the entry guardrail.
How handoffs actually work under the hood
The mechanism, in slightly more depth than L07’s theory page covered.
When you write triage = Agent(..., handoffs=[billing, technical, account]),
the SDK does three things at construction time:
-
Wraps each handoff target in a
handoff(target)object — unless you already wrotehandoffs=[handoff(billing, ...)]yourself, in which case the SDK respects your finer-grained config (callbacks, input filters, custom descriptions). -
At runtime, when triage’s tool list is sent to the model, the SDK appends three
synthetic tools:
transfer_to_billing,transfer_to_technical,transfer_to_account. The description of each synthetic tool is derived from the target agent’snameandhandoff_description(defaulting to its first instruction line if you didn’t set one). -
When the LLM calls one of those synthetic tools, the SDK:
- Records the call in the trace as a
HandoffCallItem. - Invokes the
on_handoffcallback if one was provided. - Applies
input_filterto trim conversation history if specified. - Swaps the active agent to the target.
- Continues the loop with the target’s instructions, tools, handoffs, and guardrails in effect.
- Records the call in the trace as a
Guardrails at the triage layer
The architectural decision to place input_guardrails=[block_pii] on the
triage agent (rather than on every specialist) is deliberate. Three reasons:
- Coverage. Every ticket enters via triage. Guarding here protects all three specialists with one declaration — you can’t accidentally forget to wire it onto a future fourth specialist.
- Cost. Tripping the guardrail short-circuits the run before the routing model sees the input — no token cost is paid for malformed or unsafe requests.
-
Auditability. The trace records the guardrail decision in one place;
ops can grep for
pii_detected=truerecords without joining across four agents’ logs.
The guardrail itself is a six-line function: a regex for SSN-shaped strings, a
substring check for “credit card”, and a return of
GuardrailFunctionOutput. Production guardrails grow more sophisticated
(named-entity recognition with a small classifier model, explicit allowlists for
compliance contexts, locale-aware identifier formats), but the plumbing
— decorator, tripwire, typed exception — stays exactly the same.
actions_taken doesn’t include "issue_refund" above a
threshold without a second approver. Place each guardrail at the agent where its check
makes domain sense.
Structured outputs across handoffs
Every specialist declares output_type=Resolution:
class Resolution(BaseModel):
ticket_summary: str
specialist: str
actions_taken: list[str]
follow_up_needed: bool
The triage agent does not have an output_type — it produces
a handoff, not a final answer. The SDK handles this gracefully: only the agent that
produces the actual final output uses its output_type.
result.final_output will be a Resolution instance regardless
of which specialist answered, so the caller never has to branch on agent identity to
parse the response.
Why a shared output type instead of one per specialist? Because the consumer (your
ticketing UI, your CRM webhook, your QA dashboard) wants a uniform shape. The
specialist field tells you which agent answered; the rest is the same per
ticket. If billing genuinely needed extra fields (e.g.,
refund_amount_usd), you’d either add an optional field to
Resolution or switch to a
Union[BillingResolution, TechnicalResolution, AccountResolution] —
both work in 2026 Q2’s openai-agents.
What the lab gives you
By the end of the lab you’ll have run
python agent.py "My invoice for March is double what I expected..."
against the mocked tools and seen a multi-agent trace: triage hands off to billing,
billing calls lookup_invoice and then issue_refund, and
returns a typed Resolution at the end. You’ll have triggered the PII
guardrail deliberately by including a fake SSN. You’ll have observed
result.last_agent.name change based on the ticket content — billing
for the invoice ticket, technical for an outage, account for a password reset.
Most importantly: you’ll have one self-contained agent.py (~120
lines) committed at agents/lesson08-handoff-agent/agent.py in the course
repo. This is the same file Plan F (Lesson 11) imports into the cross-framework
scorecard. When you later see how the Claude SDK, LangGraph, and CrewAI handle
the same routing problem in Module 6, you’ll know exactly what each one is
rewriting — line by line.
References
- OpenAI Agents SDK docs — Handoffs page
(
openai.github.io/openai-agents-python/handoffs/). - OpenAI Agents SDK docs — Guardrails page
(
openai.github.io/openai-agents-python/guardrails/). - L04 —
agents/lesson04-tool-heavy-agent/theory.html, the Claude SDK tool-heavy shape, for contrast. - L06 —
agents/lesson06-graph-shaped-agent/theory.html, the LangGraph DAG shape, for contrast.