Handoffs and Guardrails in the OpenAI Agents SDK
Build a triage agent that hands work to specialists, and wrap the entry agent with input/output guardrails so off-policy traffic fails closed.
Search across all documentation pages
Build a triage agent that hands work to specialists, and wrap the entry agent with input/output guardrails so off-policy traffic fails closed.
Use Agent.handoffs (or handoff(...)) so the model transfers control via synthetic tools, and attach @input_guardrail / @output_guardrail (plus tool guardrails) so policy checks raise tripwire exceptions when they fail.
handoff_description.handoffs list (or wrap with handoff() for filters and callbacks).function_tool calls inside the workflow.InputGuardrailTripwireTriggered / OutputGuardrailTripwireTriggered at the host boundary.run_in_parallel=False) when you must avoid tool side effects on bad input (verify API for your version).import asyncio
from pydantic import BaseModel
from agents import (
Agent,
GuardrailFunctionOutput,
InputGuardrailTripwireTriggered,
Runner,
function_tool,
handoff,
input_guardrail,
RunContextWrapper,
)
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
# --- tools for specialists ---
@function_tool
def lookup_order(order_id: str) -> str:
"""Return a demo order status string."""
return f"Order {order_id}: shipped, ETA 2 days"
# --- specialists ---
billing_agent = Agent(
name="Billing agent",
handoff_description="Invoices, charges, payment methods",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
You handle billing questions. Be precise about money and dates.""",
)
refund_agent = Agent(
name="Refund agent",
handoff_description="Refunds, returns, chargebacks",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
You handle refund eligibility and next steps. Never invent policy.""",
tools=[lookup_order],
)
# --- input guardrail (runs on first agent) ---
class PolicyCheck(BaseModel):
is_disallowed: bool
reasoning: str
guardrail_agent = Agent(
name="Policy guard",
instructions="Flag requests that ask for illegal activity or credential theft.",
output_type=PolicyCheck,
)
@input_guardrail
async def policy_input_guardrail(
ctx: RunContextWrapper[None],
agent: Agent,
input: str | list,
) -> GuardrailFunctionOutput:
result = await Runner.run(guardrail_agent, input, context=ctx.context)
return GuardrailFunctionOutput(
output_info=result.final_output,
tripwire_triggered=result.final_output.is_disallowed,
)
triage = Agent(
name="Support triage",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
Route billing vs refund questions to the right specialist.
Answer only trivial FAQ yourself.""",
handoffs=[
billing_agent,
handoff(refund_agent), # customize further if needed
],
input_guardrails=[policy_input_guardrail],
)
async def main() -> None:
try:
result = await Runner.run(
triage,
"I need a refund for order A-100. It never arrived.",
)
print(result.final_output)
print("finished on:", result.last_agent.name)
except InputGuardrailTripwireTriggered:
print("Blocked by input guardrail")
asyncio.run(main())transfer_to_refund_agent).triage run because it is the first agent.| Pattern | Who stays in control | Use when |
|---|---|---|
| Handoff | Specialist takes over the conversation for that part of the run | Domain transfer ("you own billing now") |
| Agent as tool | Orchestrator stays manager and calls specialist tools | Manager needs to synthesize multiple specialists |
Start with handoffs for support-style routing. Switch to agents-as-tools when the parent must merge partial answers without giving up the floor.
handoff()Useful knobs (verify names at build):
tool_name_override / tool_description_overrideon_handoff callback for logging or prefetchinput_type for model-supplied metadata (reason, priority)input_filter to reshape history the next agent seesis_enabled for dynamic routing allowlistsExample metadata pattern:
from pydantic import BaseModel
from agents import handoff, RunContextWrapper
class EscalationData(BaseModel):
reason: str
async def on_escalation(ctx: RunContextWrapper[None], data: EscalationData):
print("escalating:", data.reason)
escalation = Agent(name="Escalation agent", instructions="Handle escalations.")
esc_handoff = handoff(
agent=escalation,
on_handoff=on_escalation,
input_type=EscalationData,
)input_type does not choose the destination; register one handoff per destination.
| Guardrail kind | Runs when | Typical use |
|---|---|---|
| Input (agent) | First agent only | Jailbreak / off-topic / PII policy on user text |
| Output (agent) | Final agent only | Leakage, brand tone, structured policy on answer |
| Tool input/output | Every custom function tool call | Block secrets in args; redact tool results |
Handoffs do not re-run the first agent's input guardrails on later specialists.
If each specialist needs checks, put tool guardrails on risky tools or add host-level policy around Runner.run.
Use blocking when tools have side effects you cannot unwind.
By default the next agent often sees prior conversation items.
If tool spam pollutes context, apply an input_filter (for example agents.extensions.handoff_filters.remove_all_tools) or nest history via run config options when you deliberately want summaries (beta/options vary - verify at build).
function_tool paths.| Approach | When it fits | Trade-off |
|---|---|---|
| OpenAI Agents handoffs + guardrails | OpenAI-native multi-specialist apps | Provider coupling |
| LangGraph routing nodes | Explicit graphs, resume, complex joins | More ceremony |
| Single agent + many tools | Small domains, weak specialist boundaries | Tool overload / weaker prompts |
| Anthropic Messages tool loop | Full control of every tool turn | You own multi-agent routing |
| Host policy proxy (API gateway) | Org-wide filters before any agent SDK | Does not replace tool-level checks |
Handoffs are presented as tools. When the model invokes the transfer tool, the runner switches the active agent and continues the run.
The standard handoff() helper targets one agent. Register multiple handoffs and let the model choose, or build a custom Handoff only when you must decide the destination in code (verify advanced APIs at build).
Usually no. Prefer a cheap, fast model for binary policy checks so you save cost when tripwires fire early.
No. They reduce some classes of abuse. Combine with least-privilege tools, output encoding, and isolation of untrusted content.
In on_handoff, in tracing dashboards, and in your application logs with request ids. Log destination agent name and optional input_type reason.
Yes via is_enabled on handoff() (bool or callable). Use this for feature flags or entitlement checks.
A stable, non-technical refusal. Do not echo the policy reasoning model’s chain-of-thought or internal scores to end users.
Handoffs stay inside a single Runner.run. Session/memory strategies still wrap the conversation across runs; do not confuse multi-agent transfer with multi-session persistence.
No. Many products work with one agent and well-scoped tools. Add specialists when prompts conflict or tool allowlists must diverge.
output_type enforces shape/schema of the final answer. Guardrails enforce policy checks that can trip independently of schema validity.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026