If LangGraph is a state machine DSL, the OpenAI Agents SDK is closer to writing regular Python. You define agents as objects, declare their handoffs explicitly, and the framework handles the routing. No graph nodes, no edge conditions, no StateGraph.add_conditional_edges.
The SDK was released in early 2026 as a standalone library, separate from the Assistants API. It's built for production multi-agent systems — specifically the routing/handoff problem that every multi-agent setup eventually hits.
This post covers the four core primitives, builds a complete triage system, adds guardrails, and explains when to choose this over LangGraph or Pydantic AI.
Installation
pip install openai-agents
The SDK uses OpenAI models by default. Claude support is community-maintained via a model adapter — more on that below.
The four primitives
Agent — defines a single agent: its name, instructions (system prompt), tools, and handoffs.
Tool — a Python function the agent can call. Same concept as Anthropic's tool use.
Handoff — route control to another agent. The first agent stops; the second takes over with full context.
Guardrail — validate input before processing or output before sending. Can block or transform.
Building a triage system
The canonical use case: a router agent reads the incoming message and hands off to one of several specialist agents.
from agents import Agent, Runner, handoff
import asyncio
# Specialist agents
support_agent = Agent(
name="Support",
instructions=(
"You handle product questions, how-to requests, and feature inquiries. "
"Be specific and helpful. If you don't know, say so clearly."
),
)
billing_agent = Agent(
name="Billing",
instructions=(
"You handle payment questions, invoice requests, subscription changes, and refunds. "
"For refund requests above ₹5,000, always escalate to a human."
),
)
technical_agent = Agent(
name="Technical",
instructions=(
"You handle bugs, API questions, error messages, and integration issues. "
"Ask for error logs and stack traces if not provided."
),
)
# Triage agent — routes but doesn't answer
triage_agent = Agent(
name="Triage",
instructions=(
"Classify the incoming message and route to the right specialist. "
"Do not attempt to answer the question yourself. "
"Route to: Support (product/feature questions), Billing (payment/invoice), "
"Technical (bugs/API/errors)."
),
handoffs=[
handoff(support_agent),
handoff(billing_agent),
handoff(technical_agent),
],
)
async def handle_query(message: str) -> str:
result = await Runner.run(triage_agent, message)
return result.final_output
# Usage
response = asyncio.run(handle_query("My payment failed but I was charged. How do I get a refund?"))
print(response)
# → Routes to billing_agent, which responds about the refund process
The triage agent classifies the message, calls the appropriate handoff(), and the specialist agent handles the rest. The result.final_output is the final agent's response.
Handoff with context passing
When you hand off, the receiving agent gets the full conversation history. You can also pass structured context:
from agents import Agent, Runner, handoff, RunContextWrapper
from pydantic import BaseModel
class CustomerContext(BaseModel):
customer_id: str
tier: str
account_age_days: int
vip_agent = Agent(
name="VIP Support",
instructions="You handle premium customers. Prioritize fast resolution.",
)
standard_agent = Agent(
name="Standard Support",
instructions="You handle general customer support.",
)
def route_by_tier(ctx: RunContextWrapper[CustomerContext], agent: Agent) -> Agent:
"""Custom handoff logic based on customer tier."""
if ctx.context.tier == "premium":
return vip_agent
return standard_agent
triage_with_context = Agent(
name="Triage",
instructions="Route the customer to the right support tier.",
handoffs=[
handoff(vip_agent),
handoff(standard_agent),
],
)
# Pass context when running
customer = CustomerContext(customer_id="CUST-001", tier="premium", account_age_days=730)
result = asyncio.run(
Runner.run(triage_with_context, "I need help with my account", context=customer)
)
Guardrails: block harmful input, catch sensitive output
Guardrails run before the agent processes input (input guardrails) or before the response is returned (output guardrails). They can block, modify, or pass through.
Input guardrail: block off-topic queries
from agents import Agent, Runner, InputGuardrail, GuardrailFunctionOutput
from pydantic import BaseModel
class TopicCheckOutput(BaseModel):
is_on_topic: bool
reason: str
# The guardrail itself is an Agent
topic_checker = Agent(
name="Topic Checker",
instructions=(
"Classify the user message. Return JSON with is_on_topic (bool) and reason (string). "
"Off-topic means: completely unrelated to the product (e.g. asking to write poetry, "
"political questions, personal advice). On-topic means: product, billing, technical support."
),
output_type=TopicCheckOutput,
)
async def check_topic(ctx, agent, input) -> GuardrailFunctionOutput:
result = await Runner.run(topic_checker, input, context=ctx.context)
output = result.final_output_as(TopicCheckOutput)
return GuardrailFunctionOutput(
output_info=output,
tripwire_triggered=not output.is_on_topic,
)
guarded_agent = Agent(
name="Support",
instructions="Answer customer support questions.",
input_guardrails=[InputGuardrail(guardrail_function=check_topic)],
)
# If off-topic, the guardrail raises InputGuardrailTripwireTriggered
from agents.exceptions import InputGuardrailTripwireTriggered
try:
result = asyncio.run(Runner.run(guarded_agent, "Write me a poem about autumn"))
except InputGuardrailTripwireTriggered as e:
print("Blocked: off-topic message")
# Return appropriate message to user
Output guardrail: catch PII before responding
from agents import OutputGuardrail, GuardrailFunctionOutput
class PIICheckOutput(BaseModel):
contains_pii: bool
pii_types: list[str] # e.g. ["email", "phone", "aadhaar"]
pii_checker = Agent(
name="PII Checker",
instructions=(
"Check if this text contains personal information: "
"email addresses, phone numbers, Aadhaar numbers, PAN numbers, credit card numbers, or passwords. "
"Return JSON with contains_pii (bool) and pii_types (list of PII types found)."
),
output_type=PIICheckOutput,
)
async def check_pii(ctx, agent, output) -> GuardrailFunctionOutput:
result = await Runner.run(pii_checker, str(output))
pii_result = result.final_output_as(PIICheckOutput)
return GuardrailFunctionOutput(
output_info=pii_result,
tripwire_triggered=pii_result.contains_pii,
)
from agents.exceptions import OutputGuardrailTripwireTriggered
safe_agent = Agent(
name="Support",
instructions="Answer customer support questions.",
output_guardrails=[OutputGuardrail(guardrail_function=check_pii)],
)
try:
result = asyncio.run(Runner.run(safe_agent, "What's my password?"))
except OutputGuardrailTripwireTriggered:
print("Response blocked: contained PII")
Built-in tracing
Every Runner.run() creates a trace automatically. View it in the OpenAI dashboard under Traces, or export to OpenTelemetry:
from agents.tracing import set_trace_processors
from agents.tracing.processors import OtelTraceProcessor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
# Send traces to your OTEL collector (Datadog, Honeycomb, Jaeger, etc.)
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317")))
set_trace_processors([OtelTraceProcessor(provider)])
The trace includes: which agent handled the message, which tool calls were made, the handoff path (triage → billing), and timing for each step.
Streaming
from agents import Runner
async def stream_agent(message: str):
async with Runner.run_streamed(triage_agent, message) as stream:
async for event in stream.stream_events():
if event.type == "raw_response_event":
if hasattr(event.data, "delta") and hasattr(event.data.delta, "text"):
print(event.data.delta.text, end="", flush=True)
elif event.type == "agent_updated_stream_event":
print(f"\n[Handed off to: {event.new_agent.name}]\n")
asyncio.run(stream_agent("My payment failed"))
Using Claude instead of OpenAI models
The SDK defaults to OpenAI. To use Claude, set a custom model provider:
from agents import Agent, set_default_openai_client, set_default_openai_api
from anthropic import Anthropic
# Community-maintained Claude adapter — check PyPI for latest
# pip install openai-agents-claude
from openai_agents_claude import ClaudeModel
support_agent = Agent(
name="Support",
instructions="Handle product support questions.",
model=ClaudeModel("claude-sonnet-4-6"),
)
Note: Claude support depends on a community adapter, not the official SDK. For Claude-first agent development, Pydantic AI or the raw Anthropic SDK are more stable choices.
When to choose the OpenAI Agents SDK
| Scenario | Best choice |
|---|---|
| Multi-agent routing with handoffs | OpenAI Agents SDK |
| Complex graphs with conditional logic | LangGraph |
| Type-safe single agent with typed outputs | Pydantic AI |
| Full control, no abstraction overhead | Raw Anthropic/OpenAI SDK |
| Using OpenAI models exclusively | OpenAI Agents SDK |
| Team knows Python, hates magic | Pydantic AI |
The SDK's handoff model is genuinely simpler than LangGraph for the common routing pattern. You don't need to define nodes, edges, and conditional routing — you declare the handoffs and the model routes. The tradeoff: less flexibility for complex multi-step graphs that need conditional branching based on intermediate results.
The multi-agent systems lesson covers the conceptual model that all these frameworks implement.



