AI EngineeringZero to ProductionHome·About·What’s new·Contact
Frontier Agent Capabilities · Part 7

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.

⏱️ ~1.5 hours🧪 4 labs🎯 Beginner→Tech-lead

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.
📋 Before you startThis page builds on: Agent & tool foundations · The Claude API. Jumping in? Skim those first — or open the recap.
30-second recap
  • Agent & tool foundations: The model asks to call a tool, your code runs it and returns the result, and the loop repeats until the model answers — with step caps so it can't run forever.
  • The Claude API: One call — messages.create — takes a model, a system prompt and messages, and returns content blocks; tools and streaming are options on the same call.

1 · Why another agent framework? essential

Here's the moment that sends people looking for a framework. You built an agent by hand in Chapter 4 — a loop that calls the model, parses the tool it asked for, runs it, feeds the result back, and repeats. It worked beautifully in the demo. Then the requirements arrived: this reply has to come back as a validated object, not a string you regex; a "billing" question should be handed to a different agent than a "bug report"; a prompt containing a password must never reach the model; and when it misbehaves in production you need to see every step it took, not sprinkle print everywhere. Suddenly your tidy 40-line loop is 400 lines of glue you now have to test and maintain — and none of that glue is your actual product.

Think of it like web servers. You can write raw socket code that parses HTTP by hand, and doing it once teaches you what a request really is. But nobody ships a product that way — they reach for a framework that has already solved the boring, error-prone parts (routing, parsing, sessions) so they can spend their effort on the thing that's actually theirs. Agent SDKs are that same jump for agents: they hand you the repetitive machinery — typed outputs, routing between agents, guardrails, conversation memory, tracing — as a few named primitives, so you write the behaviour and not the plumbing.

So what precisely is an "agent SDK"? It's a library that wraps the model→tool→model loop you already understand and gives its recurring pieces first-class names and types. The newer SDKs — the OpenAI Agents SDK and Pydantic AI — are deliberately small: they picked a handful of concepts (agent, tool, handoff, guardrail, output type, session) and stopped there, betting that fewer ideas you fully understand beats a sprawling toolkit you use 10% of. The word doing the heavy lifting is typed: because your tools and outputs have declared shapes, a whole class of "the model returned something weird and it exploded three functions later" bugs turns into a loud, immediate failure at author time. That is the pitch — not that they do something the raw loop can't, but that they make the parts you'd get wrong hard to get wrong.

The common mistake to name now: beginners assume "OpenAI Agents SDK" means "locked to OpenAI's models" and skip it if they use Claude. That's wrong, and it's worth un-learning before we go further — the SDK reaches Claude and other models through a model adapter, so the name describes who built it, not who you can call.

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.

hand-built: the loop, then the glue 40-line raw loop real requirements arrive… parse output route agents block secrets memory + trace 400 lines of glue you test & own SDK: the same pieces, named agent SDK a handful of primitives output_type handoff guardrail session/trace typed, few concepts, free observability Same needs, two costs. The requirements that turn a tidy raw loop into 400 lines of glue (left) are the exact pieces an SDK already gives you as typed primitives (right) — so you write behaviour, not plumbing.
🗺️ How to read this diagram

Compare the two halves left and right: both start from the same model→tool→model loop, but they meet the same four new requirements very differently.

  • The left side starts as a small raw loop; when real requirements land it sprouts four red boxes — parse the output, route between agents, block secrets, keep memory and traces — each hand-written code you must test and maintain.
  • The right side is an agent SDK: those same four needs arrive as green named primitives (output_type, handoff, guardrail, session/trace) the library already implements.
  • The payoff is the two coloured footers: red "400 lines of glue you own" versus green "typed, few concepts, free observability" — that is the trade the chapter is about.

In short: An SDK isn't doing something the raw loop can't — it hands you the boring, error-prone parts as primitives so your effort goes to the product.

2 · The framework landscape essential

The hardest part of this whole topic isn't using any one framework — it's the paralysis of choosing. A new engineer opens the ecosystem and finds a raw loop, LangChain, LangGraph, CrewAI, AutoGen, the OpenAI Agents SDK, Pydantic AI, and a dozen more, each with a landing page insisting it's the one true way. The instinct is to pick the most popular or the most powerful and force every project through it. That's how teams end up running a full stateful-graph engine to answer one FAQ, or hand-rolling routing in a bare loop when a first-class primitive existed. The cure is a map, not a favourite.

Here's the unifying idea that makes the map readable: they all run the same loop underneath — model, tool, result, back to the model. None of them invented a new way to think; they differ only in how much structure they impose and what that structure buys you in return. So instead of memorizing feature lists, place them on a single axis of weight — how much of your code the framework owns. At the light end is the raw loop (it owns nothing; you write everything). At the heavy end sits LangGraph (it owns your control flow as an explicit graph). Everything else lands in between, and the trade is always the same shape: more weight means more done-for-you but more of your code shaped by, and locked to, the framework.

Read the table below as a decision tool, not a leaderboard. The instinct that will serve you: identify your project's single hardest requirement first — "I need validated typed outputs," or "I need one agent to hand a conversation to another," or "my control flow is a genuinely branchy state machine" — then pick the lightest framework whose column already solves that. Weight you don't need is not neutral; it's code you maintain, concepts your teammates must learn, and a bigger rewrite the day you leave. The columns ("Typed?", "Handoffs", "Guardrails") are exactly the recurring needs the rest of this chapter unpacks, so this table doubles as its outline.

FrameworkWeightTyped?HandoffsGuardrailsReach for it when…
Raw loop (Ch04)noneyou add ithand-rolledhand-rolledlearning; one agent; total control
LangChain / LangGraphheavypartialgraph edgescallbacks/manualcomplex stateful graphs; big ecosystem
CrewAI / AutoGenmediumlooseroles/messagesmanualmulti-agent role play, conversations
OpenAI Agents SDKlightyesfirst-classfirst-classtyped multi-agent w/ handoffs + tracing
Pydantic AIlightyes (Pydantic)via toolsvalidatorstype-safe structured outputs + DI
Raw loop control LangGraph stateful graph CrewAI/AutoGen multi-role OpenAI Agents handoffs+guardrails Pydantic AI types+DI
🗺️ How to read this diagram

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.

Same loop underneathEvery box here still runs the Ch04 loop: model → tool → result → model. The frameworks differ in what they wrap around it — state, typing, roles, handoffs, tracing. If you understand the loop, you understand all of them.

3 · Typed structured output (Pydantic AI shape) essential

Every engineer who has parsed an LLM reply by hand knows this pain. You ask the model to "return the urgency and category as JSON," it does — beautifully, ninety-nine times. Then on the hundredth it wraps the JSON in a chatty preamble, or spells urgency "Urgent" instead of "high", or drops the category entirely. Your json.loads and your response["category"] sail right past the check and hand corrupt data to the next function, which stores it, which surfaces it in a dashboard three days later as a silent wrong number. The failure happened at the boundary but detonated far away — the worst kind of bug to trace.

The fix is a discipline you already trust everywhere else: validate untrusted input at the door. A model's reply is untrusted input — it's generated by a probabilistic system you don't control — so you treat it exactly as you'd treat a form submission or an API payload: define the shape you require, and reject anything that doesn't fit before it can travel any further. Think of it as a bouncer with a guest list at the entrance to your program. Nobody malformed gets past the first line.

That's what a typed output means in Pydantic AI, and it's worth being precise about the mechanism because it's easy to under-sell. You don't parse the reply yourself; you declare the type you expect — a schema with named fields and their kinds — and hand it to the agent as its output_type. From then on the SDK makes the model responsible for producing data that validates against that schema, coercing and checking it before your code ever sees it. A field of the wrong type or an out-of-range value raises a validation error at the edge, loudly, where it's cheap to catch — instead of quietly poisoning everything downstream. The name for this is "parse, don't validate later": the boundary is the one place you look, and past it every object is guaranteed well-formed.

In the lab we model that shape with a plain dataclass plus a hand-written check, so it runs offline with no external install — the manual validate function stands in for what Pydantic does automatically. The common mistake it guards against, and the one to watch for in your own code: treating the model's string as if it were already trustworthy. The typed approach flips that assumption; the reply is guilty until it validates.

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.

untrusted model reply urgency: "high" "URGENT" · missing output_type validate at the door Triage object ✓ ValueError, loudly the schema is the one place you check — past it, every object is well-formed valid →downstream trusts it invalid →stops at the edge Parse, don't validate later. Declaring the output type turns the model's reply into a checkpoint: good data passes as a typed object, bad data fails at the boundary — never downstream where it's expensive to trace.
🗺️ How to read this diagram

Follow the arrows left to right: an untrusted reply enters, hits one checkpoint, and splits into two outcomes.

  • The left blue box is the model's raw reply — sometimes clean ("high"), sometimes malformed ("URGENT" or a missing field). You don't trust it yet.
  • The middle box is the declared output_type: the schema that validates every reply at the door, before your code runs.
  • The two arrows out of it are the split — a blue arrow to a valid typed Triage object, a red arrow to a loud ValueError. The green/red boxes below restate the payoff: valid data is trusted downstream; invalid data stops right here.

In short: The typed output is a single boundary checkpoint — malformed replies fail loudly there instead of quietly poisoning code three functions away.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · typed validated output, modeled (runs)
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'
▶ How this works

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.

  1. @dataclass class Triage declares the shape we expect back: an urgency and a category. Nothing more, nothing less.
  2. validate(d) is our stand-in for Pydantic: it rejects any urgency outside the allowed set and any empty category, raising ValueError loudly at the boundary.
  3. run_agent pretends the model returned a dict; we push it through validate so bad data can never reach the rest of the program.
  4. The try/except shows 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.

Types are the guardrailThe win isn't just convenience: a typed output means malformed model responses fail loudly at the boundary instead of silently corrupting downstream code. That's the same discipline as validating any external input.
Python · the real Pydantic AI shape — needs the SDK (does NOT run here)
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

You've called a company's support line and heard it: "Press 1 for billing, press 2 for technical support." The first voice you reach isn't there to solve your problem — it's there to figure out who should, and then transfer you. Once you're transferred, that specialist owns the rest of the call; the switchboard is out of the picture. Agents hit the same wall the moment they grow past one job. A single agent stuffed with instructions for billing and refunds and crash reports and shipping becomes a bloated, unreliable generalist — its prompt is a mess, and it's mediocre at everything. The natural fix is the switchboard: a small triage agent that reads the request and routes it to a focused specialist.

A handoff is that transfer made into a first-class primitive. The triage agent doesn't answer the billing question itself — it hands the entire conversation to the billing agent, which takes over and owns the turn from there. The key word is transfers control: after a handoff, the specialist is driving, not the router. That's a genuinely different move from the "sub-agent as a tool" pattern you may have seen, where the caller invokes a helper, gets a value back, and stays in charge — and the note after the lab draws that line sharply, because mixing them up is the classic confusion here.

Why make it a primitive at all, rather than just writing an if/else on keywords like our lab does? Because at real scale the routing logic gets rich — it depends on the model's judgement about the request, not just string matching — and because the SDK can then give you the surrounding machinery for free: it records the handoff in the trace, carries the conversation context across the transfer, and lets the routing be declarative (you list the candidate specialists; the triage agent decides). This sits deliberately between two neighbours you've met: it's more structured than CrewAI's looser message-passing between roles, and lighter than wiring explicit edges in a LangGraph state machine. Our runnable model below strips it to the essence — a triage function returns a target name instead of a final answer, and a tiny harness dispatches to it — including a hops counter, because the one thing that will bite you in routing is agents bouncing a request back and forth forever, so a loop guard is not optional.

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.

incoming request "I want a refund" triage agent who owns this? billing agent tech agent general reply loop guard: hops > 3 escalate to human chosen specialist now owns the turn The switchboard, made a primitive. Triage doesn't answer — it transfers control to the one specialist that fits, and that agent drives the rest of the turn. A hop counter stops requests bouncing forever.
🗺️ How to read this diagram

Read left to right: a request enters, the triage agent decides, and control jumps to exactly one of the boxes on the right.

  • The left box is the incoming request; the purple triage agent reads it and asks only one question — who should own this?
  • The three arrows fan out to the candidate targets; the request follows just one of them. Here "refund" lights up the green billing agent, which then owns the rest of the turn (that is the transfer of control, not a return-a-value call).
  • The red box at the bottom is the loop guard: if a request keeps getting re-routed past hops > 3, it escalates to a human instead of bouncing forever.

In short: A handoff routes a request to one specialist and hands it the wheel — the loop guard is what keeps that routing from spinning.

Python · a handoff router, modeled (runs)
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.
▶ How this works

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.

  1. triage(query) returns a pair: either ("answer", text) to reply directly, or ("handoff", name) to route to a specialist.
  2. SPECIALISTS maps each target name to the agent that handles it — here just small functions standing in for full agents.
  3. run looks at the pair: on "answer" it returns the text; on "handoff" it calls the named specialist. The hops > 3 line is a loop guard so routing can't spin forever.
  4. The for loop 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.

Handoff vs sub-agent-as-toolA handoff transfers control (the target agent owns the rest of the turn); calling a sub-agent as a tool returns a value and the caller stays in charge. The SDKs support both — pick handoff for routing, tool-call for delegation you want to fold back in.

5 · Guardrails — validate before and after advanced

Picture the two failures that keep an agent's owner up at night. First: a user pastes their password, or a Social Security number, or a prompt-injection payload straight into the chat — and your agent dutifully ships it off to the model, logging sensitive data and burning a paid API call on a request you never should have accepted. Second: the model, having answered, returns a 4,000-word ramble, or a reply containing a slur, or JSON that isn't quite JSON — and your UI renders it, or your parser chokes, in front of a customer. Both are avoidable, and both are avoided in the same place: at the edges of the agent, not inside the prompt.

A guardrail is a check that runs around the agent rather than within it, and the mental model is a factory line with an inspector at each end. The input guardrail inspects the request before it reaches the model: if it violates policy — banned terms, PII, an obviously malicious instruction — it stops the line then and there, which crucially means you never pay for the model call. The output guardrail inspects the reply before it leaves: it can block an unsafe answer, or — and this is the subtler, more useful move — repair it, clamping an over-long response to a length limit or stripping a disallowed field so a fixable problem doesn't become a rejected one.

Why is this a separate concept instead of "just put the rules in the system prompt"? Because a prompt is a request to the model, and the model can ignore it — prompts are persuasion, guardrails are enforcement. A guardrail is your own code, running deterministically outside the model's discretion, so its verdict doesn't depend on the model cooperating. That distinction is the whole reason both SDKs make guardrails first-class: the Agents SDK as explicit input/output guardrail functions (a tripped "tripwire" halts the run), Pydantic AI as validators attached to the output type. The runnable lab models both ends as a guarded wrapper — the classic decorator shape where the input guard, the call, and the output guard nest around any agent function — so you can see that a guardrail is, structurally, nothing more exotic than input validation applied to a model instead of a form.

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.

prompt in request input guard banned? PII? agent / model output guard block / repair ✗ blocked here → no model call, no cost ✂ clamp / strip a fixable reply guards are your code, outside the model — enforcement, not a prompt request deterministic checks at both edges of the agent An inspector at each end of the line. The input guard can stop a bad prompt before it costs a model call; the output guard blocks or repairs the reply before it leaves — both run in your code, outside the model's discretion.
🗺️ How to read this diagram

Trace the single arrow left to right — a request flows through two checkpoints with the model in the middle.

  • The first purple box is the input guard: it inspects the prompt for banned terms or PII before the model runs. If it trips (the red note below), the line stops here — you never pay for the call.
  • The middle box is the agent/model call itself, reached only once the input guard passes.
  • The second purple box is the output guard: it inspects the reply and either blocks it or repairs it (the green note — e.g. clamping length) before returning. The green band underneath is the point: both guards are your own deterministic code at the edges, not a request buried in the prompt.

In short: Guardrails wrap the agent at both ends — reject early to save a call, repair or block late to keep bad replies from shipping.

Python · a guardrail wrapper, modeled (runs)
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
▶ How this works

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.

  1. input_guard raises if the prompt contains a banned term — stopping the call before the (pretend) model even runs.
  2. output_guard repairs rather than rejects here: an over-long reply is clamped to 200 characters instead of being thrown away.
  3. guarded(agent) returns a new function that runs the input guard, calls the agent, then runs the output guard — the classic wrapper pattern.
  4. The chatty agent lambda 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 things reliably go wrong the first time an agent meets real users, and neither shows up in a demo. The first: a user says "my name is Dana," then in the next message asks "what's my name?" — and the agent has no idea, because each call started from a blank slate. LLMs are stateless; the model remembers nothing between requests unless you re-send the whole conversation every time. The second: the agent gives a bafflingly wrong answer in production, and you have no way to see why — which tool it called, what the model actually returned, where the handoff went. You're left adding print statements and re-running, hoping to reproduce a non-deterministic failure. These are the two problems sessions and tracing solve, and they're exactly the tedious, easy-to-botch parts you'd otherwise hand-roll.

A session is persistent memory for a conversation: it stores the running transcript so each new turn automatically arrives with the earlier turns as context, and it survives across separate calls (and even process restarts, if backed by something like SQLite). Think of it as the difference between a goldfish and a colleague — the goldfish greets you fresh every morning; the colleague remembers yesterday's decision. This is the same long-term-memory problem the memory chapter tackles from scratch, except the SDK hands it to you as one object you thread into each run, so you stop manually rebuilding the message list every turn (and stop getting it subtly wrong).

Tracing is the flight recorder. It captures every step of a run — each model call, each tool invocation, each handoff, each guardrail decision — as a structured, viewable timeline. When something misbehaves, debugging becomes reading the trace instead of guessing: you see the exact point the agent took the wrong branch. This is genuinely the strongest argument for adopting an SDK over your hand-built loop once you're past learning: not that it does more, but that observability comes free. Rebuilding trustworthy tracing by hand is a hundred fiddly lines you'll get wrong under pressure, precisely when you need it most — which is why the tip after the (SDK-only) lab urges you not to try.

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.

no session — stateless goldfish "I'm Dana" "who are you?" turn 2 starts blank → no idea with session — remembers "I'm Dana" session store "who are you?" → "Dana" every turn also written to the trace timeline model call tool call handoff guardrail A colleague, not a goldfish. Without a session each turn is blank (top); a session persists the transcript so turn two recalls turn one (middle). Tracing records every step (bottom) so debugging is reading a timeline, not adding prints.
🗺️ How to read this diagram

Read the three bands top to bottom: the problem, the session fix, then the trace.

  • The top band is the stateless default — turn one says "I'm Dana", but turn two starts blank (red) and can't answer "who are you?" because the model remembers nothing between calls.
  • The middle band threads a green session store between the turns: turn one is saved, and turn two automatically arrives with that context, so it answers "Dana" — no manual history rebuilding.
  • The bottom row of amber cells is the trace: every model call, tool call, handoff and guardrail decision is recorded as a timeline you can read after the fact.

In short: A session is memory that survives across calls; tracing is the flight recorder — both are handed to you instead of hand-threaded.

Python · the real Agents SDK shape — needs the SDK (does NOT run here)
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)
Don't rebuild tracing by handSessions and tracing are the parts that are tedious and error-prone to hand-roll. If your agent is going to production, the observability an SDK gives you is worth more than the hundred lines of loop it replaces.

7 · Choosing — and the lock-in question tech-lead

Every lead eventually inherits the codebase that answers this section's warning by counter-example. Someone picked the framework that was trending on the day they started, wired its abstractions through every file, and now — two quarters later — that framework is unmaintained, or the team needs a capability it doesn't have, and migrating means rewriting the whole application because the framework is the application. The framework was chosen for its logo, not its fit, and the bill came due. Choosing well is the tech-lead skill this whole chapter has been building toward, and it has two halves: pick for the requirement, and plan for the exit.

The first half is discipline against fashion. Map each candidate to the one need it's best at — raw loop for learning or a single tightly-scoped agent; Pydantic AI when your value is 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 have a complex stateful graph; CrewAI/AutoGen for role-playing multi-agent conversations — and when two would fit, take the lighter one, because unused power is pure liability. The runnable selector below encodes exactly this as ordered ifs, which makes the priority explicit: the decision is a policy you can defend, not a vibe.

The second half is the one juniors miss: lock-in is real, and it lives in a specific place. Here's the crucial distinction — the model is swappable (both these SDKs are provider-agnostic, so moving from Claude to another model is a config change), but the framework is not. Its Agent/Runner/handoff types thread through your control flow, so switching frameworks later is a rewrite, not a setting. You can't eliminate that cost, but you can bound it, and the technique is the same one good engineers use for any dependency: keep the framework a thin shell and your value underneath it portable. Concretely — push your domain logic (tool functions, prompts, validation rules, business decisions) into plain functions the framework merely orchestrates. A Pydantic output type and a plain tool function travel to any framework; only the orchestration around them has to be rewritten. Adopt a framework because you need what it adds, keep your logic framework-agnostic, and a future migration stays a bounded job instead of a full stop.

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.

the framework shell — hard to leave Agent · Runner · handoff (SDK types) portable core inside — travels to any framework tool functions prompts validation rules business logic swap axis model: config change ✓ framework: a rewrite ✗ thin shell → bounded migration choose for the need, keep the green core portable, and the exit stays cheap Lock-in lives in the shell, not the core. The model is a config swap; the framework's types thread through everything, so a switch is a rewrite. Push your domain logic into plain functions the framework merely orchestrates and that rewrite stays bounded.
🗺️ How to read this diagram

Look at the big box on the left first, then the two swap outcomes on the right.

  • The red outer box is the framework shell — its Agent/Runner/handoff types thread through your control flow, which is why it is hard to leave.
  • The green inner boxes are your domain logic — tool functions, prompts, validation rules, business logic. Keep these as plain functions the framework only orchestrates, and they travel to any framework unchanged.
  • The right column is the swap axis: changing the model is a green config change (both SDKs are provider-agnostic), while changing the framework is a red rewrite — bounded only because the green core is portable.

In short: Swap the model freely; the framework is the real lock-in — keep your logic in portable plain functions so leaving it is a bounded job, not a full stop.

Python · pick a framework by requirement (runs)
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
▶ How this works

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.

  1. learning is checked first: if you're still learning, the answer is the raw loop — understand the mechanics before adopting a framework.
  2. complex_state routes to LangGraph; genuine stateful-graph needs are what justify its weight.
  3. multi_agent and need_handoffs picks the OpenAI Agents SDK — first-class handoffs plus tracing are its sweet spot.
  4. typed_output picks Pydantic AI; a bare multi_agent falls 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.

Choose wisely — the framework is the lock-inThe models are swappable (both these SDKs are provider-agnostic), but the framework is not. Its abstractions run through your whole codebase. Adopt one because you need what it adds — handoffs, guardrails, tracing, typed outputs — not for the logo. Keep domain logic in plain, portable functions so a future migration is bounded, and don't reach for a heavy stack when a raw loop or a light SDK does the job.

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.py for 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.

Exercise 1 · Place a need on the framework landscapeBeginner

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.

Exercise 2 · Typed structured output (Pydantic AI shape)Intermediate

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 BaseModel result with a few typed fields (e.g. a category string, a bounded int priority, a bool)
  • Construct an Agent(model, output_type=Model) and call run_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.

Exercise 3 · Handoffs — one agent routes to anotherAdvanced

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 Agent with handoffs=[a, b]
  • Run through Runner.run_sync(triage, …) and read final_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.

Exercise 4 · Guardrails — validate before and afterExpert

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_guardrail decorator on a guard function
  • Return a GuardrailFunctionOutput with a tripwire_triggered flag
  • 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.

Exercise 5 · Sessions & tracing (Agents SDK)Professional

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 consecutive Runner.run_sync calls
  • 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.

Exercise 6 · Choose a framework and reason about lock-inIndustry scenario

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

✓ Knowledge check

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
They sit between the hand-built raw loop and the heavier stacks: fewer concepts, with types doing the work and observability built in. Both are provider-agnostic despite their names — the OpenAI Agents SDK runs against Claude and others via a model adapter, and Pydantic AI is model-agnostic too.
✓ Knowledge check

What does declaring a typed output (the Pydantic AI shape) buy you, and where does a guardrail run?

Show answer
Declaring the output type makes the agent responsible for returning data that validates against the schema, replacing fragile string parsing with a loud failure at the edge. A guardrail runs around the agent: an input guard can reject a prompt before it costs a model call, and an output guard can block or repair an unsafe/malformed reply.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in