AI Agents
|stacknotice.com
14 min left|
0%
|2,800 words
AI Agents

OpenAI Agents SDK: Building Production AI Agents (2026)

Complete guide to the OpenAI Agents SDK — Agent, Runner, Tools, Handoffs, Guardrails, Sessions, tracing, and multi-agent patterns for production Python apps.

C
Carlos Oliva
Software Developer
July 20, 202614 min read
Share:
OpenAI Agents SDK: Building Production AI Agents (2026)

OpenAI's Agents SDK has become the standard starting point for building production agentic systems with Python. Released in early 2025 and matured significantly since, it gives developers a small, opinionated set of primitives — Agent, Runner, Tools, Handoffs, Guardrails, Sessions — that map directly onto what you need for real multi-agent workflows without the overhead of more complex orchestration frameworks.

If you've built something with LangGraph or CrewAI and found yourself fighting the abstraction more than the problem, the Agents SDK is worth understanding. It sits at a useful point in the complexity spectrum: enough structure to standardize how agents hand off work to each other and validate their outputs, but no graph topology to author or role definitions to wrangle.

What the SDK Gives You

Six core primitives:

  • Agent: An LLM with a system prompt, a list of tools it can call, and optionally a list of other agents it can hand off to
  • Runner: Executes the agent loop — runs the model, processes tool calls, loops until done
  • Tools: Functions the agent can call during its reasoning (built-in: web search, code execution; or custom Python functions)
  • Handoffs: Structured transfer of control from one agent to another with context passed along
  • Guardrails: Input/output validators that run before the model sees a message or after it produces one
  • Sessions: Persistent conversation state across multiple runs

The SDK is built on the OpenAI Responses API and works with any model accessible via LiteLLM, which means you can swap Claude, Gemini, or local models for the LLM layer while keeping the agent infrastructure.

Installation

pip install openai-agents

Requires Python 3.10 or newer. Set your API key:

export OPENAI_API_KEY=sk-...

Your First Agent

from agents import Agent, Runner
 
agent = Agent(
    name="Code Reviewer",
    instructions="""You are a TypeScript code reviewer.
    When given code, you analyze it for:
    - TypeScript type safety issues
    - Performance problems
    - Security vulnerabilities
    - Missing error handling
 
    Be specific. Quote the problematic code and explain why it's an issue.""",
    model="gpt-4o",
)
 
result = Runner.run_sync(agent, "Review this: const data = await fetch(url).then(r => r.json())")
print(result.final_output)

Runner.run_sync runs the full agent loop synchronously. For async contexts (FastAPI, async scripts):

import asyncio
from agents import Agent, Runner
 
async def main():
    agent = Agent(
        name="Assistant",
        instructions="You are a helpful coding assistant.",
    )
    result = await Runner.run(agent, "What's the difference between null and undefined in TypeScript?")
    print(result.final_output)
 
asyncio.run(main())

Custom Tools

Tools are the mechanism through which agents interact with the world — APIs, databases, filesystems, external services. Define them as Python functions with type annotations:

from agents import Agent, Runner, function_tool
import httpx
 
@function_tool
def search_npm_package(package_name: str) -> str:
    """Search npm registry for a package and return its latest version and description."""
    response = httpx.get(f"https://registry.npmjs.org/{package_name}/latest")
    if response.status_code == 404:
        return f"Package '{package_name}' not found on npm"
 
    data = response.json()
    return f"Name: {data['name']}\nVersion: {data['version']}\nDescription: {data.get('description', 'No description')}"
 
@function_tool
def read_file(path: str) -> str:
    """Read a file from the current project directory. Path must be relative."""
    import os
    # Security: restrict to current directory
    full_path = os.path.abspath(path)
    cwd = os.path.abspath(".")
    if not full_path.startswith(cwd):
        return "Error: cannot read files outside project directory"
 
    try:
        with open(full_path) as f:
            return f.read()
    except FileNotFoundError:
        return f"File not found: {path}"
 
agent = Agent(
    name="Package Analyst",
    instructions="""You help analyze npm packages.
    When asked about a package, look it up with search_npm_package.
    When asked to review code, read it with read_file first.""",
    tools=[search_npm_package, read_file],
)
 
result = Runner.run_sync(agent, "What version is react and what does it do?")
print(result.final_output)

The @function_tool decorator generates the JSON schema for the tool from your function's type annotations and docstring. The docstring becomes the tool description the model sees — write it from the model's perspective, not yours.

Built-In Tools

The SDK ships with several built-in tools:

from agents import Agent
from agents.tools import WebSearchTool, CodeInterpreterTool
 
research_agent = Agent(
    name="Research Agent",
    instructions="You research technical topics using web search.",
    tools=[
        WebSearchTool(),           # Searches the web
        CodeInterpreterTool(),     # Executes Python code in a sandbox
    ],
)

Handoffs: Multi-Agent Architecture

Handoffs are how you build systems where a coordinator agent delegates work to specialized agents. The coordinator doesn't do the work itself — it decides which specialist should handle each part.

from agents import Agent, Runner, handoff
 
# Specialist agents
typescript_expert = Agent(
    name="TypeScript Expert",
    instructions="""You are a TypeScript expert.
    You fix TypeScript type errors, improve type safety, and add proper generics.
    You only work on TypeScript/JavaScript files.
    When done, summarize what you changed and why.""",
    model="gpt-4o",
)
 
security_reviewer = Agent(
    name="Security Reviewer",
    instructions="""You are a web security expert.
    You check code for OWASP vulnerabilities: SQL injection, XSS, CSRF,
    insecure direct object references, authentication flaws.
    You provide specific remediation for each issue found.""",
    model="gpt-4o",
)
 
performance_analyst = Agent(
    name="Performance Analyst",
    instructions="""You analyze code for performance issues:
    N+1 queries, unnecessary re-renders, missing memoization,
    inefficient algorithms, blocking operations.""",
    model="gpt-4o",
)
 
# Coordinator decides who handles what
coordinator = Agent(
    name="Code Review Coordinator",
    instructions="""You coordinate thorough code reviews.
 
    When given code to review:
    1. First identify the type of issues present
    2. Hand off to TypeScript Expert if there are type issues
    3. Hand off to Security Reviewer if there are security concerns
    4. Hand off to Performance Analyst if there are performance issues
    5. You can hand off to multiple specialists sequentially
 
    After all specialists have reviewed, synthesize their findings into
    a final summary with priorities (critical, major, minor).""",
    handoffs=[
        handoff(typescript_expert),
        handoff(security_reviewer),
        handoff(performance_analyst),
    ],
)
 
code_to_review = """
async function getUser(req, res) {
    const userId = req.query.id;
    const user = await db.query(`SELECT * FROM users WHERE id = ${userId}`);
    res.json(user.rows[0]);
}
"""
 
result = Runner.run_sync(coordinator, f"Review this code:\n```javascript\n{code_to_review}\n```")
print(result.final_output)

The coordinator loops: it can hand off to multiple specialists in sequence, collect their outputs, and synthesize a final response. Each specialist agent only sees what the coordinator passes to it.

Handoff with Context

Pass structured context when handing off:

from agents import handoff
from pydantic import BaseModel
 
class SecurityHandoffData(BaseModel):
    code: str
    language: str
    known_issues: list[str]
    priority: str  # "critical" | "high" | "normal"
 
security_reviewer = Agent(
    name="Security Reviewer",
    instructions="Review code for security vulnerabilities. Focus on critical issues first.",
)
 
coordinator = Agent(
    name="Coordinator",
    handoffs=[
        handoff(
            security_reviewer,
            input_type=SecurityHandoffData,
            tool_name="request_security_review",
            tool_description="Send code to the security specialist for review",
        )
    ],
)

Guardrails: Input and Output Validation

Guardrails run validation logic before the model sees input (input guardrails) or after it produces output (output guardrails). Use them to enforce safety constraints, validate output format, or detect jailbreak attempts.

from agents import Agent, Runner, GuardrailFunctionOutput, input_guardrail, output_guardrail
from pydantic import BaseModel
 
# Input guardrail: block requests about competitors
@input_guardrail
async def block_competitor_questions(ctx, agent, input) -> GuardrailFunctionOutput:
    input_text = input if isinstance(input, str) else str(input)
    competitors = ["anthropic", "google", "meta llama"]
 
    if any(c in input_text.lower() for c in competitors):
        return GuardrailFunctionOutput(
            output_info="Competitor question detected",
            tripwire_triggered=True,  # blocks the request
        )
    return GuardrailFunctionOutput(output_info="OK", tripwire_triggered=False)
 
# Output guardrail: ensure responses don't contain PII
class PIICheck(BaseModel):
    contains_pii: bool
    pii_types: list[str]
 
pii_checker = Agent(
    name="PII Checker",
    instructions="""Check if the given text contains PII: names, emails,
    phone numbers, addresses, social security numbers.
    Return a structured assessment.""",
    output_type=PIICheck,
)
 
@output_guardrail
async def check_for_pii(ctx, agent, output) -> GuardrailFunctionOutput:
    result = await Runner.run(pii_checker, f"Check this for PII:\n{output.final_output}")
    check = result.final_output
 
    if check.contains_pii:
        return GuardrailFunctionOutput(
            output_info=f"PII detected: {check.pii_types}",
            tripwire_triggered=True,  # blocks the output
        )
    return GuardrailFunctionOutput(output_info="No PII", tripwire_triggered=False)
 
safe_agent = Agent(
    name="Customer Support Agent",
    instructions="Help customers with their questions about our product.",
    input_guardrails=[block_competitor_questions],
    output_guardrails=[check_for_pii],
)

When a guardrail triggers (tripwire_triggered=True), the runner raises a GuardrailTripwireTriggered exception that you can catch and handle:

from agents.exceptions import GuardrailTripwireTriggered
 
try:
    result = await Runner.run(safe_agent, user_message)
    return {"response": result.final_output}
except GuardrailTripwireTriggered as e:
    return {"error": "Request blocked by safety policy", "reason": str(e)}

Structured Output

Force the agent to return a specific Pydantic model:

from pydantic import BaseModel
from agents import Agent, Runner
 
class CodeReview(BaseModel):
    overall_score: int  # 1-10
    critical_issues: list[str]
    warnings: list[str]
    suggestions: list[str]
    summary: str
 
reviewer = Agent(
    name="Structured Reviewer",
    instructions="Review code and return a structured assessment.",
    output_type=CodeReview,
)
 
result = Runner.run_sync(reviewer, "Review: const x = eval(userInput)")
review: CodeReview = result.final_output
 
print(f"Score: {review.overall_score}/10")
for issue in review.critical_issues:
    print(f"🚨 {issue}")

Sessions: Persistent State

Sessions maintain conversation history across multiple runs — essential for chatbots, multi-turn workflows, and anything where the agent needs to remember previous context.

from agents import Agent, Runner
from agents.sessions import InMemorySession, SqliteSession
 
agent = Agent(
    name="Project Assistant",
    instructions="""You are a project management assistant.
    Remember tasks, deadlines, and team members across conversations.""",
)
 
# In-memory session (lost when process exits)
session = InMemorySession()
 
# SQLite session (persists to disk)
session = SqliteSession("agent_sessions.db")
 
# First turn
result = await Runner.run(
    agent,
    "Add a task: implement OAuth login, due Friday, assigned to Alice",
    session=session,
    session_id="project-alpha",
)
 
# Second turn — agent remembers the first
result = await Runner.run(
    agent,
    "What tasks are pending?",
    session=session,
    session_id="project-alpha",  # same session ID = same conversation
)
print(result.final_output)
# → "You have one pending task: implement OAuth login, due Friday, assigned to Alice."

Tracing and Observability

The SDK includes built-in tracing — every agent run produces a trace showing which tools were called, what the model received and produced, how long each step took, and how handoffs occurred.

from agents import Agent, Runner
from agents.tracing import TracingProcessor
 
# Print traces to console (development)
import agents
agents.enable_verbose_stdout_logging()
 
# Or send to your observability platform
from agents.tracing import set_trace_processors
from agents.tracing.processors import OTELSpanExporter
 
set_trace_processors([OTELSpanExporter(endpoint="http://your-otel-collector:4317")])

In production, traces give you the audit trail needed for debugging agent behavior and meeting compliance requirements — every decision the agent made, in order, with timing.

Using Non-OpenAI Models

The SDK supports any model via LiteLLM. Swap the LLM while keeping all the agent infrastructure:

from agents import Agent, Runner
from agents.extensions.models.litellm_model import LitellmModel
 
# Use Claude instead of GPT
claude_agent = Agent(
    name="Claude Reviewer",
    instructions="You review code for quality and security issues.",
    model=LitellmModel(model="anthropic/claude-sonnet-4-6"),
)
 
# Use Gemini
gemini_agent = Agent(
    name="Gemini Analyst",
    instructions="You analyze data and produce summaries.",
    model=LitellmModel(model="gemini/gemini-2.0-flash"),
)
 
# Mix models in a multi-agent system
coordinator = Agent(
    name="Coordinator",
    instructions="Coordinate between specialists.",
    model="gpt-4o",  # OpenAI for coordination
    handoffs=[handoff(claude_agent), handoff(gemini_agent)],
)

This is a significant feature: you can benchmark different models against each other within the same agent structure, or use cheaper/faster models for simple tasks and more capable ones for complex reasoning.

A Complete Production Pattern: Support Ticket Classifier

Putting it together — a real pattern you'd build in a production app:

from agents import Agent, Runner, function_tool, handoff
from pydantic import BaseModel
import asyncio
 
class TicketClassification(BaseModel):
    category: str  # "billing" | "technical" | "general" | "refund"
    priority: str  # "critical" | "high" | "normal" | "low"
    summary: str
    requires_human: bool
 
@function_tool
def get_account_info(account_id: str) -> str:
    """Look up account details by ID."""
    # In production: query your database
    return f"Account {account_id}: Pro plan, active since 2024-01, no open incidents"
 
@function_tool
def create_ticket(category: str, priority: str, summary: str, account_id: str) -> str:
    """Create a support ticket in the ticketing system."""
    ticket_id = f"TICK-{hash(summary) % 10000:04d}"
    return f"Ticket {ticket_id} created: {category} / {priority}"
 
# Specialists
billing_agent = Agent(
    name="Billing Specialist",
    instructions="""You handle billing questions, refunds, and subscription changes.
    You have access to account information.
    Always look up the account before responding.""",
    tools=[get_account_info, create_ticket],
)
 
technical_agent = Agent(
    name="Technical Specialist",
    instructions="""You handle technical issues and bugs.
    Collect: error messages, steps to reproduce, environment details.
    Create a ticket for anything requiring engineering investigation.""",
    tools=[create_ticket],
)
 
# Triage coordinator
triage_agent = Agent(
    name="Triage Agent",
    instructions="""You classify incoming support requests and route them.
    First classify the request (category, priority, summary).
    Then route to the appropriate specialist.
    For critical issues, set requires_human=True.""",
    output_type=TicketClassification,
    handoffs=[
        handoff(billing_agent, tool_description="Route billing and payment questions"),
        handoff(technical_agent, tool_description="Route technical issues and bugs"),
    ],
)
 
async def handle_support_request(account_id: str, message: str):
    result = await Runner.run(
        triage_agent,
        f"Account ID: {account_id}\nRequest: {message}",
    )
 
    if isinstance(result.final_output, TicketClassification):
        classification = result.final_output
        if classification.requires_human:
            print(f"🔴 Escalating to human: {classification.summary}")
        else:
            print(f"✅ Handled automatically: {classification.summary}")
    else:
        print(result.final_output)
 
asyncio.run(handle_support_request("ACC-12345", "I was charged twice this month"))

SDK vs LangGraph vs CrewAI

OpenAI Agents SDKLangGraphCrewAI
Abstraction levelMediumLow (graphs)High (roles/crews)
Setup complexityLowHighLow
ControlHighMaximumMedium
Multi-agent modelHandoffsGraph edgesCrews
Python version3.10+3.9+3.10+
Non-OpenAI modelsVia LiteLLMYes (native)Yes (via LiteLLM)
TracingBuilt-inVia LangSmithLimited
Best forProduction APIsComplex stateful flowsPrototype teams

Choose the Agents SDK when you want structured handoffs and guardrails without authoring a graph. Choose LangGraph when you need explicit control over state transitions and loops. For building agents with Claude specifically, see Building Your First AI Agent with Claude.

For the security and governance layer needed to run agents in enterprise environments, see AI Agents in Production: Why 88% of Enterprise Pilots Fail.

Quick Reference

from agents import Agent, Runner, function_tool, handoff
from agents.sessions import SqliteSession
 
# Agent
agent = Agent(
    name="My Agent",
    instructions="System prompt here",
    model="gpt-4o",
    tools=[my_tool],
    handoffs=[handoff(other_agent)],
    output_type=MyPydanticModel,  # structured output
)
 
# Tool
@function_tool
def my_tool(param: str) -> str:
    """Description the model sees."""
    return "result"
 
# Run
result = Runner.run_sync(agent, "user message")
result = await Runner.run(agent, "user message")  # async
print(result.final_output)
 
# With session
session = SqliteSession("sessions.db")
result = await Runner.run(agent, "message", session=session, session_id="user-123")
 
# Guardrail
from agents import GuardrailFunctionOutput, input_guardrail
 
@input_guardrail
async def my_guardrail(ctx, agent, input) -> GuardrailFunctionOutput:
    blocked = "bad word" in str(input)
    return GuardrailFunctionOutput(output_info="check", tripwire_triggered=blocked)
 
# Catch guardrail block
from agents.exceptions import GuardrailTripwireTriggered
try:
    result = await Runner.run(agent, input)
except GuardrailTripwireTriggered:
    return {"error": "blocked"}
#ai-agents#openai#python#typescript#llm
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.