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.
Summary
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.
Recipe
- Define specialist agents with clear instructions and optional
handoff_description. - Put those specialists on a triage agent's
handoffslist (or wrap withhandoff()for filters and callbacks). - Add recommended handoff prompt guidance so the model understands transfer tools.
- Implement input guardrails for the first agent in the chain (cheap model checks work well).
- Implement output guardrails on the agent that produces the final output when needed.
- Use tool guardrails for per-tool checks on custom
function_toolcalls inside the workflow. - Catch
InputGuardrailTripwireTriggered/OutputGuardrailTripwireTriggeredat the host boundary. - Prefer blocking input guardrails (
run_in_parallel=False) when you must avoid tool side effects on bad input (verify API for your version).
Working Example
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())- Handoffs are exposed to the LLM as tools (default names like
transfer_to_refund_agent). - Input guardrails on
triagerun because it is the first agent. - Output guardrails (not shown) run only on the agent that emits the final output.
Deep Dive
Handoffs vs agents-as-tools
| 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.
Customizing handoff()
Useful knobs (verify names at build):
tool_name_override/tool_description_overrideon_handoffcallback for logging or prefetchinput_typefor model-supplied metadata (reason, priority)input_filterto reshape history the next agent seesis_enabledfor dynamic routing allowlists
Example 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 boundaries (easy to misuse)
| 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.
Parallel vs blocking input guardrails
- Parallel (default): lower latency; agent may start (and spend tokens) before a tripwire cancels.
- Blocking: guardrail finishes first; failed checks never start the expensive agent path.
Use blocking when tools have side effects you cannot unwind.
History hygiene on handoff
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).
Gotchas
- Assuming every agent runs its input guardrails. Only the first agent in the chain does.
- Assuming output guardrails run on intermediate specialists. Only the final output agent does.
- Forgetting handoff prompt guidance. Models route more reliably with the recommended handoff prefix.
- Putting authorization only in prompts. Guardrails help; real auth still belongs in tools and backends.
- Letting full tool transcripts flood specialists. Use input filters when history is noisy.
- Treating tripwires as soft warnings. They raise exceptions; handle them at the API boundary with a user-safe message.
- Expecting tool guardrails on hosted tools / handoff tools. They target custom
function_toolpaths.
Alternatives
| 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 |
FAQs
How does the model "call" a handoff?
Handoffs are presented as tools. When the model invokes the transfer tool, the runner switches the active agent and continues the run.
Can one handoff dynamically pick among many agents?
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).
Should guardrails use the same model as the main agent?
Usually no. Prefer a cheap, fast model for binary policy checks so you save cost when tripwires fire early.
Do guardrails stop prompt injection completely?
No. They reduce some classes of abuse. Combine with least-privilege tools, output encoding, and isolation of untrusted content.
Where should I log handoffs?
In on_handoff, in tracing dashboards, and in your application logs with request ids. Log destination agent name and optional input_type reason.
Can I disable a handoff at runtime?
Yes via is_enabled on handoff() (bool or callable). Use this for feature flags or entitlement checks.
What user message should I return after a tripwire?
A stable, non-technical refusal. Do not echo the policy reasoning model’s chain-of-thought or internal scores to end users.
How do handoffs interact with sessions?
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.
Should every product use multi-agent handoffs?
No. Many products work with one agent and well-scoped tools. Add specialists when prompts conflict or tool allowlists must diverge.
What is the difference between guardrails and `output_type`?
output_type enforces shape/schema of the final answer. Guardrails enforce policy checks that can trip independently of schema validity.
Related
- OpenAI Agents SDK Basics - install, tools, first triage pattern
- Native MCP Support in the OpenAI Agents SDK - external tool servers
- Prompt Injection & Guardrails Basics - broader security framing
- Multi-Agent Handoff: Specialist Agents Passing Work to Each Other - architecture view
- OpenAI Agents SDK & Native Primitives Best Practices - production checklist
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.