OpenAI Agents SDK: handoffs & guardrails
A small, explicit framework for production agents: Agents, Runner, tools, handoffs, guardrails, and sessions, with a worked triage example and the failure modes that matter.
What you'll learn
- How the Agent and Runner divide instructions, model calls, tools, and state
- How a triage agent hands a conversation to a specialist through a transfer tool
- Where input and output guardrails run, and why they are not authorization
- How to diagnose routing errors, guardrail false positives, loops, and duplicate side effects
Before you start
At 3:07 a.m., a customer types: “I was charged twice. Please refund the duplicate.”
A general-purpose agent can explain the refund policy. It might even know which API to call. That is not the same as safely issuing a refund.
You want:
- one component to classify the request;
- another to understand billing rules; and
- narrowly defined tools to inspect the payment and perform the refund.
A guardrail can abort the run when it trips, but it may run concurrently with the initial agent execution rather than finishing first. Consequential actions must still be blocked by tools whose backends authenticate the caller, authorize the resource, validate the operation, and make it idempotent.
A giant prompt can describe that arrangement. It cannot make the arrangement easy to inspect.
The OpenAI Agents SDK provides explicit objects for this loop. The model chooses what to say or which tool to call; the SDK runs the loop, executes tools, transfers work between agents, checks inputs and outputs, and carries conversation history.
It is not a magical “make me an autonomous employee” button. That would be a rather expensive button.
Six primitives
An agent is a model with a job. A runner repeatedly asks it what to do and carries out the answer.
1. Agent
An Agent is an LLM configured with:
- instructions;
- tools;
- optional handoffs; and
- optionally, a structured output type.
For support, a billing agent might be told:
Handle invoices, duplicate charges, and refunds. Never claim that money was returned unless the refund tool confirms it.
Instructions are not permissions. Give the agent only the tools it needs, and enforce least privilege in the service behind those tools. Triage and Billing can use the same model; they are separate agents because their responsibilities and tool boundaries differ.
2. Runner
The Runner owns the loop:
- Send the conversation and agent instructions to the model.
- Inspect the response.
- Execute requested tools and add their results to the conversation.
- Ask the model what to do next.
- Stop at a final answer, configured limit, or guardrail failure. A handoff changes the current agent and continues the loop.
The model does not execute Python or access your database. It emits a structured request; the runner validates and dispatches it.
Trace:
- every model call;
- every tool call;
- every handoff; and
- every guardrail decision
so you can reconstruct an incident. See observability.
3. Tools
A tool exposes an application operation through:
- a name;
- a description; and
- typed arguments.
A call might look like:
refund_payment(payment_id="pay_4821", amount_cents=48000)
Your application must still:
- authenticate and authorize the request;
- validate it;
- make it idempotent; and
- decide whether it may affect production data.
The model can request a refund; it must not define the refund policy.
4. Handoffs
A handoff transfers the active conversation to a specialist. Triage selects Billing through a transfer-like tool such as transfer_to_billing; the runner switches agents and continues.
A handoff is routing, not a broadcast or approval. It does not ask several agents to debate, and it does not perform the specialist’s side effect. Use a workflow or supervisor when several agents must work and merge results.
5. Guardrails
A guardrail checks input or output and can stop a run when a rule is violated. The SDK reports the decision with a result containing tripwire_triggered.
Guardrails are policy checks, not replacements for service-side authorization or database constraints.
6. Sessions
A session stores conversation history across runs. It is conversational memory, not business truth: “the customer says they were charged twice” does not prove that the ledger contains two charges. Query the ledger.
The running example: route first, solve second
The support flow is:
- Customer sends the request to Triage.
- Triage selects Billing.
- The runner records the transfer and starts Billing with the conversation.
- Billing looks up the payment, confirms the duplicate, and may request a refund.
- Billing explains the tool’s result.
The transfer does not move money.
Latency and cost
Suppose the model endpoint takes 1.2 seconds for Triage and 1.8 seconds for Billing.
A duplicate-charge request needing one lookup and one refund requires at least:
request
→ Triage model call: chooses transfer_to_billing
→ Billing model call: chooses lookup_payment
→ lookup result
→ Billing model call: chooses refund_payment
→ refund result
→ Billing model call: writes the final answer
That is four model calls, plus tool round trips. A handoff improves separation but can increase latency and cost.
If the first call uses 300 input and 60 output tokens, and the Billing call uses 900 input and 180 output tokens, those two calls already consume 1,200 input and 240 output tokens before the later turns. A monolithic agent might use 1,000 input and 180 output tokens for the same request.
Use routing for boundaries, ownership, and debuggability; measure its cost and quality.
A compact implementation
This example wires Triage, two specialists, an input guardrail, and the payment tools. PaymentBackend is application-owned; its security checks are the important boundary.
from dataclasses import dataclass
from typing import Any
from agents import (
Agent,
GuardrailFunctionOutput,
RunContextWrapper,
Runner,
function_tool,
input_guardrail,
)
@dataclass(frozen=True)
class AuthenticatedPrincipal:
# Created by authentication middleware, never by the model.
customer_id: str
authenticated: bool
@dataclass
class SupportContext:
principal: AuthenticatedPrincipal
payments: "PaymentBackend"
# Generated by the application for this refund request.
refund_operation_id: str
class PaymentBackend:
def __init__(self, db: Any, provider: Any):
self.db = db
self.provider = provider
def _require_authenticated(
self, principal: AuthenticatedPrincipal
) -> None:
if not principal.authenticated:
raise PermissionError("authenticated customer required")
async def lookup_payment(
self,
*,
principal: AuthenticatedPrincipal,
payment_id: str,
) -> dict[str, Any]:
self._require_authenticated(principal)
payment = await self.db.get_payment(payment_id)
if payment is None or payment.customer_id != principal.customer_id:
raise PermissionError("payment not found")
return {
"payment_id": payment.id,
"state": payment.state,
"amount_cents": payment.amount_cents,
"refundable_cents": payment.refundable_cents,
}
async def refund_payment(
self,
*,
principal: AuthenticatedPrincipal,
payment_id: str,
amount_cents: int,
idempotency_key: str,
) -> dict[str, Any]:
self._require_authenticated(principal)
payment = await self.db.get_payment(payment_id)
if payment is None or payment.customer_id != principal.customer_id:
raise PermissionError("payment not found")
if amount_cents <= 0:
raise ValueError("refund amount must be positive")
if payment.state != "succeeded":
raise ValueError("payment is not refundable")
if amount_cents > payment.refundable_cents:
raise ValueError("refund exceeds refundable amount")
# This claim must be atomic. Retries return the old result.
reservation = await self.db.claim_idempotency_key(
customer_id=principal.customer_id,
key=idempotency_key,
request={
"payment_id": payment_id,
"amount_cents": amount_cents,
},
)
if reservation.existing_result is not None:
return reservation.existing_result
if not reservation.acquired:
return await self.db.wait_for_idempotency_result(
customer_id=principal.customer_id,
key=idempotency_key,
)
result = await self.provider.refund(
payment.provider_id,
amount_cents=amount_cents,
idempotency_key=idempotency_key,
)
await self.db.complete_idempotency_key(
customer_id=principal.customer_id,
key=idempotency_key,
result=result,
)
return result
@function_tool
async def lookup_payment(
ctx: RunContextWrapper[SupportContext],
payment_id: str,
) -> dict[str, Any]:
return await ctx.context.payments.lookup_payment(
principal=ctx.context.principal,
payment_id=payment_id,
)
@function_tool
async def refund_payment(
ctx: RunContextWrapper[SupportContext],
payment_id: str,
amount_cents: int,
) -> dict[str, Any]:
return await ctx.context.payments.refund_payment(
principal=ctx.context.principal,
payment_id=payment_id,
amount_cents=amount_cents,
idempotency_key=ctx.context.refund_operation_id,
)
billing = Agent(
name="Billing",
handoff_description="Handles invoices, charges, and refunds.",
instructions=(
"Handle billing questions. Confirm payment facts with tools. "
"Never claim a refund succeeded without a successful tool result."
),
tools=[lookup_payment, refund_payment],
)
technical = Agent(
name="Technical",
handoff_description="Handles product bugs and technical how-to questions.",
instructions="Diagnose product problems and explain the fix clearly.",
)
@input_guardrail
async def support_only(ctx, agent, user_input) -> GuardrailFunctionOutput:
text = user_input.lower() if isinstance(user_input, str) else ""
allowed = any(
topic in text
for topic in ("refund", "invoice", "charged", "bug", "error", "how do")
)
return GuardrailFunctionOutput(
tripwire_triggered=not allowed,
output_info={"support_request": allowed},
)
triage = Agent(
name="Triage",
instructions=(
"Route every support request to Billing or Technical. "
"Do not solve the request yourself. "
"Use Billing for invoices, charges, and refunds; "
"use Technical for bugs and how-to questions."
),
handoffs=[billing, technical],
input_guardrails=[support_only],
)
# principal comes from authentication middleware, payments is initialized,
# and the operation ID is generated by the server.
#
# ctx = SupportContext(
# principal=principal_from_auth_middleware,
# payments=payment_backend,
# refund_operation_id=server_generated_operation_id,
# )
# result = await Runner.run(
# triage,
# "I was charged twice for my invoice",
# context=ctx,
# )
# Triage selects transfer_to_billing; Billing can then look up and refund.
The model-visible arguments omit customer_id and the idempotency key. The application supplies them.
The model may request a refund, but the backend checks ownership, amount, payment state, and retries.
Handoff descriptions are routing signals: “Handles invoices, charges, and refunds” is clearer than BillingAgent. Triage also says “route, do not answer.”
The likely causes are ambiguous routing metadata or conflicting instructions. State the ownership boundary, say “route, do not answer,” and test billing, technical, and ambiguous requests. Do not add agents before fixing the route.
Guardrails have boundaries
An input guardrail is not necessarily a gate that completes before model work. Input checks can run alongside the first agent’s execution.
A guardrail attached to Triage also does not automatically become a policy on every specialist. Output checks apply when an agent produces final output, normally the final agent in the chain.
For a refund, use layers:
- Input checks reject clearly unrelated requests.
- Billing instructions describe how to investigate.
- The refund tool verifies the authenticated customer, payment, amount, window, and state.
- The payment service enforces authorization and idempotency.
- An output check rejects unsupported claims such as “your money is back” when the tool says “pending.”
The tool boundary is often the most important layer. If a prompt persuades the model to request a refund, the tool should still refuse an unauthorized customer or amount.
Record guardrail decisions for tracing without putting unnecessary sensitive data in logs. See agent security.
Sessions are history, not a database
Sessions let Billing see earlier messages, such as “The second charge was on Tuesday,” without rebuilding the transcript. Give each user and conversation a distinct session identity, set retention rules, and redact credentials.
When a customer asks “Did you refund me?”, query the payment system. Session history may contain a stale tool result or mistaken claim. It is context, not evidence.
What breaks first
Triage answers instead of handing off
A trace shows Triage writing a billing answer without a transfer. Narrow the handoff description, strengthen “route, do not answer,” and evaluate representative requests.
For high-confidence routes, such as an explicit billing form, deterministic application code may be safer than LLM routing.
A harmless request trips the input guardrail
Keyword checks miss synonyms, spelling, languages, and context. Replace them with a tested classifier or structured decision, measure false positives separately from false negatives, and keep a safe fallback.
A refund happens twice
Retries can repeat a tool call after a network timeout. Use an idempotency key, make the payment service authoritative, and return the existing result for a repeated operation.
A guardrail saying refunds are allowed does not make a refund idempotent.
Agents bounce between each other
Both agents may claim an ambiguous request. Define each handoff’s scope, remove unnecessary reverse handoffs, and set a run budget.
For predictable workflows, encode state transitions in application code instead of asking models to negotiate ownership.
When this SDK is the right size
The Agents SDK fits a small set of clearly owned agents, such as a router with Billing and Technical specialists. Its value is explicitness: the loop, transfer, guardrail, and session are visible.
Use:
- a plain model API for one prompt and response;
- an ordinary function for deterministic routes; and
- a stateful graph for approvals, webhooks, retries, or long-running workflows.
LangGraph is one option for that shape.
The SDK does not replace authentication, authorization, rate limits, durable records, idempotent side effects, tracing, or evaluation. It makes those concerns easier to connect.
Next
Production agents need measurement and limits:
Quick check
Quick check
Practice this in an interview
All questionsKeep raw credentials outside model context and traces. Let the model propose typed intent, authorize the final action and arguments deterministically, then have a trusted executor inject a short-lived, narrowly scoped, audience-restricted credential for one call. Re-authorize downstream and gate high-impact writes with explicit approval.
Autonomous agents are risky because untrusted prompts, retrieved documents, tool outputs, and memories can influence a model that has real authority to read data and take actions. The main risks are prompt injection and hijacking, excessive permissions and confused-deputy actions, data exfiltration, poisoned memory or tools, and runaway cost or destructive loops; defenses must enforce authorization, isolation, approvals, validation, budgets, and auditability outside the model.
An AI agent is an application that lets an LLM choose and execute validated tools in a bounded loop, carrying observations and state forward until it reaches a goal or needs approval. A single LLM call produces one response or tool-call proposal and stops; it does not itself provide the loop, live-system access, memory, or side effects.
Register every candidate as an immutable, versioned artifact, then move it through environments (dev to staging to prod) gated by automated checks rather than promoting straight to prod. In modern MLflow you use aliases like champion and challenger instead of the deprecated stage labels, and promotion is a governed, auditable action with sign-off and an easy rollback by repointing the alias. Always validate in staging and roll out progressively (canary or shadow) before full traffic.