guide ai-agents openai

OpenAI Agents SDK: Tools, Handoffs, Guardrails, Sessions, and Tracing

26 min read
Updated

Build production AI agents with the OpenAI Agents SDK. A practical Python guide to tools, handoffs, guardrails, sessions, human approval, and tracing.

Part of the AI Agents topic hub.

Hero image for OpenAI Agents SDK: Tools, Handoffs, Guardrails, Sessions, and Tracing
Table of Contents

OpenAI released the Agents SDK in March 2025 as a small Python framework with a few primitives: agents, tools, handoffs, guardrails, and tracing.

The small core survived. Almost everything around it grew.

The current SDK uses the Responses API by default for OpenAI models. It has built-in sessions, human approval, MCP, hosted tools, local shell and computer tools, voice agents, multiple session backends, tool search, and integrations for durable execution. The original examples in this guide used old imports and several APIs that no longer exist, so I rewrote it from scratch.

This version focuses on the parts you need to understand to build a real agent:

  • What the SDK handles for you

  • How the agent loop works

  • How to design tools

  • When to use a handoff or an agent as a tool

  • Where guardrails actually run

  • How sessions differ from local context

  • How tracing helps you debug the whole mess

We will build one coherent customer-support system as we go instead of introducing a new toy example every 400 words.

Updated for August 2026: All examples now use the current openai-agents Python package and agents imports. I added sessions, tool guardrails, human approval, MCP, hosted tools, the Responses API distinction, and current handoff behavior.

What Is the OpenAI Agents SDK?

The OpenAI Agents SDK is a Python runtime for agentic applications.

You give an Agent instructions, tools, and optional handoffs or guardrails. You pass that agent and some input to Runner. The runner calls the model, executes requested tools, sends results back to the model, processes handoffs, and continues until it reaches a final output or a stopping condition.

That last sentence is the product.

Calling a model is easy. The awkward code begins after the first response:

  • Did the model request a tool?

  • Are the arguments valid?

  • Can the tool run in parallel with another call?

  • Should a human approve it first?

  • How does the result return to the model?

  • Did the model hand the conversation to a specialist?

  • Which messages should the specialist see?

  • Where do you store history for the next user turn?

  • How do you inspect the run when the final answer is wrong?

The Agents SDK owns that runtime loop while leaving your business logic in ordinary Python.

Agents SDK or Responses API?

The SDK uses the Responses API underneath for OpenAI models, so these are different layers rather than competing products.

Use the Responses API directly when you want to own tool dispatch and state yourself, or when the flow is short and mostly returns one model response.

Use the Agents SDK when you want the runtime to manage several turns, tool execution, handoffs, guardrails, sessions, approvals, and traces.

I would begin with the Responses API for a single structured generation or a small tool loop you need to control precisely. I would reach for the Agents SDK when the orchestration code starts becoming a framework of its own.

If you want a broader mental model before touching code, read my guide to designing AI agents. This article stays close to the SDK.

Installing the SDK

Create a virtual environment and install the package:

python -m venv .venv
source .venv/bin/activate
pip install openai-agents

Set your API key in the shell:

export OPENAI_API_KEY="your_api_key_here"

Your first agent needs very little code:

from agents import Agent, Runner

agent = Agent(
    name="Support assistant",
    instructions="Answer product questions clearly and concisely.",
)

result = Runner.run_sync(
    agent,
    "How do I change the email address on my account?",
)

print(result.final_output)

The import is from agents, even though the package you install is called openai-agents. This has confused enough people that it is worth stating plainly.

For an async application, use await Runner.run(...). For streaming, use Runner.run_streamed(...) and consume the emitted events.

The Agent Loop

An Agent is a configuration object. It combines a model with instructions, tools, handoffs, guardrails, structured output, and runtime hooks.

Runner supplies the loop:

  1. Call the model with the current agent and input.
  2. Inspect the model output.
  3. If it is final output, stop.
  4. If it contains tool calls, execute them, append the results, and call the model again.
  5. If it requests a handoff, switch to the chosen agent and continue.

The loop sounds trivial until you implement retries, streaming, validation, state, approvals, and traces. Let the SDK handle it unless your application needs unusual control.

The Smallest Useful Agent

Give each agent a concrete job and define what success looks like.

from agents import Agent

faq_agent = Agent(
    name="FAQ agent",
    model="gpt-5.6",
    instructions="""
    Answer questions using the product documentation available to you.

    Rules:
    - Say when the documentation does not answer the question.
    - Do not invent account details, policies, or refund eligibility.
    - Keep answers concise and include the next action when one is required.
    """,
    handoff_description="Answers product and policy questions that need no account changes.",
)

The name helps traces and handoffs. The instructions set behavior. The handoff description helps another agent decide when this specialist is relevant.

The model is optional because the SDK can use a configured default. I prefer specifying it in production so a future default change does not silently alter cost or behavior.

Structured Output

Pass a Pydantic model as output_type when your application needs fields instead of prose.

from pydantic import BaseModel
from agents import Agent

class TicketSummary(BaseModel):
    category: str
    urgency: str
    summary: str
    needs_human: bool

triage_summary_agent = Agent(
    name="Ticket summarizer",
    instructions="Classify the support request for the operations dashboard.",
    output_type=TicketSummary,
)

Structured output gives you a validated application boundary. It does not prove the values are correct. You still need evals for classification quality and deterministic validation for fields with business consequences.

Tools: How Agents Take Action

Tools turn a model response into a system that can read data and change the world.

The SDK supports several categories:

  • Function tools wrap Python functions you run.

  • Hosted tools include web search, file search, code interpreter, hosted MCP, and image generation.

  • Local runtime tools can provide shell, patch, and computer-use capabilities in an environment you control.

  • Agents as tools let a manager call a specialist and receive its result.

  • Tool search lets the model load a relevant tool group only when needed.

The most important category is still the plain function tool because that is how your agent reaches your application.

Function Tools

The @function_tool decorator creates a schema from the function signature and uses the docstring as the tool description.

from agents import function_tool

@function_tool
def get_order_status(order_id: str) -> str:
    """Return the current status and estimated delivery date for an order.

    Args:
        order_id: The public order identifier, such as ORD-12345.
    """
    order = orders.get(order_id)

    if order is None:
        return "Order not found."

    return (
        f"Order {order_id} is {order.status}. "
        f"Estimated delivery: {order.estimated_delivery}."
    )

Then attach it to an agent:

order_agent = Agent(
    name="Order agent",
    instructions="Help customers with order status and delivery questions.",
    tools=[get_order_status],
)

The model sees the name, description, and JSON schema. It decides when to call the tool and supplies the arguments. The runner validates those arguments and gives the return value back to the model.

Tool Design Matters More Than the Decorator

A tool is an API designed for a probabilistic caller. Make it hard to misunderstand.

Use a precise name. get_order_status is better than orders.

Keep the job narrow. A tool called manage_customer_account hides too many possible side effects behind one vague description.

Use typed parameters with constrained values where possible. The model performs better when the schema rules out nonsense.

Return compact, useful data. Dumping a 500 KB API response into the loop consumes context and makes the next decision harder.

Separate reading from writing. A read-only get_refund_eligibility tool and a side-effecting issue_refund tool can have different approvals, guardrails, and audit policies.

Handle expected errors as useful tool output. A missing order is normal. A database connection failure belongs in your monitoring and retry path.

Add idempotency to side-effecting tools. An agent loop may retry, a network call may time out after succeeding, and a user may repeat a request. Charging a card or issuing a refund twice is a business bug regardless of how intelligent the model is.

Hosted Tools

OpenAI-hosted tools are useful when you want OpenAI to run the capability as part of the model request.

from agents import Agent, Runner, WebSearchTool

policy_agent = Agent(
    name="Policy researcher",
    instructions="Find current public policy information and cite the sources used.",
    tools=[WebSearchTool()],
)

result = Runner.run_sync(
    policy_agent,
    "Find the current carrier policy for packages marked delivered but missing.",
)

File search works with OpenAI vector stores. Code interpreter runs code in a managed environment. Hosted MCP connects remote MCP tools. Tool search can defer a large tool surface so the model loads only a relevant namespace instead of paying the context cost for every schema on every turn.

If your agent has dozens of CRM, billing, shipping, and analytics tools, group them by domain. A model choosing among four meaningful namespaces has a much easier job than choosing among 80 flat function names.

Local Tools and Sandboxes

Shell, patch, and computer tools need an execution environment. That environment is part of your security design.

Do not attach a powerful shell to the same user account that holds production credentials. Give the agent an isolated workspace, narrow network access, explicit secrets, and a recovery path. The intelligence of the model does not shrink the blast radius of a bad command.

For coding and document agents, the SDK also supports sandbox-oriented agents that work inside isolated workspaces. Use that route when files and execution state are central to the job.

MCP

Model Context Protocol gives your agent a standard interface to external tool servers. The SDK can connect to remote MCP servers and present their tools alongside function tools.

MCP is valuable when the capability already exists behind a server or needs to be shared across several agent products. A local Python function is simpler when the tool belongs only to this application.

Keep the exposed surface focused either way. More tools create more ambiguity, more schema tokens, and more actions to secure.

Handoffs Versus Agents as Tools

This is the most important orchestration choice in the SDK.

Both patterns involve multiple agents. They create different user experiences and control flows.

Handoffs Transfer the Conversation

A handoff changes which agent owns the run.

from agents import Agent

refund_agent = Agent(
    name="Refund agent",
    instructions="Handle refund requests. Verify eligibility before taking action.",
    handoff_description="Handles refund eligibility and approved refund requests.",
)

order_agent = Agent(
    name="Order agent",
    instructions="Handle shipment status, tracking, and delivery questions.",
    handoff_description="Handles order status and delivery issues.",
)

triage_agent = Agent(
    name="Support triage",
    instructions="Route each request to the appropriate specialist.",
    handoffs=[refund_agent, order_agent],
)

The SDK exposes each handoff as a tool such as transfer_to_refund_agent. If triage chooses one, the specialist becomes the active agent and normally receives the conversation history.

Use handoffs when the specialist should talk directly to the user and own the next part of the interaction. Customer-support routing and language routing are natural examples.

Agents as Tools Return to the Manager

An agent can also be exposed as a tool:

research_agent = Agent(
    name="Policy researcher",
    instructions="Research the relevant policy and return evidence with citations.",
)

manager_agent = Agent(
    name="Support manager",
    instructions="Answer the user. Consult the policy researcher when needed.",
    tools=[
        research_agent.as_tool(
            tool_name="research_policy",
            tool_description="Research a policy question and return sourced findings.",
        )
    ],
)

The manager calls the specialist, receives its result, and keeps control of the conversation.

Use this when one agent should own the final answer, combine several specialists, or enforce a consistent user-facing voice.

My rule:

  • Handoff when responsibility moves.

  • Agent as a tool when expertise is borrowed.

Do not create a multi-agent system because the diagram looks impressive. A single agent with three good tools is often easier to evaluate, cheaper to run, and more reliable than four agents passing paragraphs to one another.

Customizing a Handoff

Use the handoff() helper when you need metadata, a callback, or control over the history sent to the next agent.

from pydantic import BaseModel
from agents import Agent, RunContextWrapper, handoff

class EscalationData(BaseModel):
    reason: str
    priority: str

async def log_escalation(
    ctx: RunContextWrapper[None],
    data: EscalationData,
):
    print(f"Escalation: {data.priority} - {data.reason}")

human_escalation_agent = Agent(
    name="Human escalation",
    instructions="Collect the details required for a human support agent.",
)

escalation = handoff(
    agent=human_escalation_agent,
    on_handoff=log_escalation,
    input_type=EscalationData,
)

input_type describes metadata generated for the handoff call. Application dependencies and trusted state belong in the run context. If the receiving agent should see a filtered conversation, use an input filter.

This distinction prevents a subtle mistake: model-generated handoff metadata should never become the source of truth for authorization or account state.

Context and Sessions Solve Different Problems

The SDK uses the word context for local Python objects available during a run. Sessions store conversation history across runs.

They are easy to confuse.

Run Context

Run context carries trusted dependencies and application state that your code needs, such as a database connection, authenticated user ID, feature flags, or service clients.

from dataclasses import dataclass
from agents import RunContextWrapper, function_tool

@dataclass
class SupportContext:
    user_id: str
    account_tier: str

@function_tool
def get_account_plan(ctx: RunContextWrapper[SupportContext]) -> str:
    """Return the authenticated customer's current account plan."""
    return f"User {ctx.context.user_id} is on {ctx.context.account_tier}."

The model does not see the context object automatically. Your Python tools and callbacks can use it. This is exactly where trusted identity should live.

Never ask the model to supply its own user_id for a sensitive operation when your application already authenticated the user.

Sessions

Sessions let the SDK retrieve and save conversation history between calls to Runner.

from agents import Agent, Runner, SQLiteSession

agent = Agent(
    name="Support assistant",
    instructions="Help the customer with their account.",
)

session = SQLiteSession("support_user_42")

first = await Runner.run(
    agent,
    "My order has not arrived.",
    session=session,
)

second = await Runner.run(
    agent,
    "It was supposed to arrive yesterday.",
    session=session,
)

The second run receives the stored history automatically. You do not have to convert the previous result into a new input list yourself.

SQLite is convenient for local development. The SDK also supports other session backends and custom implementations for production infrastructure.

Choose One History Strategy

Sessions are client-side history managed by the SDK. The Responses API can also continue server-side using a previous response or conversation identifier.

Do not layer both strategies onto the same run. Choose where the history lives and make that ownership obvious.

Conversation history will grow. Decide how much to retrieve, when to summarize or compact, what to delete, and how users can correct stale information. A memory system that stores everything eventually becomes an expensive archive of contradictions.

Guardrails: Where They Run Matters

Guardrails validate input, final output, or individual function-tool calls. The timing is as important as the check.

Input Guardrails

Input guardrails inspect the initial input to the first agent in a chain. They can run in parallel with the agent for lower latency or block execution until the check finishes.

Parallel mode is the default. The main agent may already consume tokens or call a tool before a slow guardrail trips. Use blocking mode for a check that must happen before any side effect.

Output Guardrails

Output guardrails inspect the final output from the final agent. They are useful for schema-level or policy checks before a response leaves your application.

They do not inspect every intermediate agent response in a handoff chain.

Tool Guardrails

Tool guardrails wrap custom function tools. Input checks run before execution, and output checks run after execution.

This is the right boundary for many business rules because the tool is where a proposed action becomes a real action.

import json
from agents import ToolGuardrailFunctionOutput
from agents.decorators import tool, tool_input_guardrail

@tool_input_guardrail
def validate_refund(data):
    args = json.loads(data.context.tool_arguments or "{}")
    amount = float(args.get("amount", 0))

    if amount > 500:
        return ToolGuardrailFunctionOutput.reject_content(
            "Refunds above $500 require human review."
        )

    return ToolGuardrailFunctionOutput.allow()

@tool(tool_input_guardrails=[validate_refund])
def issue_refund(order_id: str, amount: float) -> str:
    """Issue an approved refund for an order."""
    return refunds.issue(order_id=order_id, amount=amount)

The example is intentionally simple. A real refund check should use trusted order data, authenticated identity, idempotency, and an audit log. The model-provided amount is a request, not proof that the customer paid it.

Tool guardrails currently apply to decorated function tools. Handoffs, hosted tools, local execution tools, and agents exposed through as_tool() have different boundaries. Put critical controls in the underlying service as well.

Guardrails Are Application Logic

An LLM-based guardrail can classify intent or detect nuanced content. Deterministic checks should enforce amounts, permissions, required approvals, and allowed state transitions.

The best safety layer is usually boring code:

if order.user_id != authenticated_user.id:
    raise PermissionError("Order does not belong to this user")

Keep that check even if three models confidently agree the refund looks legitimate.

Human Approval and Interrupted Runs

Some tools should pause before execution. A human can inspect the proposed action, approve or reject it, and resume the same run.

This is useful for sending an email, issuing a large refund, deleting records, publishing content, or changing production infrastructure.

The important architectural detail is that approval creates an interrupted run state. Persist that state somewhere durable if the reviewer may respond later. Resume with the same session when the run also uses session memory, so the stored conversation remains consistent.

Approval should show the human enough information to make a decision: the exact action, arguments, user, evidence, and expected side effects. A button labeled “Approve tool call” is compliance theater if the reviewer cannot tell what the tool will do.

Tracing: Debug the Path, Not Only the Answer

Agent failures are rarely explained by the final text alone.

Perhaps the model selected the wrong tool. Perhaps the tool returned stale data. Perhaps a handoff sent too much history. Perhaps a specialist interpreted an internal note as a user instruction. Perhaps the right answer arrived after 14 expensive turns.

Tracing records the run structure so you can inspect model calls, tool calls, handoffs, guardrails, and timing.

Tracing is enabled by default for OpenAI-backed runs. Give production workflows meaningful names and attach metadata that helps you group related runs without placing sensitive data in the trace.

from agents import Agent, RunConfig, Runner

result = await Runner.run(
    agent,
    "Where is my order?",
    run_config=RunConfig(
        workflow_name="customer_support",
        group_id="ticket_1842",
        trace_metadata={"channel": "web"},
    ),
)

Review traces during development, then turn recurring failures into eval cases. A trace explains one run. An eval tells you whether a change improved the system across a representative set.

Be careful with sensitive data. Tool arguments and outputs can contain customer details, internal documents, or credentials. Configure trace inclusion deliberately and redact at the source when possible.

A Complete Support Agent

Here is the shape of the system we have built:

import asyncio
from dataclasses import dataclass

from agents import Agent, Runner, RunContextWrapper, SQLiteSession, function_tool


@dataclass
class SupportContext:
    user_id: str


@function_tool
def get_order_status(
    ctx: RunContextWrapper[SupportContext],
    order_id: str,
) -> str:
    """Return status for an order owned by the authenticated customer."""
    order = orders.find(order_id)

    if order is None or order.user_id != ctx.context.user_id:
        return "Order not found."

    return f"{order.status}; estimated delivery {order.estimated_delivery}"


order_agent = Agent[SupportContext](
    name="Order agent",
    instructions="Help with tracking and delivery questions. Use the order tool.",
    tools=[get_order_status],
    handoff_description="Handles tracking, delivery, and missing-order questions.",
)

refund_agent = Agent[SupportContext](
    name="Refund agent",
    instructions="Explain refund eligibility and collect required details.",
    handoff_description="Handles refund and return requests.",
)

triage_agent = Agent[SupportContext](
    name="Support triage",
    instructions="Route the customer to the right specialist.",
    handoffs=[order_agent, refund_agent],
)


async def main():
    context = SupportContext(user_id="user_42")
    session = SQLiteSession("support_user_42")

    result = await Runner.run(
        triage_agent,
        "Where is order ORD-12345?",
        context=context,
        session=session,
    )

    print(result.final_output)
    print(f"Answered by: {result.last_agent.name}")


if __name__ == "__main__":
    asyncio.run(main())

This is intentionally incomplete. The refund agent still needs tools, approvals, and service-level checks. The application needs error handling, authentication, logging, rate limits, session lifecycle rules, and evals.

That is the point. The SDK removes orchestration boilerplate. It does not remove software engineering.

Production Checklist

Before shipping an Agents SDK application, I would check the following.

Tools

  • Each tool has one clear job and a precise schema.

  • Side effects are idempotent where possible.

  • Authorization is enforced in code using trusted identity.

  • Timeouts and expected failures produce useful behavior.

  • High-impact actions require appropriate approval.

Orchestration

  • Every handoff has a distinct destination and clear description.

  • Specialists receive only the history they need.

  • A single agent owns the user experience when several specialists contribute.

  • Multi-agent complexity is justified by measured performance.

State

  • Run context holds trusted dependencies and identity.

  • The application has one clear conversation-history strategy.

  • Sessions have retention, deletion, and compaction rules.

  • Interrupted runs can be stored and resumed safely.

Safety and Observability

  • Deterministic business rules live in services and tools.

  • Guardrails sit at the boundary they are meant to protect.

  • Traces avoid or redact sensitive data.

  • Representative evals cover routing, tool choice, task completion, and failures.

  • Cost, latency, token use, and intervention rates are monitored.

Is the OpenAI Agents SDK Worth Using?

The SDK is a strong choice when you want Python-first orchestration around the Responses API and you need tools, handoffs, sessions, guardrails, approvals, and traces.

Its best quality is still the small mental model. Agent, Runner, tool, handoff, guardrail, session. You can understand the core in an afternoon and add advanced pieces when the application requires them.

The danger is confusing available features with required architecture. You probably do not need six agents, three memory backends, 70 MCP tools, and a realtime voice layer for version one.

Start with one agent and one real tool. Trace it. Build ten evaluation cases from actual user requests. Add a specialist when the single agent repeatedly struggles with a distinct job. Add a handoff when that specialist should own the conversation. Add a session when the user needs continuity across turns. Add approval before an action can create material consequences.

That path produces a system you can understand. And with agents, understanding the system is still most of the battle.

Related Posts

Read The Ultimate Guide to Claude Cowork: Create Your Personal AI Assistant
Hero image for The Ultimate Guide to Claude Cowork: Create Your Personal AI Assistant
guide claude ai-agents

The Ultimate Guide to Claude Cowork: Create Your Personal AI Assistant

Learn how to turn Claude Cowork into a personal AI assistant that organizes your files, drafts documents, schedules recurring tasks, and connects to your tools. The complete guide, no coding required.

34 min
Read Claude Managed Agents: Anthropic Now Runs Your Agents For You
Hero image for Claude Managed Agents: Anthropic Now Runs Your Agents For You
guide ai-agents claude

Claude Managed Agents: Anthropic Now Runs Your Agents For You

Anthropic just launched Managed Agents, letting you spin up autonomous Claude agents in their cloud with containers, tools, and multi-agent orchestration built in. Here's how it works and how to get started.

13 min
Read The Anatomy of Claude Code And How To Build Agent Harnesses
Hero image for The Anatomy of Claude Code And How To Build Agent Harnesses
guide claude ai-agents

The Anatomy of Claude Code And How To Build Agent Harnesses

The source code for Claude Code leaked. In this post, we explore how it actually works, from the moment you type a message to the moment it delivers working code.

38 min