30 sec3 min coredeep dive optional
The OpenAI Agents SDK turns a production-shaped agent into about six lines of Python, and version 0.22, released August 19, 2026, hardens the runtime bits you will care about in week one: guardrail redaction, error surfacing, and a cleaner provider config contract.
- Install is pip install openai-agents on Python 3.10 or newer; a first agent is one Agent plus one Runner.run_sync call.
- Nine releases landed between v0.19.0 (July 27) and v0.22.0 (August 19), roughly one every three days; the default model is gpt-5.6-luna since v0.20.0.
- Guardrails come in three kinds with precise boundaries, handoffs surface as real tools named transfer_to_something, and sessions make chat stateful in one keyword argument.
- Tracing ships on by default with a traces dashboard, and MCP support covers five integration shapes with both MCP Python SDK v1 and v2 working since v0.20.
The OpenAI Agents SDK spent August quietly becoming the least ceremonial way to build an agent in Python. Where most frameworks ask you to compose graphs or configure runtimes, the v0.22.0 release on August 19, 2026 represents a different bet: the primitives you need in production, agents, handoffs, guardrails, sessions, and tracing, shipped as plain Python objects with sensible defaults. This tutorial builds your first one and flags what 0.22 changed under the hood.
Six lines from pip install to a running agent
The SDK needs Python 3.10 or newer and one install command. Authentication is the standard OPENAI_API_KEY environment variable; nothing else is required for a first run.
pip install openai-agents
export OPENAI_API_KEY=sk-...from agents import Agent, Runner
agent = Agent(
name="Support triage",
instructions="Classify the user's request and answer concisely.",
)
result = Runner.run_sync(agent, "My invoice was charged twice")
print(result.final_output)That is a complete agent. The Agent object carries a name, instructions, and, as you grow it, tools, handoffs, guardrails, and an output schema. The Runner exposes three entry points over the same signature, and which one you pick is a concurrency decision, not a code-shape decision.
| Entry point | Returns | Use when |
|---|---|---|
| Runner.run() | RunResult (awaitable) | Async application code |
| Runner.run_sync() | RunResult | Scripts, notebooks, quick tests |
| Runner.run_streamed() | RunResultStreaming | Chat UIs that stream tokens |
One default to know upfront: since v0.20.0 the SDK’s default model is gpt-5.6-luna. An explicit model on the Agent, a run-level override, or the OPENAI_DEFAULT_MODEL environment variable all win over the default, which matters more than usual in a month where the release train moved that fast.
What the agent loop actually does
Underneath the one-liner sits a loop you should be able to reason about. The Runner calls the model, then classifies what came back. A handoff switches the current agent and loops again. Tool calls execute and loop again. Plain content ends the run and lands in result.final_output. If the loop keeps going past the max_turns limit, the run raises MaxTurnsExceeded instead of spinning, and passing max_turns=None removes the cap for long-running workflows.
The loop is also where runs can pause. A run that requests tool approval can stop, hold its state, and resume from the same RunState when a human decides, which is the difference between a demo and something you can put near production systems. Result objects carry the full trail: new_items for every step, raw_responses for the model traffic, and typed input and output lists for assertions in tests.
Tools: hosted, local, and your own Python
The production toolbelt is included rather than bolted on. Hosted tools run server-side and arrive as classes: WebSearchTool, FileSearchTool, CodeInterpreterTool, ImageGenerationTool, and ToolSearchTool, which defers tool loading until the model actually requests it. Your own code becomes a tool through a decorator, and since v0.19 the friendlier alias @tool works anywhere @function_tool does.
from agents import Agent, Runner, SQLiteSession, WebSearchTool, function_tool
@function_tool
def refund_amount(order_id: str) -> str:
"""Look up the refund recorded for an order."""
return lookup_refund(order_id)
agent = Agent(
name="Support agent",
instructions="Answer using your tools and say which you used.",
tools=[WebSearchTool, refund_amount],
)
session = SQLiteSession("customer-42")
result = Runner.run_sync(agent, "Where is my refund?", session=session)
print(result.final_output)The decorator generates the JSON schema from the type hints and the docstring, so the model sees a well-formed tool definition without you writing one. Agents can also be exposed as tools themselves, the manager pattern, which differs from a handoff in that the outer agent keeps control and reads the inner agent’s output instead of transferring the conversation.
Handoffs without a framework
A handoff lets one agent pass the conversation to another, and the SDK models it the way models already think about tools. Put Agent instances in the handoffs parameter and the model receives a callable named transfer_to_ plus the agent’s name, underscores included. A handoff to an agent named Billing agent shows up as transfer_to_billing_agent.
The handoff() constructor is where the control lives: override the tool name or description, attach an on_handoff callback that runs at the moment of transfer, declare an input_type so the handoff carries structured data, filter what history the next agent sees with input_filter, or gate the whole thing behind is_enabled, as a static flag or a callable evaluated per run. Plain Agent entries can also carry a handoff_description, the routing hint the model reads when deciding who should take the call. Between that and the transfer naming, routing logic becomes reviewable configuration instead of prompt folklore.
Guardrails and sessions, the production rails
Guardrails split into three kinds with deliberately narrow boundaries, and knowing the boundaries is what makes them useful.
| Kind | Runs on | Typical job |
|---|---|---|
| Input guardrail | The first agent only | Block off-topic or unsafe requests before spend |
| Output guardrail | The final-output agent only | Validate the answer before the caller sees it |
| Tool guardrail | Every function-tool call | Vet arguments in and results out, per call |
A guardrail raises a tripwire when its check fails, which aborts the run with a typed exception the caller can catch. Input guardrails run in parallel with the model call by default, trading a little token spend for latency; flip them to blocking when the check must gate the call. Sessions are the other rail: pass session=SQLiteSession(“id”) and history persists automatically, with Redis, SQLAlchemy, MongoDB, and encrypted backends available as extras. One integration rule to remember: sessions cannot combine with conversation_id or previous_response_id in the same run, because the two mechanisms would fight over whose history is authoritative.
Tracing ties it together and is on by default with zero setup: every run produces a trace with a workflow name and an optional group_id for linking one conversation’s traces, viewable in the dashboard. Disable it three ways if you must, an environment variable, set_tracing_disabled(True), or per-run config, and note that orgs under Zero Data Retention policy cannot use it at all.
What 0.22 changed and why it matters
Version 0.22.0 is a hardening release, and its changes read like a list of production incident reports. Tool outputs rejected by output guardrails are now redacted from replayable and persisted state, so a blocked payload cannot leak back out of a resumed run. Non-streaming Responses that end in a failed or incomplete status raise ModelBehaviorError instead of silently passing through, which converts a class of quiet failures into catchable ones. Usage accounting is now isolated between independent run states, so a resumed run no longer blends token counts with its ancestor.
The one breaking change is a config contract tightening: if you pass an explicit openai_client, setting organization or project alongside it is now rejected. Those values belong on the AsyncOpenAI client itself.
from openai import AsyncOpenAI
from agents import set_default_openai_client
client = AsyncOpenAI(organization="org-example", project="proj_example")
set_default_openai_client(client)The surrounding month matters as much as the release: nine shipped versions since July 27, including offline testing utilities in v0.21 and MCP Python SDK v2 support in v0.20. A cadence that fast rewards pinning. Pin the version you tested against, read the release notes between pins, and treat the default model like any other implicit dependency: name it explicitly in code, even when it matches.
Why did the default model change to gpt-5.6-luna, and what should you do about it?
The switch landed in v0.20.0 on August 11 and is best read as the SDK keeping pace with OpenAI’s model lineup rather than as a judgment that luna is universally best. A framework default is a convenience for newcomers; it is not a recommendation for your workload, and the maintainers left three override paths precisely so teams can encode their own choice: set model on the Agent for per-agent routing, override at run level for per-invocation control, or export OPENAI_DEFAULT_MODEL for an environment-wide answer. In priority order, explicit agent model beats run override beats environment variable beats the built-in default.
The production habit to build is pinning by name. An unpinned agent silently changes behavior the day the default changes again, and with nine releases in under four weeks, defaults here are moving parts, not foundations. Naming the model in code costs nothing, makes behavior reviews honest, and keeps your evaluation results comparable across weeks. The changelog context supports the same caution: GPT-5.6 Sol saw promotional price cuts in August, which is exactly the kind of economics that makes one model the right answer this month and a different one next month.
There is a subtler benefit to explicitness: reproducible traces. When you compare a trace from Tuesday against a trace from Friday, the only interesting variable should be your change, not a quiet model swap inside the SDK. Teams that pin models and versions can diff traces meaningfully; teams that ride defaults are diffing two experiments at once and calling it regression testing.
If you are migrating from the Assistants API, which shut down August 26, this habit matters double. The Agents SDK builds on the Responses API by default, and your old deployments never asked you to name a model because the assistant configuration held it. Moving that decision into your codebase is part of the migration’s cleanup, and doing it on day one is cheaper than archaeology later.
How do the three guardrail kinds divide responsibility without overlap?
The boundaries follow the lifecycle of a request. An input guardrail runs once, on the first agent, before the conversation earns any model spend; it is the cheapest place to stop something wrong. An output guardrail runs once, on the agent that produces the final answer; it is the last place to catch something harmful before a user sees it. Tool guardrails wrap every function-tool call, checking arguments before execution and results after, which is the right scope for policy that is about a specific capability rather than the whole conversation.
Each guardrail returns a GuardrailFunctionOutput carrying a tripwire_triggered flag, and a raised tripwire aborts the run with a typed exception, InputGuardrailTripwireTriggered for the input kind. That typing matters operationally: your API layer can distinguish “the request was refused by policy” from “the model failed” and return different responses to the caller. Version 0.22 tightened the story further by redacting guardrail-blocked tool outputs from replay and persisted state, closing a path where blocked content could survive inside a resumed run’s state.
The parallel-versus-blocking decision is the main performance lever. Input guardrails run in parallel with the model call by default, which means near-zero added latency and a small chance you pay for a generation the tripwire then discards. Set run_in_parallel to False when the guardrail’s verdict must gate the call entirely, such as compliance checks where a forbidden request must never reach the model. Output guardrails are inherently blocking by position; their cost shows up as added latency before the response returns, which is why they belong on answers, not on every intermediate artifact.
A workable starting configuration for a service agent: one input guardrail for scope and safety, one output guardrail for factual formatting or refusal language, and tool guardrails only on the tools that touch money, data deletion, or external sends. Blanketing every tool is possible but teaches you nothing; the guardrails that earn their keep are the ones mapped to a named risk.
Guardrails also deserve tests, and since v0.21 the SDK ships the harness for it: the agents.testing utilities run agents deterministically with no provider requests, so a tripwire’s trigger and its typing can live in your unit suite next to everything else. A guardrail that only ever fires in production is a hope, not a control; one with a passing test that feeds it a forbidden request and asserts the typed exception is a check you can refactor against. Add a regression test whenever a tripwire fires for real, and the guardrail set compounds in usefulness the same way the rest of the codebase does.
What does MCP integration look like, and where is the version gotcha?
The SDK treats the Model Context Protocol as a first-class tool source with five shapes: a hosted option through HostedMCPTool where OpenAI runs the server side, Streamable HTTP and SSE transports for remote servers, stdio for local processes, and a manager that coordinates many MCP servers at once. Agents discover MCP tools with the protocol’s own listing flow and call them like native tools, and since v0.19.3 the listings auto-paginate, which matters once a server exposes more tools than one page holds.
The gotcha arrived with v0.20.0, which taught the SDK to work with MCP SDK v1 and v2 alike. That is good news twice over, compatibility with older servers and with new ones, with one catch: applications that customized MCP HTTP auth or supplied their own client factories must align with whichever MCP major version is installed, or stay pinned below 2 while they migrate. The package pin on PyPI, mcp at 1.19.0 or newer but under 3, reflects the same reality. Check your dependency tree before upgrading the agents package, because the SDK’s floor moved under your feet in a minor release.
The production features around MCP are the reason to prefer it over hand-rolled tool wiring: static and dynamic tool filtering to keep an agent’s tool list honest, tools-list caching to control discovery cost, configurable retry backoff so a flaky server cannot stall a run, and approval policies that put a human gate on specific MCP calls. An MCP server, for context, is exactly the piece our Model Context Protocol explainer covers from first principles; the SDK side adds the agent-half conventions on top.
For teams evaluating frameworks, this is also where the SDK’s positioning shows. A recent twelve-framework comparison rated it strongest at delegation patterns, and the MCP surface fits that read: the SDK does not try to be the broadest MCP platform, it tries to make tools, handoffs, and guardrails compose cleanly, and the MCP shapes are wired into those same primitives rather than bolted on beside them.