Lesson 08 A Triage-and-Handoff Agent
Theme:

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:

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:

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:

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:

  1. Wraps each handoff target in a handoff(target) object — unless you already wrote handoffs=[handoff(billing, ...)] yourself, in which case the SDK respects your finer-grained config (callbacks, input filters, custom descriptions).
  2. 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’s name and handoff_description (defaulting to its first instruction line if you didn’t set one).
  3. When the LLM calls one of those synthetic tools, the SDK:
    • Records the call in the trace as a HandoffCallItem.
    • Invokes the on_handoff callback if one was provided.
    • Applies input_filter to 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.
Critical detail: the conversation history is preserved across the handoff. The billing agent sees the original user ticket — the triage agent didn’t summarise it; it just decided who should handle it. This is what makes “shared conversation” the right mental model, and it’s why your specialist instructions can stay short and focused on doing rather than re-reading.

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:

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.

Don’t double-up by placing the same input guardrail on every specialist. The triage layer already filtered. The exception is specialist-specific guardrails — e.g., billing might want an output guardrail that confirms 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