Lesson 08 A Triage-and-Handoff Agent
Theme:

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

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:

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

Troubleshooting

Common stumbles