Building AI Agents with OpenAI Agents SDK in Python
When OpenAI released the Agents SDK in March 2025, it signaled a shift in how the company thinks about agent development. Rather than leaving developers to stitch together API calls with custom orchestration logic, the SDK provides opinionated primitives for building multi-agent systems: agents, handoffs, guardrails, and tracing — all in a lightweight, Python-native package.
Having spent the past few months building with the SDK in production, I want to share what works, what to watch out for, and how the core concepts fit together. If you have been building agents with other frameworks or raw API calls, the Agents SDK offers a refreshingly minimal approach that stays close to the metal while handling the hard parts of orchestration.
Core concepts
The Agents SDK is built around four primitives:
- Agent: An LLM configured with a system prompt, a set of tools, and optional handoff targets.
- Runner: The execution engine that manages the agent loop — calling the model, executing tools, and handling handoffs.
- Handoff: A mechanism for one agent to transfer control to another, enabling multi-agent workflows.
- Guardrail: Input and output validators that run in parallel with agent execution to enforce safety and quality constraints.
Setting up your first agent
Install the SDK and set up a basic agent:
pip install openai-agents
from agents import Agent, Runner
import asyncio
agent = Agent(
name="Research Assistant",
instructions="""You are a helpful research assistant. When asked about
a topic, provide detailed, well-structured information with key facts
and recent developments. Always cite your reasoning.""",
model="gpt-4o"
)
async def main():
result = await Runner.run(
agent,
"What are the key differences between transformer "
"and state-space model architectures?"
)
print(result.final_output)
asyncio.run(main())
The Runner.run method handles the full agent loop internally. It sends the messages to the model, processes any tool calls, and continues until the agent produces a final text response. This loop is the heart of the SDK — you do not need to write it yourself.
Adding tools
Tools are defined as plain Python functions with type annotations. The SDK automatically generates the JSON schema for the function and handles argument parsing.
from agents import Agent, Runner, function_tool
import httpx
import asyncio
@function_tool
async def search_arxiv(query: str, max_results: int = 5) -> str:
"""Search arXiv for academic papers on a given topic.
Args:
query: The search query for finding papers.
max_results: Maximum number of results to return.
"""
url = "http://export.arxiv.org/api/query"
params = {"search_query": f"all:{query}", "max_results": max_results}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params)
return response.text
@function_tool
def summarize_findings(text: str, max_sentences: int = 3) -> str:
"""Summarize a block of text into key findings.
Args:
text: The text to summarize.
max_sentences: Maximum number of sentences in the summary.
"""
sentences = text.split(". ")
return ". ".join(sentences[:max_sentences]) + "."
research_agent = Agent(
name="Paper Researcher",
instructions="You help researchers find and summarize academic papers. "
"Use the search tool to find papers, then summarize the results.",
tools=[search_arxiv, summarize_findings],
model="gpt-4o"
)
async def main():
result = await Runner.run(
research_agent,
"Find recent papers on multi-agent reinforcement learning"
)
print(result.final_output)
asyncio.run(main())
The @function_tool decorator handles the heavy lifting. It introspects the function signature, extracts the docstring for descriptions, and creates the tool definition that gets sent to the API. Async functions are supported natively.
Agent handoffs
Handoffs allow one agent to transfer control to another. This is the SDK's mechanism for building multi-agent systems without manually managing conversation routing.
from agents import Agent, Runner
import asyncio
# Specialist agents
sql_agent = Agent(
name="SQL Expert",
instructions="You are a SQL expert. Help users write, optimize, and "
"debug SQL queries. Provide clear explanations of query "
"plans and optimization strategies.",
model="gpt-4o"
)
python_agent = Agent(
name="Python Expert",
instructions="You are a Python expert. Help users write clean, "
"efficient Python code. Follow PEP 8 conventions and "
"suggest modern Python patterns.",
model="gpt-4o"
)
# Triage agent that routes to specialists
triage_agent = Agent(
name="Triage Agent",
instructions="""You are a triage agent. Analyze the user's question
and hand off to the appropriate specialist:
- For SQL, database, or query questions: hand off to SQL Expert
- For Python coding questions: hand off to Python Expert
If the question doesn't fit either category, answer it yourself.""",
handoffs=[sql_agent, python_agent],
model="gpt-4o"
)
async def main():
result = await Runner.run(
triage_agent,
"How do I optimize a slow PostgreSQL query that joins three tables?"
)
print(f"Handled by: {result.last_agent.name}")
print(f"Response: {result.final_output}")
asyncio.run(main())
When the triage agent decides to hand off, the Runner transfers the conversation to the target agent. The result.last_agent property tells you which agent ultimately handled the request, which is invaluable for logging and debugging.
Guardrails for safety and quality
Guardrails run alongside agent execution to validate inputs and outputs. They can intercept and block requests before they reach the model or filter responses before they reach the user.
from agents import Agent, Runner, InputGuardrail, GuardrailFunctionOutput
from pydantic import BaseModel
import asyncio
class SafetyCheck(BaseModel):
is_safe: bool
reasoning: str
safety_agent = Agent(
name="Safety Checker",
instructions="Evaluate if the user input is safe and appropriate. "
"Flag anything that requests harmful, illegal, or "
"unethical content.",
output_type=SafetyCheck,
model="gpt-4o-mini"
)
async def check_input_safety(ctx, agent, input_text):
result = await Runner.run(safety_agent, input_text, context=ctx)
output = result.final_output_as(SafetyCheck)
return GuardrailFunctionOutput(
output_info=output,
tripwire_triggered=not output.is_safe
)
guarded_agent = Agent(
name="Guarded Assistant",
instructions="You are a helpful assistant that answers questions "
"about technology and programming.",
input_guardrails=[
InputGuardrail(guardrail_function=check_input_safety)
],
model="gpt-4o"
)
async def main():
try:
result = await Runner.run(
guarded_agent,
"How do I build a REST API in Python?"
)
print(result.final_output)
except Exception as e:
print(f"Request blocked: {e}")
asyncio.run(main())
A key design decision in the SDK is that guardrails run in parallel with the main agent. This means the safety check does not add latency to the happy path — the main agent starts processing immediately, and if the guardrail trips, the response is discarded. This is a thoughtful optimization for production workloads.
Built-in tracing
The SDK includes built-in tracing that captures every step of agent execution. Traces are sent to the OpenAI dashboard by default, but you can configure custom processors.
from agents import Agent, Runner, trace
import asyncio
agent = Agent(
name="Traced Agent",
instructions="You are a helpful assistant.",
model="gpt-4o"
)
async def main():
# Traces are automatically captured
with trace("my-workflow"):
result = await Runner.run(agent, "Explain gradient descent")
print(result.final_output)
# Access trace data programmatically
# Traces include: agent calls, tool executions,
# handoffs, guardrail results, and timing data
asyncio.run(main())
Tracing is essential for debugging multi-agent systems in production. When an agent chain produces an unexpected result, traces let you see exactly which agent made which decision, what tools were called with what arguments, and where the reasoning went sideways.
Streaming responses
For user-facing applications, streaming is critical. The SDK supports streaming through Runner.run_streamed:
from agents import Agent, Runner
import asyncio
agent = Agent(
name="Streaming Assistant",
instructions="You are a helpful assistant. Provide detailed answers.",
model="gpt-4o"
)
async def main():
result = Runner.run_streamed(agent, "Explain how transformers work")
async for event in result.stream_events():
if event.type == "raw_response_event" and hasattr(event.data, "delta"):
print(event.data.delta, end="", flush=True)
asyncio.run(main())
Production considerations
After running the Agents SDK in production for several months, here are the patterns I have found most valuable:
Use handoffs for domain separation. Rather than building one monolithic agent with dozens of tools, create specialist agents and use a triage agent to route. This keeps system prompts focused and reduces the chance of tool confusion.
Layer guardrails defensively. Use input guardrails for safety filtering and output guardrails for format validation. The parallel execution model means guardrails are essentially free in terms of latency.
Set up tracing from day one. Set up custom trace processors that push data to your observability stack. When something goes wrong in a multi-agent pipeline at 3 AM, traces are the difference between a quick fix and hours of guesswork.
Keep agents stateless. The Runner handles conversation state within a single run. For multi-turn conversations, manage state externally and pass relevant context into each run. This makes agents easier to test and scale.
Wrapping up
The OpenAI Agents SDK strikes a good balance between simplicity and power. Instead of a full framework with dozens of abstractions, it provides a small set of well-designed primitives that compose naturally. If you are already using OpenAI models and want a production-grade agent framework without the overhead of larger ecosystems, the Agents SDK is worth serious consideration.