Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an AI Agent with the OpenAI Agents SDK

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.

What you'll learn

  • Split one agent into specialists and connect them with handoffs instead of one long system prompt covering every case
  • Write an input_guardrail that stops an off-topic request before any agent spends tokens on it
  • Write an output_guardrail that catches a policy violation in the final answer and routes it to a human instead of sending it
  • Pass context through a handoff with on_handoff so the receiving agent doesn't start from zero
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate10 min read45-60 minPython 3.10+ (or Node.js 20+ for TypeScript), openai-agents, OpenAI API keyLast updated November 2026RapidDev Engineering Team
TL;DR

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.

Quick facts about this guide
FactValue
ToolOpenAI Agents SDK
DifficultyIntermediate
Time required45-60 min
CompatibilityPython 3.10+ (or Node.js 20+ for TypeScript), openai-agents, OpenAI API key
Last updatedNovember 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

1

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.

typescript
1pip install openai-agents
2export 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.

2

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.

typescript
1from agents import Agent, function_tool
2
3@function_tool
4def 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"}
7
8@function_tool
9def 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."
12
13billing_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.

3

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.

typescript
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.

4

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.

typescript
1from agents import input_guardrail, GuardrailFunctionOutput, RunContextWrapper
2
3OFF_TOPIC_MARKERS = ("competitor", "unrelated to our product")
4
5@input_guardrail
6async 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)
10
11triage_agent.input_guardrails = [reject_off_topic]

Expected result: A message containing an off-topic marker raises InputGuardrailTripwireTriggered instead of reaching triage_agent.

5

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.

typescript
1import re
2from agents import output_guardrail
3
4@output_guardrail
5async 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)) > 500
9 return GuardrailFunctionOutput(output_info={"amount": match.group(1) if match else None}, tripwire_triggered=too_large)
10
11billing_agent.output_guardrails = [block_large_refund_promise]

Expected result: A billing_agent reply promising a $900 refund raises OutputGuardrailTripwireTriggered; one promising $40 does not.

6

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.

typescript
1import asyncio
2from agents import Runner, InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered
3
4async def handle_message(text: str) -> str:
5 try:
6 result = await Runner.run(triage_agent, text)
7 return result.final_output
8 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]"
12
13if __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

billing_triage_agent.py
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 asyncio
5import re
6
7from agents import (
8 Agent,
9 GuardrailFunctionOutput,
10 InputGuardrailTripwireTriggered,
11 OutputGuardrailTripwireTriggered,
12 RunContextWrapper,
13 Runner,
14 function_tool,
15 input_guardrail,
16 output_guardrail,
17)
18
19OFF_TOPIC_MARKERS = ("competitor", "unrelated to our product")
20
21
22@function_tool
23def 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"}
26
27
28@function_tool
29def 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."
32
33
34billing_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)
40
41support_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)
47
48
49@input_guardrail
50async 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)
54
55
56@output_guardrail
57async 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)) > 500
61 return GuardrailFunctionOutput(output_info={"amount": match.group(1) if match else None}, tripwire_triggered=too_large)
62
63
64billing_agent.output_guardrails = [block_large_refund_promise]
65
66triage_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)
76
77
78async def handle_message(text: str) -> str:
79 try:
80 result = await Runner.run(triage_agent, text)
81 return result.final_output
82 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]"
86
87
88if __name__ == "__main__":
89 print(asyncio.run(handle_message("My invoice looks wrong, can I get a refund?")))
90

Common 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.

ChatGPT Prompt

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]

Build AI Agents Prompt

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.