Lab 08 — A triage-and-handoff support agent with the OpenAI Agents SDK
Lesson 08 · Building Your Own Agents · ~45 min hands-on
Goal: at the end of this lab you will have (a) run agent.py against three
different tickets and seen the triage agent route to all three specialists, (b) triggered
the PII input guardrail deliberately, (c) inspected the typed Resolution
output, and (d) added a fourth specialist to feel the extension shape. This is the OpenAI
Agents SDK’s “sweet spot” — one triage front door plus N specialist
sub-agents linked by handoffs.
Prereqs
-
The
agents/requirements.txtalready installed (which pinsopenai-agents>=0.0.10,<0.1). If not, runpip install -r agents/requirements.txtfrom the repo root. OPENAI_API_KEYexported in your shell.-
L07 lab completed — you should be comfortable with
Agent,Runner,handoffs=, and@input_guardrail. - Working directory at the repo root (the paths below assume that).
Step 1 — Read agents/lesson08-handoff-agent/agent.py
The full program lives in the repo at
agents/lesson08-handoff-agent/agent.py. Below it is reproduced inline so
you can read along without flipping windows. We’ll then walk through each block.
Open the file in your editor — you don’t need to retype anything; the file is already there. The inlined copy in this lab guide is for reading convenience.
Step 2 — The full program
Here is the entire agent.py, verbatim:
"""Triage-and-handoff customer-support agent — Lesson 08 of the Agents course.
Built around the OpenAI Agents SDK primitives shipped in the `openai-agents`
PyPI package (>=0.0.10,<0.1).
One triage agent classifies an incoming ticket and hands off to one of three
specialist agents:
- billing (lookup_invoice, issue_refund)
- technical (check_service_status, open_incident)
- account (lookup_account, reset_password)
All specialist tools are mocked. The triage agent has zero tools — its only
options are the three handoffs. A PII input guardrail wraps triage so every
request flows through one shared safety belt.
Run:
export OPENAI_API_KEY=...
python agent.py "My invoice for March is double what I expected."
NOTE: this file is also inlined in lab_guide.html. If you edit it, also
update the inlined block there.
"""
from __future__ import annotations
import re
import sys
from typing import Any
from pydantic import BaseModel
from agents import (
Agent,
GuardrailFunctionOutput,
Runner,
function_tool,
input_guardrail,
)
# --- Output type --------------------------------------------------------
class Resolution(BaseModel):
"""Shared final-answer shape across all specialists."""
ticket_summary: str
specialist: str # 'billing' | 'technical' | 'account'
actions_taken: list[str]
follow_up_needed: bool
# --- Billing tools ------------------------------------------------------
@function_tool
def lookup_invoice(account_id: str, month: str) -> str:
"""Look up an invoice by account id and month (e.g., '2026-03')."""
return (
f"invoice INV-{month}-{account_id[:4]}: amount $240.00, "
f"auto-renewed (expected $120.00)"
)
@function_tool
def issue_refund(invoice_id: str, amount_usd: float) -> str:
"""Issue a refund against an invoice. Returns a confirmation id."""
return f"refund issued: REF-{invoice_id[-6:]} for ${amount_usd:.2f}"
# --- Technical tools ----------------------------------------------------
@function_tool
def check_service_status(service: str) -> str:
"""Return the current status of a named service."""
return f"{service}: degraded (regional outage in us-east-1 since 14:02 UTC)"
@function_tool
def open_incident(severity: str, summary: str) -> str:
"""Open an incident ticket. severity in {'low','med','high','critical'}."""
return f"incident opened: INC-9421 (severity={severity}) - {summary[:40]}"
# --- Account tools ------------------------------------------------------
@function_tool
def lookup_account(email: str) -> str:
"""Look up an account by email."""
return f"account {email}: status=active, plan=Pro, last_login=2026-05-20"
@function_tool
def reset_password(account_id: str) -> str:
"""Send a password-reset email to the account."""
return f"reset email sent for account {account_id}"
# --- PII input guardrail ------------------------------------------------
SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
CC_RE = re.compile(r"\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b")
@input_guardrail
async def block_pii(ctx: Any, agent: Any, user_input: str) -> GuardrailFunctionOutput:
"""Block obvious SSN / credit-card patterns before the model sees them."""
hit_ssn = bool(SSN_RE.search(user_input))
hit_cc = bool(CC_RE.search(user_input))
return GuardrailFunctionOutput(
output_info={"ssn": hit_ssn, "cc": hit_cc},
tripwire_triggered=hit_ssn or hit_cc,
)
# --- Specialist agents --------------------------------------------------
BILLING_INSTRUCTIONS = (
"You are a Billing specialist. Resolve billing/refund/invoice tickets. "
"Use lookup_invoice to read the invoice; use issue_refund only after "
"confirming the discrepancy. Always fill the Resolution.actions_taken "
"list with what you did. Set follow_up_needed=true only if more info "
"is required from the customer."
)
TECHNICAL_INSTRUCTIONS = (
"You are a Technical specialist. Resolve outage / error tickets. "
"Use check_service_status to assess the impact. Open an incident "
"(open_incident) for any severity above 'low'. Always fill "
"Resolution.actions_taken."
)
ACCOUNT_INSTRUCTIONS = (
"You are an Account specialist. Resolve login/password/account tickets. "
"Use lookup_account first; reset_password only if the customer explicitly "
"asked or the account is in a clear lockout state. Fill "
"Resolution.actions_taken."
)
billing = Agent(
name="Billing",
instructions=BILLING_INSTRUCTIONS,
model="gpt-4o-mini",
tools=[lookup_invoice, issue_refund],
output_type=Resolution,
)
technical = Agent(
name="Technical",
instructions=TECHNICAL_INSTRUCTIONS,
model="gpt-4o-mini",
tools=[check_service_status, open_incident],
output_type=Resolution,
)
account = Agent(
name="Account",
instructions=ACCOUNT_INSTRUCTIONS,
model="gpt-4o-mini",
tools=[lookup_account, reset_password],
output_type=Resolution,
)
# --- Triage agent -------------------------------------------------------
TRIAGE_INSTRUCTIONS = (
"You are the front-line triage agent for customer support. Read the "
"ticket and decide which specialist should handle it:\n"
" - billing / refunds / invoices -> hand off to Billing.\n"
" - outage / errors / 'not working' -> hand off to Technical.\n"
" - login / password / account access -> hand off to Account.\n"
"Do NOT try to resolve the ticket yourself. Hand off on the first turn."
)
triage = Agent(
name="Triage",
instructions=TRIAGE_INSTRUCTIONS,
model="gpt-4o-mini",
handoffs=[billing, technical, account],
input_guardrails=[block_pii],
)
# --- Entry point --------------------------------------------------------
def run_support_agent(ticket: str) -> dict[str, Any]:
"""Run the triage agent against a ticket and return the structured result."""
result = Runner.run_sync(triage, ticket)
out: dict[str, Any] = {
"final_output": result.final_output,
"last_agent": result.last_agent.name,
}
if isinstance(result.final_output, Resolution):
out["resolution"] = result.final_output.model_dump()
return out
# --- CLI ----------------------------------------------------------------
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: python agent.py 'your support ticket text'")
out = run_support_agent(sys.argv[1])
print(f"[last agent: {out['last_agent']}]")
print("\n=== final output ===")
print(out["final_output"])
if "resolution" in out:
print("\n=== structured resolution ===")
for k, v in out["resolution"].items():
print(f"{k}: {v}")
Authoring tip
If you tweak agent.py later, remember to update this inlined block too —
the script’s top-of-file docstring has a comment reminding you. The
>, <, and & characters above are
HTML-escaped; the SDK type hints use -> arrows, which render fine after
escaping.
Step 3 — Run it on a billing ticket
From the repo root:
python agents/lesson08-handoff-agent/agent.py \
"My invoice for March is double what I expected and the auto-renewal won't turn off."
Expected: [last agent: Billing], then a Resolution with
specialist="billing", an actions_taken list along the lines of
["looked up invoice INV-2026-03-...", "issued refund of $120.00"], and
follow_up_needed=false. The lookup_invoice and
issue_refund mocks return canned strings, so the agent has
deterministic-ish material to work with — the exact wording varies by run, but the
routing is stable.
✓ Self-check: last agent is Billing and the structured resolution names billing. No traceback.
Step 4 — Run it on a technical ticket
python agents/lesson08-handoff-agent/agent.py \
"Our checkout has been returning 500 errors for the last 20 minutes."
Expected: [last agent: Technical], specialist="technical",
actions_taken mentions check_service_status plus
open_incident (severity above low). Triage made one choice; the
Technical specialist picked the right two tools to use.
Step 5 — Run it on an account ticket
python agents/lesson08-handoff-agent/agent.py \
"I can't log in. I tried my password three times and now I'm locked out."
Expected: [last agent: Account], specialist="account",
actions_taken includes lookup_account and
reset_password. Three tickets, three different specialists picked, all from
the same triage front door.
✓ Self-check: across Steps 3–5 you saw three distinct last_agent values. The triage agent never tried to answer the ticket itself.
Step 6 — Walk through the file
A guided tour, top to bottom:
-
ResolutionPydantic model — the shared output shape across all three specialists. Triage does not have anoutput_type=because triage never produces a final output; its only job is to pick a specialist and hand off. -
The six
@function_toolfunctions — each is a normal Python function with a docstring. The SDK reads the type hints and docstring to build the JSON schema the LLM sees. All six return canned strings so the lab stays reproducible (no network, no clock dependency). -
block_piiinput guardrail — two regexes (SSN_REfor SSN-shaped strings andCC_REfor credit-card-shaped digit groups). The tripwire fires on any hit. The function is decorated with@input_guardrail, and it’s wired on triage — not on the specialists — so every ticket flows through one shared safety belt before any model call. -
The three specialist agents (
billing,technical,account) — each constructed withname=,instructions=,model="gpt-4o-mini",tools=[…], andoutput_type=Resolution. Notice none of them hashandoffs=[…]— they own their ticket end-to-end. (Homework Task 2 asks you to add a back-handoff to triage.) -
triage— instructions tell it to only route;toolsis empty;handoffs=[billing, technical, account]produces three synthetictransfer_to_*tools at runtime; andinput_guardrails=[block_pii]wraps the entry point. -
run_support_agent— the entry point. CallsRunner.run_sync(triage, ticket)and returns{final_output, last_agent, resolution}. Plan F (Lesson 11) imports this function as the OpenAI SDK row in the cross-framework scorecard. -
__main__block — CLI wrapper, prints everything.
Step 7 — Trigger the PII guardrail
python agents/lesson08-handoff-agent/agent.py \
"My SSN is 123-45-6789 and I need help with my account."
Expected: agents.exceptions.InputGuardrailTripwireTriggered is raised before
any model call. The exception’s output_info says
{"ssn": True, "cc": False}.
✓ Self-check: the agent didn’t even reach the triage LLM — the guardrail short-circuited the run and saved that token cost. In production you’d catch this exception and reply with a “please redact sensitive details and resubmit” message.
Step 8 — Inspect handoffs in the trace
Temporarily add this to run_support_agent after the
Runner.run_sync(...) call to print the trace item types:
for item in Runner.run_sync(triage, ticket).new_items:
print(type(item).__name__)
Re-run with the billing ticket. Expected types include HandoffCallItem,
HandoffOutputItem, ToolCallItem,
ToolCallOutputItem, and MessageOutputItem. The
HandoffOutputItem’s target_agent.name confirms which
specialist was selected.
Revert the inspection code before moving on.
Step 9 — Add a fourth specialist
Now you’ll feel the extension shape. Add a shipping specialist for
delivery-tracking tickets. Drop this alongside the other @function_tool
functions and agent definitions:
@function_tool
def track_shipment(tracking_id: str) -> str:
"""Look up the status of a shipment by tracking id."""
return f"shipment {tracking_id}: in transit, ETA tomorrow"
shipping = Agent(
name="Shipping",
instructions=(
"You are a Shipping specialist. Resolve delivery / tracking tickets. "
"Use track_shipment to look up the status. Fill Resolution.actions_taken."
),
model="gpt-4o-mini",
tools=[track_shipment],
output_type=Resolution,
)
Add shipping to triage’s handoffs=[…] list and add
one line to TRIAGE_INSTRUCTIONS:
" - shipping / delivery / tracking -> hand off to Shipping.\n"
Run:
python agents/lesson08-handoff-agent/agent.py \
"Where is my order? Tracking number is 1Z999AA10123456784."
Expected: [last agent: Shipping], specialist="shipping". Notice
you added three things (one tool, one agent, one entry in handoffs= plus one
instruction line) to support a whole new vertical — that’s the shape’s
payoff. Compare that with the L04 tool-heavy agent, where a new vertical would mean
cramming more tools into the single agent’s tool list and lengthening the
system prompt.
What you should leave with
agent.pyrunning end-to-end against all four ticket types (billing, technical, account, and shipping after Step 9).- First-hand observation of the PII input guardrail short-circuiting the run before any token spend.
- A typed
Resolutionobject you can hand to any downstream code without parsing strings. - The instinct that the OpenAI Agents SDK’s “one triage front door + N specialists” division of labour fits any routed-by-content workflow naturally.
Troubleshooting
Common stumbles
- Triage answers the ticket itself instead of handing off. The instructions aren’t strong enough. Add “Do NOT try to resolve the ticket yourself. Hand off on the first turn.” (which is already in the file — if you removed it experimenting, put it back).
-
last_agent.name == "Triage"— same root cause. The model produced a text answer from triage without calling a handoff tool. Either tighten the instructions or (for stronger enforcement) remove triage’s ability to produce text by giving it anoutput_type=with a handoff-required schema. -
InputGuardrailTripwireTriggeredon a normal ticket — your regex matched something unintended. Printoutput_infoto see which check fired, then refine the regex. -
final_outputisn’t aResolutioninstance — the specialist agent that answered doesn’t haveoutput_type=Resolutionset. Check the agent constructor. -
gpt-4o-minirefuses to call thetransfer_to_*tools — confirm the SDK actually injected them by addingprint([t.name for t in triage.tools])after construction. You should see exactly threetransfer_to_*entries (or four after Step 9).