← Back to Blog

Choosing an Agent Framework: Claude Agent SDK vs OpenAI Agents SDK vs LangGraph

You do not pick an agent framework because it is the best. You pick it because it fits the shape of your problem, and feature lists will not tell you which shape you have. So here is a different kind of comparison: one task, three frameworks, and the code each one makes you write.

The task is a support agent. A customer says their order never arrived. The agent looks up the order, decides whether it can be refunded, issues the refund, or hands the conversation to a human. Three rules, two tools, one escalation path. Small enough to follow line by line, real enough that the frameworks start to disagree about how you should build it.

The frameworks: the Claude Agent SDK (Anthropic's library that wraps Claude Code's agent loop), the OpenAI Agents SDK (the production successor to Swarm), and LangGraph (LangChain's low-level orchestration runtime). All three run the same loop under the hood: model thinks, model calls a tool, the tool result comes back, repeat until done. The difference is what each one gives you around that loop, and what it leaves for you to build.

Prerequisites

  • Python 3.10+
  • pip install claude-agent-sdk openai-agents langgraph langchain langchain-anthropic
  • An ANTHROPIC_API_KEY for the Claude SDK and LangGraph examples, an OPENAI_API_KEY for the OpenAI SDK example
  • About 15 minutes

Versions checked against PyPI on August 6, 2026: claude-agent-sdk 0.2.131, openai-agents 0.19.4, langgraph 1.2.10.

The task

One in-memory order store, two tools, one rule.

ORDERS = {
    "ORD-1001": {"status": "shipped", "amount": 249000, "refundable": True},
    "ORD-1002": {"status": "delivered", "amount": 89000, "refundable": False},
}
  • lookup_order(order_id) returns the order or "order not found"
  • issue_refund(order_id, reason) records the refund
  • When the order is not refundable, the agent must escalate to a human instead of just saying no

The same spec appears in all three code samples below. Only the framework changes.

The same agent with the Claude Agent SDK

The Agent SDK is Claude Code as a library. You get the same agent loop, the same built-in tools (read, write, edit files, run commands, search the web), and the same permission system, callable from Python. It is the only one of the three that ships file and shell tools out of the box, which makes it the default choice for agents that work on code.

Custom tools are plain async functions wrapped by @tool. The SDK turns them into an in-process MCP server, so there is no subprocess or JSON-RPC plumbing to debug.

import anyio
from claude_agent_sdk import (
    query,
    tool,
    create_sdk_mcp_server,
    ClaudeAgentOptions,
    AssistantMessage,
    TextBlock,
)

ORDERS = {
    "ORD-1001": {"status": "shipped", "amount": 249000, "refundable": True},
    "ORD-1002": {"status": "delivered", "amount": 89000, "refundable": False},
}

@tool("lookup_order", "Look up an order by its ID", {"order_id": str})
async def lookup_order(args):
    order = ORDERS.get(args["order_id"])
    text = str(order) if order else "order not found"
    return {"content": [{"type": "text", "text": text}]}

@tool("issue_refund", "Issue a refund for an order", {"order_id": str, "reason": str})
async def issue_refund(args):
    text = f"refund issued for {args['order_id']} (reason: {args['reason']})"
    return {"content": [{"type": "text", "text": text}]}

server = create_sdk_mcp_server(
    name="support",
    version="1.0.0",
    tools=[lookup_order, issue_refund],
)

options = ClaudeAgentOptions(
    system_prompt=(
        "You are an order support agent. Use lookup_order to check an order. "
        "If the order is refundable, call issue_refund. If it is not, tell the "
        "customer a human agent will take over."
    ),
    mcp_servers={"support": server},
    allowed_tools=["mcp__support__lookup_order", "mcp__support__issue_refund"],
    max_turns=5,
)

async def main():
    async for message in query(
        prompt="Order ORD-1001 never arrived. I want a refund.",
        options=options,
    ):
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, TextBlock):
                    print(block.text)

anyio.run(main())

Three details worth knowing:

  1. Tool names in allowed_tools follow the pattern mcp__<server>__<tool>. Get the prefix wrong and the model can never call the tool.
  2. allowed_tools is an allowlist for auto-approval. max_turns caps the loop so a confused model cannot burn tokens calling the same tool forever.
  3. query() is for one-shot exchanges. For a multi-turn conversation you use ClaudeSDKClient, which keeps a session open.

The tradeoff is real: the runtime is the bundled Claude Code CLI and the model is Claude. You accept that and you get an agent that can already read, edit, and run code. If you need a different model, this is not the framework for you.

The same agent with the OpenAI Agents SDK

The OpenAI Agents SDK is the production-ready successor to Swarm, and it shows. The primitives are tiny: Agent, Runner, handoffs, guardrails. Tool schemas are generated from your function signature and docstring, so there is no schema to hand-write.

import asyncio
from agents import Agent, Runner
from agents.decorators import tool

ORDERS = {
    "ORD-1001": {"status": "shipped", "amount": 249000, "refundable": True},
    "ORD-1002": {"status": "delivered", "amount": 89000, "refundable": False},
}

@tool
def lookup_order(order_id: str) -> str:
    """Look up an order by its ID. Returns the order details, or 'order not found'."""
    return str(ORDERS.get(order_id, "order not found"))

@tool
def issue_refund(order_id: str, reason: str) -> str:
    """Issue a refund for an order."""
    return f"refund issued for {order_id} (reason: {reason})"

escalation_agent = Agent(
    name="Human Escalation",
    handoff_description="Use when the customer's order cannot be refunded automatically.",
    instructions=(
        "Tell the customer a human agent will take over and end the conversation "
        "politely. Do not promise a refund."
    ),
)

support_agent = Agent(
    name="Support Agent",
    instructions=(
        "You are an order support agent. Use lookup_order to check an order. "
        "If it is refundable, call issue_refund. If it is not, hand off to the "
        "Human Escalation agent."
    ),
    tools=[lookup_order, issue_refund],
    handoffs=[escalation_agent],
    model="gpt-5.6",  # pin a model; omit to use the SDK default
)

async def main():
    result = await Runner.run(
        support_agent,
        "Order ORD-1001 never arrived. I want a refund.",
    )
    print(result.final_output)
    print(f"answered by: {result.last_agent.name}")

asyncio.run(main())

The escalation path is a handoff: a second agent that takes over the conversation. result.last_agent.name tells you which agent actually finished, which is exactly what you want in a support system.

What you do not get: file tools, shell access, or a bundled environment. This SDK is a loop with tracing, sessions, and guardrails around it, not a coding agent runtime. It is model-agnostic through providers like LiteLLM, and tracing is on by default, viewable in the OpenAI dashboard. For a text agent with tools and delegation, it is the fastest of the three to ship.

The same agent with LangGraph

LangGraph is the odd one out: it is a low-level orchestration runtime, not an agent kit. You define the state, the nodes, and the edges, and you decide when the model talks and when your own code runs. The docs are explicit that it is for building bespoke agents that behave exactly the way your application requires. That control is the point, and it is also the cost.

from typing import Literal

from langchain.chat_models import init_chat_model
from langchain.tools import tool
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph

ORDERS = {
    "ORD-1001": {"status": "shipped", "amount": 249000, "refundable": True},
    "ORD-1002": {"status": "delivered", "amount": 89000, "refundable": False},
}

model = init_chat_model("claude-sonnet-4-6", temperature=0)

@tool
def lookup_order(order_id: str) -> str:
    """Look up an order by its ID. Returns the order details, or 'order not found'."""
    return str(ORDERS.get(order_id, "order not found"))

@tool
def issue_refund(order_id: str, reason: str) -> str:
    """Issue a refund for an order."""
    return f"refund issued for {order_id} (reason: {reason})"

tools = [lookup_order, issue_refund]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)

SYSTEM = (
    "You are an order support agent. Use lookup_order to check an order. "
    "If it is refundable, call issue_refund. If it is not, tell the customer "
    "a human agent will take over."
)


def call_model(state: MessagesState):
    return {"messages": [model_with_tools.invoke([SystemMessage(content=SYSTEM)] + state["messages"])]}


def run_tools(state: MessagesState):
    last = state["messages"][-1]
    results = []
    for call in last.tool_calls:
        fn = tools_by_name[call["name"]]
        results.append(ToolMessage(content=str(fn.invoke(call["args"])), tool_call_id=call["id"]))
    return {"messages": results}


def route(state: MessagesState) -> Literal["tools", END]:
    return "tools" if state["messages"][-1].tool_calls else END


builder = StateGraph(MessagesState)
builder.add_node("model", call_model)
builder.add_node("tools", run_tools)
builder.add_edge(START, "model")
builder.add_conditional_edges("model", route, ["tools", END])
builder.add_edge("tools", "model")

agent = builder.compile(checkpointer=InMemorySaver())

result = agent.invoke(
    {"messages": [HumanMessage(content="Order ORD-1001 never arrived. I want a refund.")]},
    config={"configurable": {"thread_id": "ticket-42"}},
)
print(result["messages"][-1].content)

The loop is now visible: model calls the LLM, route checks whether the response contains tool calls, tools executes them, and the edge back to model closes the cycle. You wrote the loop that the other two frameworks hide from you.

That buys three things the others do not have as first-class features:

  1. Durable execution. Compile with a checkpointer and the graph state survives crashes, and the run resumes from the last checkpoint. InMemorySaver works for development, and the docs point to Postgres-backed savers for production.
  2. Human-in-the-loop. interrupt() pauses the graph mid-run, waits for external input, and resumes from the saved state. An approval step is a few lines, not a re-architecture.
  3. Deterministic branches. Escalation does not have to be a model decision. You can add a node that checks refundable in code and routes to a human queue, no LLM involved.

The cost is the learning curve and the boilerplate. For one agent with two tools, LangGraph is the most code per feature. It earns that code when the workflow is long-running, stateful, or regulated, which is the direction the docs themselves push.

What each framework optimizes for

Claude Agent SDK OpenAI Agents SDK LangGraph
Agent loop Claude Code's, prebuilt Built in, minimal You assemble it from nodes
File and shell tools Built in Not included Not included
Multi-agent Subagents Handoffs Subgraphs and supervisor patterns
Persistence SDK-managed sessions Sessions Checkpointers (memory, Postgres, SQLite)
Human in the loop Permission prompts, hooks Built-in mechanisms interrupt() plus checkpointer
Model choice Claude only Many, via LiteLLM and other providers Many, via LangChain integrations
Tracing OpenTelemetry support Built in, OpenAI dashboard Via LangSmith
Runtime Bundled Claude Code CLI Lightweight Python Lightweight Python
Learning curve Low Low Medium to high
Version, Aug 2026 0.2.131 0.19.4 1.2.10

Read the table as three different answers to the same question: who owns the loop, and what comes with it.

The Claude Agent SDK owns the loop and the environment. You get Claude Code's tools and permissions for free, and you give up model choice and a slim runtime.

The OpenAI Agents SDK owns the loop and the ergonomics. You get tracing, sessions, and delegation with almost no boilerplate, and you bring your own tools and environment.

LangGraph gives you the loop. You decide everything, which is exactly what you want when the workflow is the product, and exactly what you do not want when you just need a tool-using chatbot by Friday.

When to use which

  • Build a coding agent, a repo chore bot, or anything that must read, edit, and run code: the Claude Agent SDK. The file and shell tools are the differentiator, and the permission system handles the scary parts.
  • Build a text agent with tools, routing, and delegation, especially on the OpenAI stack: the OpenAI Agents SDK. Handoffs and guardrails cover most production support and triage shapes, and the built-in tracing removes one more thing to set up.
  • Build a long-running, stateful workflow with human approval steps, retries, or audit requirements: LangGraph. Checkpointers and interrupt() are features, not add-ons, and the deterministic nodes keep the critical paths out of the model's hands.
  • Build a one-shot script that calls an LLM once and returns: none of these. Use the raw Responses or Messages API and keep the dependency count at zero.

Verdict

My honest take: the OpenAI Agents SDK is where I would start for a plain text agent with tools and handoffs, because it is the least code for the most working system. The Claude Agent SDK is the pick the moment the agent has to touch files or run commands, since it ships those tools and the permission model to go with them. LangGraph is what I would reach for when the workflow gets stateful and humans need to approve steps, because durable execution and interrupt() solve problems the other two leave for you to bolt on.

The good news is that the loop is the same idea everywhere. Build the same agent once in a second framework, and the concepts transfer. That is the exercise above: same spec, three implementations, and now you know what each one charges for the ride.

Next steps

  • Add a fourth rule to the task, like "refunds above 500000 need approval", and implement it in each framework. That single change makes the frameworks diverge fast.
  • Point the tools at a real order API and add a retry for timeouts.
  • Write ten eval cases: correct tool called, escalation when not refundable, no refund promised. Run them against the same agent in two frameworks.
  • For LangGraph, try the Functional API with @entrypoint and @task if the graph syntax feels heavy.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation