Modern agent SDKs
The newer typed, lightweight agent frameworks — OpenAI Agents SDK and Pydantic AI — and how they compare to the raw loop, LangChain/LangGraph and CrewAI/AutoGen. Fewer concepts, types doing the work, and observability that comes free.
Learning objectives
- Explain what the newer typed/lightweight agent SDKs add over a raw loop.
- Describe the OpenAI Agents SDK primitives: agents, handoffs, guardrails, sessions, tracing.
- Describe Pydantic AI: type-safe agents, structured outputs, dependency injection.
- Place them on the framework landscape vs LangChain/LangGraph and CrewAI/AutoGen.
- Choose a framework per project and reason about migration and lock-in as a lead.
1 · Why another agent framework? essential
You already met the raw agent loop (call the model, run a tool, feed the result back, repeat) and the heavier stacks — LangChain/LangGraph and CrewAI/AutoGen. The newer SDKs — OpenAI Agents SDK and Pydantic AI — sit between them: more structure than a hand-written loop, far less machinery than LangChain. Their pitch is typed, lightweight, and few-concepts: a handful of primitives you can hold in your head, with types doing the heavy lifting so bugs surface at author time, not in production.
Two things to fix up front. Despite the name, the OpenAI Agents SDK is provider-agnostic — it runs against Claude and other models through a model-adapter, not just OpenAI. And Pydantic AI is model-agnostic too, built by the Pydantic team around the same validation library that already powers structured outputs across the ecosystem.
2 · The framework landscape essential
These frameworks differ less in what they do (loop over a model + tools) than in how much they impose and what they buy you. Read the row you'd reach for first, then read up.
| Framework | Weight | Typed? | Handoffs | Guardrails | Reach for it when… |
|---|---|---|---|---|---|
| Raw loop (Ch04) | none | you add it | hand-rolled | hand-rolled | learning; one agent; total control |
| LangChain / LangGraph | heavy | partial | graph edges | callbacks/manual | complex stateful graphs; big ecosystem |
| CrewAI / AutoGen | medium | loose | roles/messages | manual | multi-agent role play, conversations |
| OpenAI Agents SDK | light | yes | first-class | first-class | typed multi-agent w/ handoffs + tracing |
| Pydantic AI | light | yes (Pydantic) | via tools | validators | type-safe structured outputs + DI |
This strip lines up the agent frameworks from most hand-built on the left to most typed/lightweight on the right. Every box still runs the same underlying loop; the caption under each says what it primarily buys you.
- Raw loop — you write the model→tool→model loop yourself. Maximum control, zero help. This is the Ch04 starting point.
- LangGraph — wraps the loop in an explicit stateful graph; reach for it when the control flow is genuinely complex.
- CrewAI / AutoGen — organize several agents into roles that message each other.
- OpenAI Agents SDK — adds first-class handoffs plus guardrails and tracing with little ceremony.
- Pydantic AI — leans on types and dependency injection so outputs validate and wiring stays clean.
In short: pick the box whose caption matches your project's hardest need — that's usually the right framework, and when two fit, choose the lighter one.
3 · Typed structured output (Pydantic AI shape) essential
Pydantic AI's headline feature: you declare the output type, and the agent is responsible for returning data that validates against it — no fragile string parsing. We model that shape with a plain dataclass + manual validation (no external pydantic import) so it runs offline. The real SDK does this for you with a Pydantic model.
typed_output.pyfrom dataclasses import dataclass
@dataclass
class Triage:
urgency: str # low | medium | high
category: str
def validate(d):
"""Stand-in for Pydantic validation: enforce the schema by hand."""
if d.get("urgency") not in {"low", "medium", "high"}:
raise ValueError(f"bad urgency: {d.get('urgency')!r}")
if not d.get("category"):
raise ValueError("category required")
return Triage(urgency=d["urgency"], category=d["category"])
def run_agent(ticket):
"""Pretend the model returned this dict; the SDK would coerce+validate it."""
raw = {"urgency": "high", "category": "billing"} if "refund" in ticket \
else {"urgency": "low", "category": "general"}
return validate(raw)
r = run_agent("I need a refund now")
print(type(r).__name__, "->", r.urgency, "/", r.category)
try:
validate({"urgency": "URGENT", "category": "x"})
except ValueError as e:
print("rejected:", e)
Triage -> high / billing
rejected: bad urgency: 'URGENT'
This models Pydantic AI's headline trick — a typed output the agent must return — using only a dataclass and a hand-written check, so it runs offline. The real SDK does the validation for you from a Pydantic model.
@dataclass class Triagedeclares the shape we expect back: anurgencyand acategory. Nothing more, nothing less.validate(d)is our stand-in for Pydantic: it rejects anyurgencyoutside the allowed set and any emptycategory, raisingValueErrorloudly at the boundary.run_agentpretends the model returned a dict; we push it throughvalidateso bad data can never reach the rest of the program.- The
try/exceptshows the guard firing:"URGENT"is not in the allowed set, so validation rejects it instead of letting it slip through.
What the output means: The good ticket becomes a Triage object (high / billing); the bad one is rejected with a clear message. That loud failure at the edge is the whole point of typed outputs.
Try this: add a third urgency like "critical" to the allowed set, or feed a dict missing category and watch which guard fires.
pydantic_ai_real.py# needs the SDK: pip install pydantic-ai
from pydantic import BaseModel
from pydantic_ai import Agent
class Triage(BaseModel):
urgency: str
category: str
agent = Agent("anthropic:claude-sonnet-4-5", output_type=Triage)
result = agent.run_sync("I need a refund now")
print(result.output.urgency) # -> "high" (validated Triage instance)
4 · Handoffs — one agent routes to another intermediate
The OpenAI Agents SDK makes handoffs a first-class primitive: a triage agent decides which specialist should take over and hands the conversation off to it. That's more structured than CrewAI's message-passing between roles and lighter than wiring LangGraph edges. Model the routing shape: an agent returns a target instead of a final answer, and the harness dispatches to it.
handoff.pydef triage(query):
"""Return either a final answer, or a handoff target for a specialist."""
q = query.lower()
if "refund" in q or "charge" in q:
return ("handoff", "billing")
if "error" in q or "crash" in q:
return ("handoff", "tech")
return ("answer", "Thanks! A general agent can help with that.")
SPECIALISTS = {
"billing": lambda q: f"[billing] Looking into the charge for: {q!r}",
"tech": lambda q: f"[tech] Reproducing the crash for: {q!r}",
}
def run(query, hops=0):
kind, payload = triage(query)
if kind == "answer":
return payload
if hops > 3:
return "escalate to human" # loop guard
return SPECIALISTS[payload](query) # hand off to the specialist
for q in ["I want a refund", "the app crashes on login", "what are your hours?"]:
print(run(q))
[billing] Looking into the charge for: 'I want a refund'
[tech] Reproducing the crash for: 'the app crashes on login'
Thanks! A general agent can help with that.
This models the OpenAI Agents SDK's handoff primitive: a triage agent doesn't always answer — sometimes it hands the conversation to a specialist. We model "hand off" as returning a target name the harness then dispatches to.
triage(query)returns a pair: either("answer", text)to reply directly, or("handoff", name)to route to a specialist.SPECIALISTSmaps each target name to the agent that handles it — here just small functions standing in for full agents.runlooks at the pair: on"answer"it returns the text; on"handoff"it calls the named specialist. Thehops > 3line is a loop guard so routing can't spin forever.- The
forloop feeds three queries through and prints who ended up handling each.
What the output means: The refund goes to [billing], the crash to [tech], and the generic question is answered directly — routing decided entirely by the triage step.
Try this: add a "shipping" specialist and a keyword for it in triage, then send "where is my order?" through run.
5 · Guardrails — validate before and after advanced
A guardrail is a check that runs around the agent: an input guardrail can reject a prompt before it costs a model call; an output guardrail can block or repair an unsafe/malformed response. Both SDKs support this — Agents SDK as explicit input/output guardrails, Pydantic AI via validators on the output type. Model a wrapper that enforces both.
guardrail.pyBANNED = {"password", "ssn"}
def input_guard(prompt):
if any(w in prompt.lower() for w in BANNED):
raise ValueError("input blocked: sensitive term")
return prompt
def output_guard(reply):
if len(reply) > 200:
return reply[:197] + "..." # repair: clamp length
return reply
def guarded(agent):
"""Wrap an agent fn with input + output guardrails."""
def wrapped(prompt):
input_guard(prompt)
return output_guard(agent(prompt))
return wrapped
agent = lambda p: f"Echo: {p} " + "x" * 300 # a chatty agent
safe = guarded(agent)
print(safe("hello there")[:40], "...len", len(safe("hello there")))
try:
safe("what is my password")
except ValueError as e:
print(e)
Echo: hello there xxxxxxxxxxxxxxxxxxxxxx ...len 200
input blocked: sensitive term
This models a guardrail: checks that run around the agent. An input guard can reject a prompt before it costs a model call; an output guard can block or repair the reply. guarded wraps any agent function with both.
input_guardraises if the prompt contains a banned term — stopping the call before the (pretend) model even runs.output_guardrepairs rather than rejects here: an over-long reply is clamped to 200 characters instead of being thrown away.guarded(agent)returns a new function that runs the input guard, calls the agent, then runs the output guard — the classic wrapper pattern.- The chatty
agentlambda always returns a 300-char reply, so the output guard always clamps it; the banned-word prompt trips the input guard and raises.
What the output means: The normal prompt comes back clamped to length 200; the "password" prompt is blocked before any work happens.
Try this: add "api_key" to BANNED, or lower the 200-char clamp and watch the repaired reply get shorter.
6 · Sessions & tracing (Agents SDK) professional
Two production affordances the Agents SDK ships out of the box. Sessions persist conversation history so multi-turn state survives across calls (the same problem the memory chapter tackles, handled for you). Tracing records every step — model calls, tool calls, handoffs, guardrail decisions — as a viewable trace, so debugging a misbehaving agent is reading a timeline instead of scattering print statements. This is the real reason to adopt an SDK over a hand-rolled loop at scale: the observability comes free.
agents_sdk_real.py# needs the SDK: pip install openai-agents
from agents import Agent, Runner, handoff
billing = Agent(name="Billing", instructions="Handle refunds and charges.")
tech = Agent(name="Tech", instructions="Handle crashes and errors.")
triage = Agent(name="Triage", instructions="Route to the right specialist.",
handoffs=[handoff(billing), handoff(tech)])
# provider-agnostic despite the name: point at any model, e.g. Claude
result = Runner.run_sync(triage, "I want a refund")
print(result.final_output)
7 · Choosing — and the lock-in question tech-lead
A lead's job here is a deliberate choice, not a fashion follow. Rough guidance: raw loop for learning or a single tightly-controlled agent; Pydantic AI when your value is in reliable typed outputs and clean dependency injection; OpenAI Agents SDK when you want multi-agent handoffs, guardrails, sessions and tracing with little ceremony; LangGraph when you genuinely need a complex stateful graph; CrewAI/AutoGen for role-playing multi-agent conversations. When two fit, prefer the lighter one.
choose_framework.pydef choose(multi_agent, need_handoffs, typed_output, complex_state, learning):
if learning:
return "raw loop (Ch04) — understand the mechanics first"
if complex_state:
return "LangGraph — you need an explicit stateful graph"
if multi_agent and need_handoffs:
return "OpenAI Agents SDK — first-class handoffs + tracing"
if typed_output:
return "Pydantic AI — type-safe structured outputs + DI"
if multi_agent:
return "CrewAI / AutoGen — role-based multi-agent"
return "raw loop — one agent, full control"
print(choose(False, False, False, False, True)) # learning
print(choose(True, True, False, False, False)) # handoffs
print(choose(False, False, True, False, False)) # typed
print(choose(False, False, False, True, False)) # stateful graph
raw loop (Ch04) — understand the mechanics first
OpenAI Agents SDK — first-class handoffs + tracing
Pydantic AI — type-safe structured outputs + DI
LangGraph — you need an explicit stateful graph
This turns the "which framework?" decision into a small function you can reason about. Each if encodes one rule of thumb, checked in priority order, so the first matching condition wins.
learningis checked first: if you're still learning, the answer is the raw loop — understand the mechanics before adopting a framework.complex_stateroutes to LangGraph; genuine stateful-graph needs are what justify its weight.multi_agent and need_handoffspicks the OpenAI Agents SDK — first-class handoffs plus tracing are its sweet spot.typed_outputpicks Pydantic AI; a baremulti_agentfalls to CrewAI/AutoGen; everything else stays a raw loop. Order matters — the top rules pre-empt the lower ones.
What the output means: The four calls print the pick for learning, handoffs, typed output and complex state — the same guidance the tech-lead section gives, expressed as code.
Try this: reorder the ifs (e.g. move typed_output above need_handoffs) and note how the priority changes which framework a mixed requirement selects.
On lock-in: an SDK owns your control flow (its Agent/Runner/handoff types thread through everything), so switching later is a rewrite, not a config change. Mitigate it the way you would any dependency — keep your domain logic (tools, prompts, validation, business rules) in plain functions the SDK merely orchestrates, so the framework is a thin shell you can peel off. The typed shapes actually help here: a Pydantic output type or a plain tool function is portable; the orchestration around it is what you'd rewrite.
Exercise FA7.1 — Route with typed handoffs
Context: The Agents SDK's core — typed output, handoff routing, and guardrails — can be assembled from the three stdlib labs you already wrote, which is the best way to see the SDK isn't magic.
Your task: Combine the typed-output, handoff, and guardrail labs into one stdlib-only pipeline: a triage router returns a validated object, dispatches to the specialist it names, and the whole thing is wrapped by input/output guards.
Requirements:
- Reuse
typed_output.py's dataclass validation for the triage result - Dispatch to the specialist named by the validated category field
- Wrap the pipeline with
guardrail.py's input and output guards - Keep it stdlib-only — no SDK import
- The result mirrors the Agents SDK core: validate → route → guard
💡 Hint: You already have all three pieces from earlier labs; the exercise is the wiring order, not new code.
Exercise FA7.2 — Make the framework call
Context: The framework choice is a judgement call a lead has to defend, and the smartest hedge against lock-in is deciding up front what logic stays in plain functions.
Your task: For three real projects — a typed extraction endpoint, a multi-agent support desk, and a complex stateful workflow — run your framework selector and justify each pick in one sentence, then name what you'd keep in plain functions for the one you'd actually build.
Requirements:
- Run
choose_framework.pyfor all three projects - Give a one-sentence justification per pick
- For your real build, name the domain logic you'd keep framework-agnostic
- Tie the lock-in bound back to the portable model→tool→model core
💡 Hint: The logic worth protecting is whatever a migration would otherwise force you to rewrite — usually tools and prompts, not the loop.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Every agent framework — raw loop, LangGraph, CrewAI/AutoGen, the Agents SDK, Pydantic AI — runs the same model→tool→model loop; you pick one by the project's hardest need, and reach for the lighter option when two fit.
Your task: Write a selector that recommends a framework from a one-line description of a project's hardest need, defaulting to the simplest option when nothing stronger is required.
Requirements:
- Encode the landscape as a lookup from need → framework (control, stateful graph, multi-agent roles, typed handoffs, structured output)
- Prefer the lighter framework when two would satisfy the need
- Fall back to a “start simple” default for an unrecognized need
- Pure decision logic — no SDK import required
- Show the pick for two or three contrasting needs
💡 Hint: A plain dict keyed on the need string with a .get(need, default) is the whole selector; the teaching point is the ordering, not the code.
Show solution
Encode the landscape table as a chooser (no SDK needed — pure decision logic):
def choose_framework(need):
table = {
"learning / total control": "Raw loop (Ch04)",
"complex stateful graph": "LangChain / LangGraph",
"multi-agent role play": "CrewAI / AutoGen",
"typed multi-agent + handoffs": "OpenAI Agents SDK",
"type-safe structured output": "Pydantic AI",
}
return table.get(need, "Raw loop — start simple, add structure only when it hurts")
print(choose_framework("type-safe structured output")) # Pydantic AI
print(choose_framework("complex stateful graph")) # LangGraph
Every framework runs the same model→tool→model loop underneath; pick the one whose primary benefit matches your hardest need, and when two fit, choose the lighter one.
Context: Pydantic AI makes the model's reply a validated type, so a malformed field is caught at author time instead of corrupting everything downstream.
Your task: Show the correct Pydantic-AI shape: a typed result model plus an agent bound to it, and label the rung as requiring the SDK.
Requirements:
- Define a Pydantic
BaseModelresult with a few typed fields (e.g. a category string, a bounded int priority, a bool) - Construct an
Agent(model, output_type=Model)and callrun_sync - Read the validated result off
result.output - Note the code needs
pip install pydantic-ai - The model id is provider-agnostic (the lesson runs it against Claude)
💡 Hint: The single load-bearing idea is output_type= — it forces the reply through the model's validation before your code ever sees it.
Show solution
The typed-output pattern — needs pip install pydantic-ai (Pydantic AI is model-agnostic):
from pydantic import BaseModel
from pydantic_ai import Agent
class Ticket(BaseModel):
category: str
priority: int # 1-5
needs_human: bool
agent = Agent(
"anthropic:claude-sonnet-4-5", # model-agnostic; Claude here
output_type=Ticket, # output is validated into Ticket
)
result = agent.run_sync("Card declined twice, customer furious.")
print(result.output.category, result.output.priority) # a validated Ticket
Because output_type is a Pydantic model, a malformed field is caught by validation instead of blowing up downstream — types do the heavy lifting so bugs surface early.
Context: The Agents SDK makes handoffs first-class: a triage agent can transfer control to a specialist instead of you hand-rolling a routing table.
Your task: Show the correct handoff shape — two specialist agents and a triage agent that routes to them — and label the rung as requiring the SDK.
Requirements:
- Create two specialist
Agents with distinct instructions - Create a triage
Agentwithhandoffs=[a, b] - Run through
Runner.run_sync(triage, …)and readfinal_output - Note the code needs
pip install openai-agents - The SDK is provider-agnostic; handoff routing is declarative, not hand-coded
💡 Hint: Let the triage agent decide the transfer — you declare the candidates in handoffs, you don't write the if/else.
Show solution
First-class handoffs — needs pip install openai-agents (the SDK is provider-agnostic; runs against Claude via an adapter):
from agents import Agent, Runner
billing = Agent(name="Billing", instructions="Handle refunds and charges.")
tech = Agent(name="Tech", instructions="Handle login and errors.")
triage = Agent(
name="Triage",
instructions="Route the user to the right specialist.",
handoffs=[billing, tech], # first-class: triage can hand off to either
)
result = Runner.run_sync(triage, "I was double-charged for my subscription.")
print(result.final_output) # answered by the Billing agent after handoff
Instead of hand-rolling routing, you declare the specialists in handoffs and the triage agent transfers control — the structure the SDK buys you over a raw loop.
Context: Guardrails validate inputs before the agent runs (and outputs before they return), giving you an author-defined boundary that sits outside the model call.
Your task: Show the correct input-guardrail shape: a guard that trips on a simple policy violation and halts the run before the model executes. Label it as requiring the SDK.
Requirements:
- Use the
@input_guardraildecorator on a guard function - Return a
GuardrailFunctionOutputwith atripwire_triggeredflag - Implement one naive check (e.g. a 9-digit all-numeric token looks like an SSN)
- Attach it via
Agent(…, input_guardrails=[guard]) - A tripped tripwire raises and stops before the model runs
💡 Hint: The tripwire boolean is the contract — when it's true the runner raises, so the expensive model call never happens.
Show solution
Input/output guardrails — needs pip install openai-agents:
from agents import Agent, Runner, input_guardrail, GuardrailFunctionOutput
@input_guardrail
def block_pii(ctx, agent, user_input: str) -> GuardrailFunctionOutput:
contains_ssn = any(part.isdigit() and len(part) == 9
for part in user_input.split())
return GuardrailFunctionOutput(
output_info={"pii": contains_ssn},
tripwire_triggered=contains_ssn, # True -> halt before the model runs
)
agent = Agent(name="Support", instructions="Help the user.",
input_guardrails=[block_pii])
# Runner.run_sync raises if the tripwire trips, stopping unsafe input early.
Guardrails run outside the model call, so a tripwire (like detected PII) halts the request before it ever reaches the model — validation at author-defined boundaries, not buried in prompts.
Context: Sessions persist conversation state across runs and tracing records every step, so multi-turn memory and observability come for free instead of being threaded by hand.
Your task: Show the correct shape for a persisted session: two turns where the second recalls a fact stated in the first. Label the rung as requiring the SDK.
Requirements:
- Create a
SQLiteSession(id, db_path) - Pass
session=into two consecutiveRunner.run_synccalls - Turn two answers using a fact only given in turn one
- State is recalled automatically — no manual history threading
- Note tracing records each run's steps and tool calls for later inspection
💡 Hint: The same session object handed to both runs is what carries memory across the gap; you never rebuild the transcript yourself.
Show solution
Sessions + tracing — needs pip install openai-agents:
from agents import Agent, Runner, SQLiteSession
agent = Agent(name="Assistant", instructions="Be helpful and remember context.")
session = SQLiteSession("user-123", "conversations.db") # persisted across runs
# Turn 1
Runner.run_sync(agent, "My name is Dana.", session=session)
# Turn 2 — the session carries the earlier turn's context automatically
r = Runner.run_sync(agent, "What's my name?", session=session)
print(r.final_output) # knows "Dana" from the persisted session
# Tracing is built in: each run records steps/tool-calls for later inspection.
A session stores the running conversation so the next turn has context without you threading it manually, and built-in tracing gives you a step-by-step record to debug and audit what the agent did.
Context: As a lead you don't just pick a framework — you plan for the day you leave it. Lock-in lives in a framework's proprietary primitives; the portable model→tool→model core survives any migration.
Your task: Write logic that both recommends a framework for a project and scores its lock-in risk, emitting a short migration plan alongside the pick.
Requirements:
- Recommend from the project's needs (types wanted? multi-agent?)
- Rate lock-in per framework: Pydantic AI LOW (portable models), Agents SDK MEDIUM (SDK-specific handoffs/sessions), raw loop NONE
- Return a (framework, lock-in, migration-plan) triple
- The migration plan keeps tool functions and prompts framework-agnostic
- Reinforce that lock-in concentrates in proprietary extras, not the core loop
💡 Hint: Ask what a rewrite would have to re-implement: if it's just glue around your own tool functions and prompts, lock-in is low.
Show solution
Recommend a framework and quantify how hard it would be to leave (pure decision logic):
def evaluate(need, team_wants_types, multi_agent):
if need == "structured output" and team_wants_types:
fw, lockin = "Pydantic AI", "LOW — Pydantic models are portable"
elif multi_agent:
fw, lockin = "OpenAI Agents SDK", "MEDIUM — handoffs/sessions are SDK-specific"
else:
fw, lockin = "Raw loop (Ch04)", "NONE — you own the loop"
plan = ("keep tool functions + prompts framework-agnostic so the model→tool→model "
"core ports even if you switch")
return fw, lockin, plan
fw, lock, plan = evaluate("structured output", team_wants_types=True, multi_agent=False)
print("framework:", fw)
print("lock-in :", lock)
print("migration:", plan)
Every SDK wraps the same loop, so lock-in lives in the proprietary extras (handoffs, sessions). Keep your tools and prompts framework-agnostic and the portable core survives a migration — choose the lighter framework when two fit.
✓ Checkpoint — you can move on when you can…
- Explain what typed/lightweight SDKs add over a raw loop and over LangChain/CrewAI.
- Describe agents, handoffs, guardrails, sessions and tracing (Agents SDK).
- Describe Pydantic AI's typed outputs and dependency injection.
- Model a handoff router, a typed validated output, and a guardrail wrapper on stdlib.
- Pick a framework per project and bound the lock-in by keeping domain logic portable.
Knowledge check check yourself
Where do the newer SDKs (OpenAI Agents SDK, Pydantic AI) sit relative to the raw loop and LangGraph/CrewAI, and are they tied to one provider?
Show answer
What does declaring a typed output (the Pydantic AI shape) buy you, and where does a guardrail run?