Agent, Runner, and the first run
You have written the agent loop by hand, so the OpenAI Agents SDK will not feel like magic. It is the same loop with names on the parts. An Agent is the configuration: a name, instructions, the tools it may call, the agents it may hand off to, and optionally the type of its final answer. A Runner is the loop: it takes an agent and an input, calls the model, runs tools, and returns a result when the model produces a final answer. Nothing you learned in module nine is wasted; the SDK just stops you rewriting it per project.
The package is openai-agents and it imports as agents. It talks to OpenAI models by default, so it needs OPENAI_API_KEY in the environment.
uv add openai-agents
export OPENAI_API_KEY=sk-...
The first run
from agents import Agent, Runner
relay = Agent(
name="Relay",
instructions=(
"You are Relay, the support assistant for a small SaaS. "
"Answer briefly. Ask one clarifying question when the request "
"is ambiguous. Never promise refunds or credits."
),
)
result = Runner.run_sync(relay, "How do I change my billing email?")
print(result.final_output)
Run it with uv run main.py and you get a sentence back. Runner.run_sync is the blocking entry point for scripts and tests. Inside a FastAPI route, or anywhere an event loop is already running, use the async form: result = await Runner.run(relay, message). Both return the same result object, and final_output is the model's final text, or a typed object if you asked for one.
There are no tools yet, so this run is one model call. It is still worth doing once, because everything you add in the next lessons changes what happens inside the runner, not how you call it.
What the Runner does for you
Map it onto lesson 09.01 and every line of the hand-written loop has a home.
| Hand-written loop | Agents SDK |
|---|---|
The for loop with max_turns |
Runner.run with max_turns= (default 10) |
TOOLS list and run_tool dispatcher |
tools=[...] on the Agent; the schema is derived from the function |
| Appending assistant and tool messages | Done inside the runner; the result exposes them |
stop_reason != "tool_use" |
The runner detects a final answer, or a handoff, or a tool call |
| Returning text | result.final_output |
| Session save and load | session= (lesson 10.04) |
The one thing to internalise is that an Agent is data. Creating one makes no network call. You can build agents at import time, keep them in a module, and pass the same instance to thousands of runs. Per-request state, such as which customer is talking, does not belong on the agent; it goes into the run.
The Agent constructor's main parameters:
| Parameter | What it is |
|---|---|
name |
A label used in traces and handoffs |
instructions |
The system prompt |
tools |
Function tools and hosted tools (lesson 10.02) |
handoffs |
Other agents this one may transfer to (lesson 10.03) |
output_type |
A Pydantic model the final answer must match |
Typed output with output_type
Relay's first real use of the SDK is not chat; it is classification. Every incoming message gets a category and an urgency before anything else happens, and a typed output makes that a function call with a schema rather than a string to parse.
from typing import Literal
from pydantic import BaseModel
from agents import Agent, Runner
class Triage(BaseModel):
category: Literal["billing", "technical", "account", "other"]
urgency: Literal["low", "normal", "high"]
summary: str
triage = Agent(
name="Triage",
instructions="Classify the customer's message. The summary is one sentence.",
output_type=Triage,
)
result = Runner.run_sync(triage, "I was charged twice this month and need it fixed today")
t: Triage = result.final_output
print(t.category, t.urgency) # billing high
final_output is now a Triage instance, validated before it reaches you. If the model produces something that does not fit the schema, the runner surfaces that as an error rather than handing you a half-parsed dict. This is structured output from lesson 08.05 wired into the loop, and it is the pattern for any agent whose answer is consumed by code rather than read by a person.
Engineering note. Decide per agent whether its answer is for a person or for code, and give every code-facing agent an
output_type. Free text that "usually" contains a category is the most common source of silent breakage in agent systems, and it is avoidable at the cost of one Pydantic model.
Where it goes wrong
Runner.run_sync inside FastAPI. An event loop is already running, and run_sync will refuse or block it. Use await Runner.run(...) in async code, always.
Per-request state on the agent. Building a new Agent per request with the customer's details baked into instructions works, but it makes the agent impossible to cache, trace, or test in isolation. Keep the agent static and pass per-run context to the runner.
Missing API key. The first run fails with an authentication error. Set OPENAI_API_KEY or load it through pydantic-settings from .env as in lesson 04.04.
Instructions that describe the company, not the job. Three paragraphs about the product and one line about what to do produce chatty, unfocused answers. Lead with the behaviour: what to answer, what to ask, what never to promise.
Treating max_turns as a limit that will not be hit. It will, and when it is the runner raises. Catch it in the route and return a graceful message, exactly as in lesson 09.01.
Try it
Install the SDK in your Relay project, write the triage agent above, and run it against ten support messages you make up, printing category and urgency for each. Then change urgency to Literal["low", "high"] only, rerun, and note how the messages you had marked normal are reassigned. That is a live demonstration of the schema shaping the answer.
Next: function tools and hosted tools, where lookup_customer, search_help_articles, and escalate_ticket become @tool functions and the runner starts calling them.
Continue with the complete track
Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.
Unlock the complete track