September 1, 2026
The Best Agent Framework in 2026: LangGraph vs OpenAI Agents SDK vs Claude Agent SDK
Three frameworks solved the same problem with three completely different primitives.

By Anubhav
11 min read
Read the article for free here.
LangGraph provides a graph runtime where nodes are functions and edges handle transitions. The OpenAI Agents SDK relies on handoffs, letting agents delegate work to other agents through typed sessions and native MCP integration. The Claude Agent SDK takes a less obvious path by giving the agent a computer. Anthropic built their framework around bash execution, file writes, and a hook system because they believe a capable agent needs a real shell.
Choosing between them depends on a question most architectural comparisons skip. What is the shape of the work?
The Three Primitives
LangGraph is designed for stateful workflows. You define a state schema, and that typed object flows through the graph from start to finish. Checkpointers persist this state at every node. If a server crashes or a workflow pauses for human approval, the run can be resumed later without losing context. LangChain 1.0 reached general availability on October 22, 2025, and the standard create_agent call in that release is a facade over this graph runtime. The old debate over whether to use LangChain or LangGraph is over.
The OpenAI Agents SDK operates on nine core building blocks, including Agents, Handoffs, Tools, Guardrails, Sessions, and Realtime. The atomic unit is an Agent handing off a task to another Agent. Provider agnosticism arrived in 2025 with support for over 100 language models via LiteLLM. The SDK treats MCP as a first-class tool type and ships voice capabilities natively using the gpt-realtime-2 line of models.
Anthropic built the Claude Agent SDK around a shell environment. Developers get Bash, Read, Write, Edit, Glob, and Grep tools alongside lifecycle hooks like PreToolUse, PostToolUse, and Stop. Subagents spawn with their own isolated context windows. Anthropic renamed this from the Claude Code SDK in late 2025 to reflect that the harness powers deep research, video creation, and note-taking alongside standard coding tasks. Teams are restricted to Claude models but have deployment options across Bedrock, Vertex, and Azure Foundry.
A 2025 Belief to Drop
A persistent assumption from last year is that the OpenAI Agents SDK forces you to use OpenAI models. That claim is about 15 months out of date. The official repository supports the OpenAI Responses API alongside 100 other models via LiteLLM and Any-LLM integrations.
# Install: pip install "openai-agents[litellm]"
# Env: export GEMINI_API_KEY=...
import os
from agents import Agent, Runner, function_tool
from agents.extensions.models.litellm_model import LitellmModel
@function_tool
def current_time_utc() -> str:
"""Return the current UTC time as an ISO-8601 string."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# OpenAI Agents SDK using Gemini via LiteLLM. No OpenAI key required.
gemini_model = LitellmModel(
model="gemini/gemini-2.5-pro",
api_key=os.environ["GEMINI_API_KEY"],
)
agent = Agent(
name="time-agent",
instructions="Answer time questions using the current_time_utc tool.",
model=gemini_model,
tools=[current_time_utc],
)
result = Runner.run_sync(agent, "What is the current UTC time?")
print(result.final_output)
# -> "The current UTC time is 2026-07-06T14:32:11+00:00."# Install: pip install "openai-agents[litellm]"
# Env: export GEMINI_API_KEY=...
import os
from agents import Agent, Runner, function_tool
from agents.extensions.models.litellm_model import LitellmModel
@function_tool
def current_time_utc() -> str:
"""Return the current UTC time as an ISO-8601 string."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# OpenAI Agents SDK using Gemini via LiteLLM. No OpenAI key required.
gemini_model = LitellmModel(
model="gemini/gemini-2.5-pro",
api_key=os.environ["GEMINI_API_KEY"],
)
agent = Agent(
name="time-agent",
instructions="Answer time questions using the current_time_utc tool.",
model=gemini_model,
tools=[current_time_utc],
)
result = Runner.run_sync(agent, "What is the current UTC time?")
print(result.final_output)
# -> "The current UTC time is 2026-07-06T14:32:11+00:00."The LitellmModel(model="gemini/gemini-2.5-pro") instantiation proves the framework can route outside the OpenAI ecosystem. The @function_tool decorator behaves the same way regardless of the underlying model. Non-OpenAI paths are documented as best-effort, and feature parity varies by provider. With strict model constraints off the table, these frameworks now compete on their architectural primitives.
Introducing Meridian
Take a mid-size fintech we will call Meridian. Their engineering department is building four distinct agents across different teams. One group needs a customer support voice agent to handle billing questions over the phone. Another team is building a multi-agent refund workflow that validates fraud rules before handing the ticket to finance. The developer experience team wants an internal code migration agent that can walk a large Java repository, apply syntax transformations, and open pull requests. Finally, the platform team is assembling an ops dashboard that queries Sentry, Linear, PagerDuty, and Postgres via MCP servers.
We can watch Meridian build these four workloads to see which framework fits best.
Workload 1: Voice and Realtime Streaming
The voice team faces a strict latency budget. They need sub-500ms streaming for their billing support agent. Interrupts matter. Handling user barge-in is a requirement. Wrapping a standard text LLM in a speech-to-text and text-to-speech loop is too slow because latency accumulates at every network hop.
The OpenAI Agents SDK handles this workload well. The gpt-realtime-2 model natively streams audio tokens, and the RealtimeAgent provides the wrapper around this capability. Neither LangGraph nor the Claude Agent SDK offers a native realtime primitive at this level in mid-2026.
# Install: pip install openai-agents
# Env: export OPENAI_API_KEY=...
import asyncio
from agents import function_tool
from agents.realtime import RealtimeAgent, RealtimeRunner
@function_tool
def lookup_billing_balance(account_id: str) -> str:
"""Return the current outstanding balance for an account."""
# In production, this hits the billing service. Here it is a stub.
return "42.17 USD outstanding as of 2026-07-06."
voice_agent = RealtimeAgent(
name="meridian-billing-voice",
instructions=(
"You are Meridian's billing voice assistant. Answer politely, briefly. "
"Confirm the account_id before disclosing any balance."
),
tools=[lookup_billing_balance],
)
async def main():
runner = RealtimeRunner(
starting_agent=voice_agent,
config={"model_settings": {"model_name": "gpt-realtime-2.1"}},
)
# session handles the audio stream and tool calls
session = await runner.run()
async with session:
# Wire the audio input source here via sounddevice or pyaudio
async for event in session:
if event.type == "history_updated":
# The item contains the finalized transcript once the turn ends
print(f"History updated with item: {event.item}")
elif event.type == "error":
print(f"Error: {event.error}")
break
asyncio.run(main())# Install: pip install openai-agents
# Env: export OPENAI_API_KEY=...
import asyncio
from agents import function_tool
from agents.realtime import RealtimeAgent, RealtimeRunner
@function_tool
def lookup_billing_balance(account_id: str) -> str:
"""Return the current outstanding balance for an account."""
# In production, this hits the billing service. Here it is a stub.
return "42.17 USD outstanding as of 2026-07-06."
voice_agent = RealtimeAgent(
name="meridian-billing-voice",
instructions=(
"You are Meridian's billing voice assistant. Answer politely, briefly. "
"Confirm the account_id before disclosing any balance."
),
tools=[lookup_billing_balance],
)
async def main():
runner = RealtimeRunner(
starting_agent=voice_agent,
config={"model_settings": {"model_name": "gpt-realtime-2.1"}},
)
# session handles the audio stream and tool calls
session = await runner.run()
async with session:
# Wire the audio input source here via sounddevice or pyaudio
async for event in session:
if event.type == "history_updated":
# The item contains the finalized transcript once the turn ends
print(f"History updated with item: {event.item}")
elif event.type == "error":
print(f"Error: {event.error}")
break
asyncio.run(main())The RealtimeAgent and RealtimeSession classes manage the audio-in and audio-out streams alongside the tool-calling loop. Tools defined with @function_tool work inside a realtime session just like they do inside a text agent. The framework exposes the event loop while abstracting the WebSocket state management.
To build this in LangGraph or the Claude Agent SDK, a team would have to write custom WebSocket handling, voice activity detection, and complex interrupt semantics. That becomes a media pipeline project rather than an agent project.
If a company policy dictates that the voice model must be Claude or Gemini, the answer changes. Anthropic added realtime capabilities later, and teams should verify feature parity if reading this after mid-2026.
Workload 2: Durable Multi-Agent Orchestration with HITL
The ops team is building the refund flow. The workflow requires a fraud check followed by a finance approval handoff. It must wait for human review on refunds above $500. It must write the final decision back to a Postgres database. The system needs to survive server crashes and handle asynchronous human-in-the-loop pauses without losing context.
LangGraph fits this workload. The combination of checkpointers and the interrupt primitive provides the exact control flow required for long-running business processes.
Real engineering teams validate this architecture in production. Uber saved roughly 21,000 developer hours using LangGraph for their AutoCover and Validator systems. LinkedIn published a paper detailing a hierarchical semantic memory tree for their Hiring Assistant, while their supervisor and subagent architecture is documented separately in LangChain engineering talks.
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
from langchain_google_genai import ChatGoogleGenerativeAI
# LangGraph is provider-agnostic. Here it uses Gemini.
llm = ChatGoogleGenerativeAI(model="gemini-2.5-pro", temperature=0)
class RefundState(TypedDict):
order_id: str
amount_usd: float
customer_reason: str
fraud_risk: Literal["low", "medium", "high"] | None
finance_decision: Literal["approved", "denied"] | None
human_review_needed: bool
def fraud_check(state: RefundState) -> RefundState:
"""Run the LLM-backed fraud check against the customer's stated reason."""
prompt = (
f"Assess fraud risk for refund of ${state['amount_usd']:.2f}. "
f"Customer reason: {state['customer_reason']!r}. "
"Respond with one word: low, medium, or high."
)
verdict = llm.invoke(prompt).content.strip().lower()
if verdict not in {"low", "medium", "high"}:
verdict = "high" # fail-closed on ambiguous LLM output
return {**state, "fraud_risk": verdict}
def finance_approval(state: RefundState) -> RefundState:
"""Above $500 or medium risk, pause for a human. Otherwise auto-approve."""
needs_human = state["amount_usd"] > 500 or state["fraud_risk"] in {"medium", "high"}
if needs_human:
# Pause the graph. On resume, interrupt returns the human's decision.
human_decision = interrupt({
"order_id": state["order_id"],
"amount_usd": state["amount_usd"],
"fraud_risk": state["fraud_risk"],
"prompt": "Approve (yes/no)?",
})
return {**state, "human_review_needed": True, "finance_decision": human_decision}
return {**state, "human_review_needed": False, "finance_decision": "approved"}
# Build the graph
graph = StateGraph(RefundState)
graph.add_node("fraud_check", fraud_check)
graph.add_node("finance_approval", finance_approval)
graph.add_edge(START, "fraud_check")
graph.add_conditional_edges(
"fraud_check",
lambda s: "finance_approval" if s["fraud_risk"] != "high" else END,
)
graph.add_edge("finance_approval", END)
# Checkpointer. For production, swap MemorySaver for PostgresSaver.
compiled = graph.compile(checkpointer=MemorySaver())
# Run it. Interrupt fires on the $850 refund and the graph pauses.
config = {"configurable": {"thread_id": "order-4291"}}
result = compiled.invoke(
{
"order_id": "4291",
"amount_usd": 850.00,
"customer_reason": "arrived damaged, no photo",
"fraud_risk": None,
"finance_decision": None,
"human_review_needed": False,
},
config=config,
)
# Later, a human reviewer says yes. Resume with Command.
final = compiled.invoke(Command(resume="approved"), config=config)from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command
from langchain_google_genai import ChatGoogleGenerativeAI
# LangGraph is provider-agnostic. Here it uses Gemini.
llm = ChatGoogleGenerativeAI(model="gemini-2.5-pro", temperature=0)
class RefundState(TypedDict):
order_id: str
amount_usd: float
customer_reason: str
fraud_risk: Literal["low", "medium", "high"] | None
finance_decision: Literal["approved", "denied"] | None
human_review_needed: bool
def fraud_check(state: RefundState) -> RefundState:
"""Run the LLM-backed fraud check against the customer's stated reason."""
prompt = (
f"Assess fraud risk for refund of ${state['amount_usd']:.2f}. "
f"Customer reason: {state['customer_reason']!r}. "
"Respond with one word: low, medium, or high."
)
verdict = llm.invoke(prompt).content.strip().lower()
if verdict not in {"low", "medium", "high"}:
verdict = "high" # fail-closed on ambiguous LLM output
return {**state, "fraud_risk": verdict}
def finance_approval(state: RefundState) -> RefundState:
"""Above $500 or medium risk, pause for a human. Otherwise auto-approve."""
needs_human = state["amount_usd"] > 500 or state["fraud_risk"] in {"medium", "high"}
if needs_human:
# Pause the graph. On resume, interrupt returns the human's decision.
human_decision = interrupt({
"order_id": state["order_id"],
"amount_usd": state["amount_usd"],
"fraud_risk": state["fraud_risk"],
"prompt": "Approve (yes/no)?",
})
return {**state, "human_review_needed": True, "finance_decision": human_decision}
return {**state, "human_review_needed": False, "finance_decision": "approved"}
# Build the graph
graph = StateGraph(RefundState)
graph.add_node("fraud_check", fraud_check)
graph.add_node("finance_approval", finance_approval)
graph.add_edge(START, "fraud_check")
graph.add_conditional_edges(
"fraud_check",
lambda s: "finance_approval" if s["fraud_risk"] != "high" else END,
)
graph.add_edge("finance_approval", END)
# Checkpointer. For production, swap MemorySaver for PostgresSaver.
compiled = graph.compile(checkpointer=MemorySaver())
# Run it. Interrupt fires on the $850 refund and the graph pauses.
config = {"configurable": {"thread_id": "order-4291"}}
result = compiled.invoke(
{
"order_id": "4291",
"amount_usd": 850.00,
"customer_reason": "arrived damaged, no photo",
"fraud_risk": None,
"finance_decision": None,
"human_review_needed": False,
},
config=config,
)
# Later, a human reviewer says yes. Resume with Command.
final = compiled.invoke(Command(resume="approved"), config=config)The pause-and-resume pattern shows what durable execution looks like in code. The interrupt() function inside the finance approval node writes the current state to the checkpointer. The system can shut down at this point. Hours later, the Command(resume="approved") call picks up where the graph paused using the same thread ID. It does not re-run the fraud check.
Durability requires a distinction here. The open-source runtime persists state to MemorySaver, which lives in process memory. Real crash recovery under load requires LangGraph Platform backed by Postgres or a custom implementation of PostgresSaver.
The OpenAI Agents SDK offers Sessions to handle conversation state and Sandbox Agents for long-horizon work. It lacks a direct equivalent to LangGraph's node-level checkpointing across an arbitrary graph. The Claude Agent SDK provides JSONL session state locally or Managed Agents for hosted durability but restricts teams to Claude models. LangGraph currently owns the durable orchestration space.
Workload 3: Coding-Adjacent and File-and-Shell Centric
The developer experience team is building an internal code migration agent. The agent needs to walk a 400,000-line Java repository, apply syntax transformations, run unit tests, and open pull requests. The agent needs to read files, edit them, execute shell commands, and iterate based on compiler errors. Bash and Edit are the entire workload.
The Claude Agent SDK maps well to what the DX team needs. Anthropic's thesis is to give your agents a computer.
The framework provides native tools for Bash, Read, Write, Edit, Glob, and Grep. It includes a hook system acting as a control surface. A PreToolUse hook can enforce security guardrails to prevent destructive commands. A PostToolUse hook can trigger formatters or linters. Subagents parallelize the transformation work with isolated contexts, meaning a long compiler error log passed to a subagent does not pollute the main agent's context window.
# Install: pip install claude-agent-sdk
# Env: export ANTHROPIC_API_KEY=...
import anyio
from claude_agent_sdk import (
ClaudeSDKClient,
ClaudeAgentOptions,
AgentDefinition,
HookMatcher,
)
# PreToolUse hook: block Bash calls that look like rm -rf
async def block_dangerous_bash(input_data, tool_use_id, context):
if input_data.get("tool_name") == "Bash":
cmd = input_data.get("tool_input", {}).get("command", "")
if "rm -rf" in cmd or "rm -rf" in cmd:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm -rf blocked by policy",
}
}
return {}
# Subagent: runs in isolated context to lint one file
lint_agent = AgentDefinition(
description="Run linters on a single file and return a concise report.",
prompt=(
"You are the lint subagent. Given a file path, run the project's linter "
"on it and return a one-paragraph summary of failures. Do not fix anything."
),
tools=["Bash", "Read"], # Note: tools is deprecated in favor of skills in recent SDKs
)
options = ClaudeAgentOptions(
system_prompt=(
"You are Meridian's code migration agent. Walk the target directory, "
"apply the migration, run tests, and open a PR. Prefer small commits."
),
allowed_tools=["Bash", "Read", "Write", "Edit", "Glob", "Grep"],
hooks={"PreToolUse": [HookMatcher(hooks=[block_dangerous_bash])]},
agents={"lint": lint_agent},
# resume="mig-run-2026-07-06-01", # uncomment to resume a prior session
)
async def main():
async with ClaudeSDKClient(options=options) as client:
await client.query(
"Migrate services/payments/ from Java 17 to Java 21. "
"For every file you touch, delegate to the `lint` subagent afterward. "
"Do NOT commit or open PRs yet. Stop after changes are on disk."
)
async for message in client.receive_response():
print(message)
anyio.run(main)# Install: pip install claude-agent-sdk
# Env: export ANTHROPIC_API_KEY=...
import anyio
from claude_agent_sdk import (
ClaudeSDKClient,
ClaudeAgentOptions,
AgentDefinition,
HookMatcher,
)
# PreToolUse hook: block Bash calls that look like rm -rf
async def block_dangerous_bash(input_data, tool_use_id, context):
if input_data.get("tool_name") == "Bash":
cmd = input_data.get("tool_input", {}).get("command", "")
if "rm -rf" in cmd or "rm -rf" in cmd:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "rm -rf blocked by policy",
}
}
return {}
# Subagent: runs in isolated context to lint one file
lint_agent = AgentDefinition(
description="Run linters on a single file and return a concise report.",
prompt=(
"You are the lint subagent. Given a file path, run the project's linter "
"on it and return a one-paragraph summary of failures. Do not fix anything."
),
tools=["Bash", "Read"], # Note: tools is deprecated in favor of skills in recent SDKs
)
options = ClaudeAgentOptions(
system_prompt=(
"You are Meridian's code migration agent. Walk the target directory, "
"apply the migration, run tests, and open a PR. Prefer small commits."
),
allowed_tools=["Bash", "Read", "Write", "Edit", "Glob", "Grep"],
hooks={"PreToolUse": [HookMatcher(hooks=[block_dangerous_bash])]},
agents={"lint": lint_agent},
# resume="mig-run-2026-07-06-01", # uncomment to resume a prior session
)
async def main():
async with ClaudeSDKClient(options=options) as client:
await client.query(
"Migrate services/payments/ from Java 17 to Java 21. "
"For every file you touch, delegate to the `lint` subagent afterward. "
"Do NOT commit or open PRs yet. Stop after changes are on disk."
)
async for message in client.receive_response():
print(message)
anyio.run(main)The allowed_tools list defines the agent's environment. The hook system provides a guardrail surface that runs inside the agent loop rather than around it. The PreToolUse hook fires before the tool executes. Returning a deny decision blocks the call immediately.
The lint subagent has its own tool list and context window. Anthropic marks the tools field on AgentDefinition as deprecated in favor of skills in recent SDK updates, though it still works today. The commented resume line shows how session state is picked up from JSONL files on the local filesystem.
LangGraph requires teams to build these shell primitives as custom Python functions. OpenAI Agents SDK Sandbox Agents run in containerized environments which is architecturally similar, but the primitive set is not natively named or exposed in the same file-centric way.
The caveat is model lock-in. If a team has a hard rule against vendor lock-in on the model layer, the Claude Agent SDK is a non-starter. LangGraph becomes the fallback, and the team will have to rebuild the bash and edit primitives themselves.
Workload 4: MCP-Heavy Tool Orchestration
The platform team is building a one-question ops dashboard. An engineer can ask a question, and the agent queries Sentry for errors, Linear for tickets, PagerDuty for incidents, and Postgres for user data. The agent spends most of its time orchestrating third-party MCP servers and a fraction of its time synthesizing the final answer.
This workload is a tie between the OpenAI Agents SDK and the Claude Agent SDK. Both treat MCP as a first-class primitive that behaves like local functions. LangGraph supports MCP, but the ergonomics require more boilerplate as of mid-2026. Teams using LangGraph often end up wrapping MCP servers as tools by hand.
The choice between OpenAI and Claude for an MCP-heavy workload comes down to secondary constraints.
Pick the OpenAI Agents SDK if the system requires first-party tracing, strict guardrails, and handoffs to specialist sub-agents. It makes sense if the company wants to route queries to different model providers via LiteLLM to save costs on simple synthesis tasks.
Pick the Claude Agent SDK if the system benefits from the hook lifecycle, strict subagent isolation, and Claude's tool-use reliability.
Writing custom API wrappers for standard SaaS tools consumes engineering time. MCP standardizes context and tool discovery. The framework just needs to get out of the way and let the agent talk to the server. Both OpenAI and Anthropic built their SDKs to support this pattern natively.
The Decision Matrix
On CrewAI
A note on CrewAI. It is the framework many developers expect in a comparison like this. When I ran verification, the primary evidence for CrewAI's current version, Flows adoption, and enterprise case studies did not hold up the way LangGraph's, OpenAI's, and Anthropic's did. Rather than pad the comparison with weaker sourcing, I scoped it out.
The Real Choice
Meridian built four agents using three different frameworks. The voice team and the platform team used the OpenAI SDK. The ops team used LangGraph. The code migration team used the Claude Agent SDK.
Framework wars are the wrong frame for engineering decisions. The problem is matching the framework's primary primitive to the shape of the workload.
The next round of the ecosystem will likely re-argue durability as LangGraph Platform competes with Anthropic Managed Agents. We will probably see LangGraph add first-class realtime primitives or Anthropic open their SDK to other model providers. Until then, the primitives dictate the architecture.
if this helped, clap ๐ so others can find it too โ and if you want the shorter, sharper cuts of stuff like this, I also post notes on substack. Further reading:
What Actually Happens When You Call graph.invoke() โ LangGraph runtime internals; what the checkpointer, channels, and step loop actually do.
I Spent 6 Months Tuning Claude Code. Here's the Exact Setup That Finally Worked: Production setup wisdom from six months on Claude Code and the Agent SDK.
LangGraph vs Temporal for AI Agents: Durable Execution Architecture Beyond For Loops: Durable execution deeper, LangGraph's checkpointer versus Temporal's mature workflow engine.
Multi-Agent Systems: When 2 Agents Beat 1 (and When They Don't): When handoffs and subagents actually pay off, and when they don't.
What Is Agent Harness Engineering? The Architecture Doing 80% of the Work in Every Production Agent: The meta-frame this comparison sits inside, why primitive choice dominates outcomes.