The OpenAI Agents SDK gives you Agent and Runner primitives plus handoffs between specialist agents and guardrails that can halt a run before it answers. This guide builds a billing-support triage agent that hands off to a Billing agent or a Technical agent, blocks off-topic requests on the way in, and blocks any promised refund over $500 on the way out. Stack: Python, openai-agents. Budget 45-60 minutes.
| Fact | Value |
|---|---|
| Tool | OpenAI Agents SDK |
| Difficulty | Intermediate |
| Time required | 45-60 min |
| Compatibility | Python 3.10+ (or Node.js 20+ for TypeScript), openai-agents, OpenAI API key |
| Last updated | November 2026 |
What you'll build
You'll build a triage agent for a SaaS company's support inbox. It reads an incoming message, hands off to a Billing agent for invoice and payment questions or a Technical agent for product issues, and never lets a reply promising a refund over $500 go out without a human checking it first.
Handoffs in the OpenAI Agents SDK are tools under the hood: each specialist agent you list in handoffs=[...] becomes a transfer_to_X tool the triage agent can call, and the SDK does the actual context switch once the model picks one. Guardrails sit around that whole run: an input_guardrail decorated with @input_guardrail can inspect the incoming message and raise a tripwire before any agent touches it, and an output_guardrail can inspect the final answer and raise one after everything else has run but before you'd act on the result.
Choose the OpenAI Agents SDK when you want first-class handoffs and guardrails as SDK primitives rather than something you build yourself, and you're fine building on OpenAI's models. If you want the same approval behavior but prefer Claude's own harness and MCP-first tool ecosystem, the Claude Agent SDK guide builds a comparable support agent.
Prerequisites
- Python 3.10+ and a terminal
- An OpenAI API key (OPENAI_API_KEY) with available credits
- pip install openai-agents
- Comfort with Python decorators and async/await
Step-by-step guide
Install the SDK and confirm it runs
Install openai-agents and export your API key. Run a one-line sanity check before building anything else: a bare Agent with no tools, run through Runner.run_sync, should return a final_output string. This confirms your environment and key work before you add handoffs and guardrails on top.
1pip install openai-agents2export OPENAI_API_KEY="sk-..."3python -c "from agents import Agent, Runner; a=Agent(name='t'); print(Runner.run_sync(a,'say hi').final_output)"Expected result: A short greeting printed to the terminal with no errors.
Define the specialist agents and their tools
billing_agent handles invoices and refunds; support_agent handles product/technical issues. Give each a function_tool it actually needs: look_up_invoice for billing, check_known_issue for support. Set handoff_description on each so the triage agent (built next) has a clear signal for when to pick it.
1from agents import Agent, function_tool23@function_tool4def look_up_invoice(customer_id: str) -> dict:5 """Look up a customer's most recent invoice and payment status."""6 return {"customer_id": customer_id, "amount": 49.00, "status": "paid", "invoice_id": "INV-2291"}78@function_tool9def check_known_issue(product_area: str) -> str:10 """Check whether product_area has a known, already-tracked issue."""11 return "No known issue for this area; treat as a new report."1213billing_agent = Agent(14 name="Billing agent",15 handoff_description="Handles invoices, payments, and refund requests.",16 instructions="Help with billing questions. Look up the invoice before discussing any amount.",17 tools=[look_up_invoice],18)19support_agent = Agent(20 name="Technical agent",21 handoff_description="Handles product bugs and how-to questions.",22 instructions="Help with technical issues. Check for a known issue before answering.",23 tools=[check_known_issue],24)Expected result: Two Agent objects, each runnable on its own, each with a handoff_description a triage agent can read.
Build the triage agent with handoffs
triage_agent lists both specialists in handoffs=[...]. The SDK turns each into a transfer_to_billing_agent / transfer_to_technical_agent tool; the model calls one based on its own instructions plus each agent's handoff_description. No routing logic lives in your code at this layer.
1triage_agent = Agent(2 name="Triage agent",3 instructions=(4 "You are the first line of support for a SaaS product. Read the customer's "5 "message and hand off to Billing agent for payment/invoice questions or "6 "Technical agent for product issues. Don't try to answer either kind yourself."7 ),8 handoffs=[billing_agent, support_agent],9)Expected result: Sending triage_agent a billing question results in a run that ends inside billing_agent's response.
Add an input guardrail that rejects off-topic requests
An input_guardrail runs before the triage agent's turn starts. This one is a simple keyword/heuristic check: if the message mentions a competitor product or is clearly unrelated to your SaaS, set tripwire_triggered=True. When the tripwire fires, Runner.run raises InputGuardrailTripwireTriggered instead of letting any agent spend tokens on the request.
1from agents import input_guardrail, GuardrailFunctionOutput, RunContextWrapper23OFF_TOPIC_MARKERS = ("competitor", "unrelated to our product")45@input_guardrail6async def reject_off_topic(ctx: RunContextWrapper[None], agent: Agent, input_text) -> GuardrailFunctionOutput:7 text = input_text if isinstance(input_text, str) else str(input_text)8 is_off_topic = any(marker in text.lower() for marker in OFF_TOPIC_MARKERS)9 return GuardrailFunctionOutput(output_info={"off_topic": is_off_topic}, tripwire_triggered=is_off_topic)1011triage_agent.input_guardrails = [reject_off_topic]Expected result: A message containing an off-topic marker raises InputGuardrailTripwireTriggered instead of reaching triage_agent.
Add an output guardrail that catches a policy violation
An output_guardrail runs after an agent produces its final output, before you act on it. This one regex-scans the text for a dollar amount after the word 'refund' and flags anything over $500; your code catches the resulting OutputGuardrailTripwireTriggered and routes that reply to a human review queue instead of sending it, rather than silently blocking it with no record.
1import re2from agents import output_guardrail34@output_guardrail5async def block_large_refund_promise(ctx: RunContextWrapper[None], agent: Agent, output) -> GuardrailFunctionOutput:6 text = output if isinstance(output, str) else getattr(output, "final_output", str(output))7 match = re.search(r"refund.{0,20}\$(\d+(?:\.\d{2})?)", text, re.IGNORECASE)8 too_large = bool(match) and float(match.group(1)) > 5009 return GuardrailFunctionOutput(output_info={"amount": match.group(1) if match else None}, tripwire_triggered=too_large)1011billing_agent.output_guardrails = [block_large_refund_promise]Expected result: A billing_agent reply promising a $900 refund raises OutputGuardrailTripwireTriggered; one promising $40 does not.
Run the full triage flow and handle both tripwires
Wrap Runner.run in a try/except that catches InputGuardrailTripwireTriggered (respond with a redirect to the public help center) and OutputGuardrailTripwireTriggered (route to a human review queue instead of replying). This is the only place either exception should surface; everywhere else in your code can assume a clean result.
1import asyncio2from agents import Runner, InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered34async def handle_message(text: str) -> str:5 try:6 result = await Runner.run(triage_agent, text)7 return result.final_output8 except InputGuardrailTripwireTriggered:9 return "This looks outside what we can help with here; try our public help center."10 except OutputGuardrailTripwireTriggered:11 return "[Routed to human review: proposed refund exceeds auto-approval limit]"1213if __name__ == "__main__":14 print(asyncio.run(handle_message("My invoice looks wrong, can I get a refund?")))Expected result: A billing question about a small refund gets a direct answer; one implying a large refund gets routed to the review message instead.
Complete working example
1"""Billing/support triage agent: handoffs between specialists, guarded on input and output.2Run: python billing_triage_agent.py (pip install openai-agents; set OPENAI_API_KEY)3"""4import asyncio5import re67from agents import (8 Agent,9 GuardrailFunctionOutput,10 InputGuardrailTripwireTriggered,11 OutputGuardrailTripwireTriggered,12 RunContextWrapper,13 Runner,14 function_tool,15 input_guardrail,16 output_guardrail,17)1819OFF_TOPIC_MARKERS = ("competitor", "unrelated to our product")202122@function_tool23def look_up_invoice(customer_id: str) -> dict:24 """Look up a customer's most recent invoice and payment status."""25 return {"customer_id": customer_id, "amount": 49.00, "status": "paid", "invoice_id": "INV-2291"}262728@function_tool29def check_known_issue(product_area: str) -> str:30 """Check whether product_area has a known, already-tracked issue."""31 return "No known issue for this area; treat as a new report."323334billing_agent = Agent(35 name="Billing agent",36 handoff_description="Handles invoices, payments, and refund requests.",37 instructions="Help with billing questions. Look up the invoice before discussing any amount.",38 tools=[look_up_invoice],39)4041support_agent = Agent(42 name="Technical agent",43 handoff_description="Handles product bugs and how-to questions.",44 instructions="Help with technical issues. Check for a known issue before answering.",45 tools=[check_known_issue],46)474849@input_guardrail50async def reject_off_topic(ctx: RunContextWrapper[None], agent: Agent, input_text) -> GuardrailFunctionOutput:51 text = input_text if isinstance(input_text, str) else str(input_text)52 is_off_topic = any(marker in text.lower() for marker in OFF_TOPIC_MARKERS)53 return GuardrailFunctionOutput(output_info={"off_topic": is_off_topic}, tripwire_triggered=is_off_topic)545556@output_guardrail57async def block_large_refund_promise(ctx: RunContextWrapper[None], agent: Agent, output) -> GuardrailFunctionOutput:58 text = output if isinstance(output, str) else getattr(output, "final_output", str(output))59 match = re.search(r"refund.{0,20}\$(\d+(?:\.\d{2})?)", text, re.IGNORECASE)60 too_large = bool(match) and float(match.group(1)) > 50061 return GuardrailFunctionOutput(output_info={"amount": match.group(1) if match else None}, tripwire_triggered=too_large)626364billing_agent.output_guardrails = [block_large_refund_promise]6566triage_agent = Agent(67 name="Triage agent",68 instructions=(69 "You are the first line of support for a SaaS product. Read the customer's "70 "message and hand off to Billing agent for payment/invoice questions or "71 "Technical agent for product issues. Don't try to answer either kind yourself."72 ),73 handoffs=[billing_agent, support_agent],74 input_guardrails=[reject_off_topic],75)767778async def handle_message(text: str) -> str:79 try:80 result = await Runner.run(triage_agent, text)81 return result.final_output82 except InputGuardrailTripwireTriggered:83 return "This looks outside what we can help with here; try our public help center."84 except OutputGuardrailTripwireTriggered:85 return "[Routed to human review: proposed refund exceeds auto-approval limit]"868788if __name__ == "__main__":89 print(asyncio.run(handle_message("My invoice looks wrong, can I get a refund?")))90Common mistakes
Why it's a problem: Writing one giant agent with instructions for billing, technical, and everything else
How to avoid: Split by domain and connect with handoffs. Each specialist's instructions stay short and the triage agent only has to decide who, not how.
Why it's a problem: Putting the output guardrail on the triage agent instead of the agent that actually produces the risky output
How to avoid: output_guardrails run on the agent object they're attached to. Attach refund-amount checks to billing_agent, where refund language actually appears.
Why it's a problem: Letting InputGuardrailTripwireTriggered or OutputGuardrailTripwireTriggered propagate uncaught
How to avoid: Both are real exceptions. Catch them at the boundary where you turn a run into a user-facing reply, and respond with something specific to each case.
Why it's a problem: Making an input guardrail call a full LLM agent for every single message
How to avoid: That's expensive and slow at volume. Use a heuristic or the smallest available model for the guardrail check itself; save the capable model for the agents doing real work.
Why it's a problem: Assuming a handoff carries full conversation memory to the new agent automatically
How to avoid: Handoffs pass the conversation history, but any side data (customer ID, account tier) you want the next agent to have without re-deriving it should go through an on_handoff callback.
Best practices
- Give every specialist agent a specific, distinguishing handoff_description; vague ones make the triage model guess.
- Keep input guardrails cheap and fast since they run on every message, not just risky ones.
- Route a tripwire to a human queue with context, not just a generic refusal, so nothing silently disappears.
- Scope each specialist's tools to what it actually needs; billing_agent has no business calling a technical-issue tool.
- Log guardrail output_info even when tripwire_triggered is False, so you can tune thresholds later from real data.
- Use the SDK's built-in tracing to see which agent handled a given run and which guardrails fired, especially while tuning thresholds.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have an OpenAI Agents SDK triage agent with billing_agent and support_agent connected via handoffs, an input_guardrail for off-topic messages, and an output_guardrail blocking refunds over $500 (code below). Add a third specialist, sales_agent, and an on_handoff callback that passes the customer's plan tier into whichever agent it hands off to. [paste complete_code]
In Claude Code, open billing_triage_agent.py and ask: 'Add a second output_guardrail on support_agent that flags any reply promising a specific ship date, and route tripwires from both guardrails to the same review queue function.'
Frequently asked questions
How is a handoff different from just calling another agent as a tool?
A handoff fully transfers the conversation to the new agent, which then owns the rest of that turn and replies directly to the user. Calling an agent as a tool instead keeps the first agent in charge; it gets the second agent's output back as a tool result and decides what to do with it.
What happens if a guardrail's own code raises an unrelated exception?
That propagates as a normal Python exception, not as InputGuardrailTripwireTriggered or OutputGuardrailTripwireTriggered. Wrap guardrail logic that can fail (network calls, parsing) in its own try/except and resolve to a safe tripwire_triggered value rather than letting the whole run crash.
Can I use Claude or another model with the OpenAI Agents SDK?
The SDK is model-agnostic through LiteLLM-style model strings for some providers, but it's built and documented around OpenAI's models, and that's what every guardrail and tracing feature in this guide assumes. If you specifically want Claude's tool-use model, the Claude Agent SDK guide is the more direct path.
Do guardrails add noticeable latency?
A heuristic guardrail like the ones in this guide adds effectively none. A guardrail that calls another model adds that model's latency on top of the main agent's, which is why cheap, fast checks are worth keeping cheap and fast.
How much does a triage run like this cost?
On GPT-6.1 Sol ($2/M input, $10/M output), one triage-plus-specialist turn with a short system prompt and one tool call runs a fraction of a cent to a couple of cents, mostly driven by how much conversation history gets resent each turn.
Can someone build this triage and guardrail setup for us?
Yes. RapidDev builds production agents on the OpenAI Agents SDK, Claude Agent SDK, and LangChain, including the handoff and guardrail design most tutorials only sketch. A free 30-minute consultation is the fastest way to scope yours.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation