Lesson 07 OpenAI Agents SDK Intro
Theme:

OpenAI Agents SDK Intro

Lesson 07 · OpenAI Agents SDK · ~12 min read

1. What the OpenAI Agents SDK is — and what it isn’t

The first source of confusion is the name. The OpenAI Agents SDK is not part of the openai Python client, and it is not the older Assistants API (which was a remote, stateful service hosted by OpenAI). It is a local Python framework distributed in the openai-agents PyPI package, and it uses your model provider as a transport rather than as a runtime. pip install openai-agents pulls the regular openai client in as a dependency, but every import you write in this module comes from a top-level agents namespace — no openai. prefix.

The SDK can drive OpenAI’s own models out of the box and other providers through a LiteLLM adapter, although as of 2026 Q2 the non-OpenAI paths still have rough edges (tool-calling parity, streaming semantics, error mapping). To keep the lessons reproducible we standardise on gpt-4o-mini for every example.

A brief compass relative to the other three frameworks in this course: the Claude Agent SDK ships inside the anthropic package; LangGraph is its own package and assumes you wire in an LLM client yourself; CrewAI is its own package with stronger opinions about roles and team composition. Of the four, OpenAI’s shape is closest to the Claude Agent SDK in raw API ergonomics — but it adds two primitives the Claude SDK does not have first-class: handoffs and guardrails.

2. The three headline primitives

Almost everything you build with this SDK is some combination of three objects. Slides walk through them in a sentence each; here is a deeper look.

Agent — constructed with at minimum name=, instructions= (the system prompt), and model=. Optional but very common: tools=[], handoffs=[], input_guardrails=[], output_guardrails=[], and output_type=. The name field is not cosmetic — when this agent appears as a handoff target on another agent, its name becomes part of the synthetic tool name (transfer_to_<name>) and shows up in every trace, so pick names you’ll be happy to read in logs.

handoff(target_agent, on_handoff=..., input_filter=..., input_type=...) — the typed wrapper around a handoff target. The shorthand Agent(..., handoffs=[other]) wraps other in handoff(other) with defaults. Reach for the explicit handoff(...) form when you want an on_handoff callback (logging, audit, metrics), an input_filter (trim or redact the history before the target sees it), or a Pydantic input_type the LLM must fill in before the transfer is allowed to happen.

Guardrails — @input_guardrail runs before the model sees the user input; @output_guardrail runs after the model produces a final answer. Both return a GuardrailFunctionOutput whose tripwire_triggered: bool flag short-circuits the run with a typed exception (InputGuardrailTripwireTriggered / OutputGuardrailTripwireTriggered). The output_info field is freeform and lands in the trace for diagnostics.

3. Runner, sessions, and the final output

The Runner class is the SDK’s loop driver. You hand it the starting agent and an input, and it runs the inner think–act loop until the agent produces a final answer. Three entry points:

All three return a RunResult with the following fields: final_output (the answer), last_agent (which agent actually produced it, after any handoffs), new_items (the messages produced during this run), and raw_responses (provider-level metadata for debugging).

If you set output_type=SomePydanticModel on the agent, final_output is a typed instance rather than a string — internally the SDK injects a synthetic “final-answer” tool keyed on the Pydantic schema, validates the LLM’s arguments against it, and returns the parsed object. That’s the same final-answer-tool pattern Module 2 introduced by hand, packaged here as a constructor flag.

For multi-turn chats across Runner.run calls, pass a Session object that holds prior messages. The SDK ships SQLiteSession out of the box; production deployments often wire a Redis- or Postgres-backed session instead.

4. Handoffs in depth

The mental model: a handoff is the LLM saying “this isn’t my job — give it to the billing agent.” The SDK realises that intent in three steps:

  1. It compiles each handoffs=[…] entry into a synthetic tool on the current agent, named transfer_to_<target_name>, with the target’s instructions as the tool description so the model knows when to call it.
  2. When the LLM calls a handoff tool, the SDK records the transfer in the trace, swaps the active agent to the target, and continues the same conversation loop — the target sees the prior message history (filtered if input_filter= is set) and its own instructions / tools / handoffs.
  3. The loop exits when whichever agent is currently active produces a final_output. result.last_agent tells you which agent that was, so you can route the answer to the right channel downstream.
Handoffs are one-way by default: once you transfer to billing, the billing agent owns the run until it produces an answer. If you want billing to be able to hand back to triage, you put handoffs=[triage] on the billing agent — the SDK supports cycles. The Module 4 lab keeps it acyclic (triage → specialists) because it’s easier to reason about; the L08 homework asks you to add a one-cycle “escalation” path.

5. Guardrails as a safety belt

The SDK’s most-distinctive design decision is separating cheap pre/post checks from the main loop. Two motivations drive that choice:

The input_guardrails=[…] argument accepts a list — each guardrail runs in parallel before the model call and the first tripwire short-circuits the run. output_guardrails=[…] are similar but run after the final output (typed via output_type if you’ve set one). The SDK doesn’t ship pre-built guardrails — those are application logic by nature — but the decorator + tripwire pattern is consistent enough that you can lift one between projects unmodified.

Don’t use guardrails for things that need the model’s reasoning. A guardrail’s classifier can itself be an LLM call (the SDK supports this with @input_guardrail calling Runner.run(small_classifier_agent, …)), but that adds cost and latency. Reserve heavyweight guardrails for high-stakes verticals; for everything else a quick regex or string check is the right tool.

6. Tools, sessions, and structured outputs

A brief tour of the supporting surface area, in case you need any of these in the L07 lab or homework:

@function_tool — plays the same role as the Claude SDK’s @tool. Reads the Python type hints and the docstring, builds a JSON Schema, and registers the tool on whichever Agent includes it in tools=[]. Return values can be strings, Pydantic models, or plain dicts; the SDK serialises them for the model. Exceptions raised inside the function become is_error tool results so the LLM can retry rather than crashing the run.

Session — multi-turn state. SQLiteSession("path.db", session_id="user-42") gives you a persistent chat history without writing a database layer. Pass session= to Runner.run(...) and the SDK appends each turn automatically — you don’t need to manage a messages list yourself.

output_type= with Pydantic — preferred over hand-rolling a final-answer tool whenever you control the agent’s output schema. The SDK validates the model’s output against the schema, retries on validation errors (the retry count is configurable), and returns a typed instance via result.final_output. Module 6 will compare this with Module 2’s manual write_report pattern.

7. When to pick the OpenAI Agents SDK (and when not to)

The decision matrix you will revisit in Module 6, surfaced here so you can spot the sweet spot when L08’s triage-and-handoff demo lands.

Pick the OpenAI Agents SDK when:

Don’t pick the OpenAI Agents SDK when:

This is the framing L08 builds on: the triage-and-handoff customer-support agent is the canonical sweet-spot demo, and every primitive you just read about — Agent, handoff(), guardrails, output_type= — shows up in the next lesson’s code.

References