I Built the Same Agent Three Ways: Raw Python, OpenAI Agents SDK and LangGraph
Agent frameworks do not make language models more intelligent. They organize the software around the model.
To understand what that means, I implemented the same research agent using:
- A handwritten Python runtime
- OpenAI Agents SDK
- LangGraph
The goal was not to find the framework with the fewest lines of code. I wanted to understand:
- What each framework removes
- What guarantees it actually provides
- What remains application responsibility
- How failures and human approvals behave
- Where framework lock-in appears
The conclusion was simple:
OpenAI Agents SDK primarily abstracts the agent loop. LangGraph primarily abstracts stateful workflow execution.
Those sound similar, but they solve different engineering problems.
The research agent
The agent receives a question such as:
How has PostgreSQL's support for vector search evolved?
It can:
- Search the web
- Read selected pages
- Record structured notes and citations
- Decide whether more research is required
- Produce a cited report
- Request human approval before publishing the report
The conceptual workflow is:
flowchart TD
A["Research question"] --> B["Plan or select action"]
B --> C["Search or read"]
C --> D["Update evidence"]
D --> E{"Enough evidence?"}
E -- No --> B
E -- Yes --> F["Draft report"]
F --> G["Human approval"]
G -- Revise --> B
G -- Approve --> H["Publish"]
The model can influence what to search and when to stop. But publishing, iteration limits, approval requirements and persistence are software decisions.
That distinction remains true in every implementation.
What an orchestration framework abstracts
My handwritten loop required code for:
- Calling the model
- Advertising tool schemas
- Parsing tool calls
- Validating arguments
- Dispatching tools
- Returning observations to the model
- Maintaining messages and application state
- Detecting completion
- Limiting turns
- Handling failures
- Pausing for approval
- Serializing pending work
- Producing traces
A framework packages some of these mechanisms behind reusable abstractions.
| Runtime concept | Raw Python | OpenAI Agents SDK | LangGraph |
|---|---|---|---|
| Agent configuration | Dataclass or dictionary | Agent |
Usually a model-calling node |
| Tool registration | Schema plus dispatch table | @function_tool and Agent.tools |
Tool node or ordinary graph node |
| Execution loop | while loop |
Runner |
Graph execution runtime |
| State | Application-defined object | Inputs, context, sessions and RunState |
Typed graph state |
| Transition | if, match, function call |
Model loop, handoff or Python | Edges, conditional edges and Command |
| Checkpoint | Database write | Application-managed; durable RunState for interruptions |
Checkpointer |
| Human pause | Return a custom pending state | Tool approval interruption | interrupt() |
| Resume | Load state and restart loop | Resume serialized RunState |
Invoke with Command(resume=...) |
| Trace | Custom events and spans | Built in | Commonly LangSmith or custom tracing |
The abstractions are different representations of ordinary runtime operations. They do not eliminate those operations.
Version one: handwritten Python
The framework-free runtime made the control flow obvious:
async def run(state: ResearchState) -> RunResult:
while state.turn < MAX_TURNS:
state.turn += 1
response = await call_model(
messages=state.messages,
tools=tool_schemas,
)
state.messages.append(response)
if response.final_answer:
state.draft = response.final_answer
return RunResult.completed(state)
for call in response.tool_calls:
tool = registry.get(call.name)
if tool is None:
state.messages.append(
tool_error(call, "Unknown tool")
)
continue
arguments = tool.validate(call.arguments)
if tool.requires_approval:
await checkpoint_store.save(state)
return RunResult.interrupted(
state=state,
pending_call=call,
)
try:
output = await tool.execute(arguments)
except Exception as error:
output = classify_tool_error(error)
state.messages.append(tool_result(call, output))
raise TurnLimitExceeded()
This is effectively what an agent runtime does:
- Ask the model what to do.
- Interpret the response.
- Execute requested actions.
- Add observations to the next model input.
- Repeat until completion or interruption.
What the handwritten runtime did well
It gave me complete control over:
- State representation
- Retry rules
- Tool permissions
- Persistence format
- Provider APIs
- Trace format
- Recovery behavior
There was almost no conceptual lock-in.
Where the burden appeared
The loop itself was easy. Production semantics were not.
I had to decide:
- Can tools run concurrently?
- What if one concurrent call succeeds and another fails?
- Is a timeout returned to the model or raised to the application?
- What happens if the process crashes after publishing but before recording success?
- How do I reconstruct the exact pending tool call after a two-hour approval delay?
- How do I correlate model, tool and approval events?
- Can an old checkpoint be resumed after the agent definition changes?
The framework-free implementation is clearest at small scale, but every reliability feature becomes application code.
Version two: OpenAI Agents SDK
The OpenAI Agents SDK represents an agent as a model configured with instructions, tools, output expectations, guardrails and possible handoffs. Its Runner owns the model-tool loop. The SDK describes itself as intentionally having a small number of primitives rather than being a general graph engine. OpenAI Agents SDK overview
A simplified implementation looks like this:
from agents import Agent, Runner, function_tool
@function_tool
async def search_web(query: str) -> list[SearchResult]:
return await search_client.search(query)
@function_tool
async def read_page(url: str) -> Page:
return await page_reader.read(url)
@function_tool(needs_approval=True)
async def publish_report(report: str) -> str:
return await publisher.publish(report)
research_agent = Agent(
name="Research agent",
instructions=RESEARCH_INSTRUCTIONS,
tools=[search_web, read_page, publish_report],
output_type=ResearchReport,
)
result = await Runner.run(
research_agent,
"Research PostgreSQL vector-search evolution.",
max_turns=12,
)
Runner now handles:
- Repeated model calls
- Tool schema generation
- Argument validation
- Tool invocation
- Returning tool results to the model
- Final-output detection
- Turn limits
- Handoffs
- Trace generation
This is the shortest of the three implementations because the research agent is fundamentally a model-controlled tool loop.
Handoffs in ordinary runtime terms
A handoff means:
- The current model selects another agent.
- The runtime changes the active agent configuration.
- Relevant conversation state is transferred.
- The loop continues using the new agent.
The SDK exposes handoffs to the model as tools such as transfer_to_writer_agent. OpenAI handoffs documentation
This is useful when ownership should change—for example, from a researcher to a citation auditor. If the researcher should remain in control and only request a subtask, treating another agent as a tool is usually the closer abstraction.
State is not one thing
The Agents SDK has several state-related concepts:
- Input items: what the model has seen during the run
- Context: application objects available to tools and hooks
- Sessions: conversation history across runs
RunState: a serializable runtime snapshot used for interruption and resumption
A session is not automatically a durable workflow engine. It primarily maintains conversation history. Agents SDK sessions
For human approval, interrupted results can be converted into RunState, serialized, stored and later resumed. The state includes pending interruptions, generated items and runtime metadata. Agents SDK human-in-the-loop
Conceptually:
result = await Runner.run(research_agent, question)
if result.interruptions:
state = result.to_state()
await database.save(state.to_json())
return "Waiting for approval"
Later:
state = RunState.from_json(await database.load(run_id))
state.approve(state.get_interruptions()[0])
result = await Runner.run(research_agent, state)
Important durability boundary
RunState provides a durable pause-and-resume boundary for interruptions. That is not identical to automatically checkpointing an arbitrary workflow after every application step.
For fully durable, long-running execution across crashes and retries, the SDK documentation points to integrations with systems such as Dapr, Temporal, Restate and DBOS. Agents SDK durable-execution integrations
Therefore:
The Agents SDK gives me resumable agent interruptions. A general durable workflow may still require an external orchestration system.
Error handling
Function-tool failures can be converted into model-visible error observations, customized through an error function or re-raised to application code. Async tools also support timeout behavior. Agents SDK tools
But I still decide:
- Which failures are retryable
- Whether the model should see internal error details
- Whether a failed write may be repeated
- How retries interact with idempotency
- When a failure should require human escalation
The framework transports errors. It cannot choose the business-safe recovery policy for me.
Version three: LangGraph
LangGraph begins from a different abstraction:
An agent workflow is state transformed by nodes connected through transitions.
Instead of hiding the loop, I represent it explicitly:
class ResearchState(TypedDict):
messages: list
sources: list[Source]
notes: list[Note]
draft: str | None
approved: bool
iteration: int
Then I define operations:
builder = StateGraph(ResearchState)
builder.add_node("researcher", call_research_model)
builder.add_node("tools", execute_research_tools)
builder.add_node("draft", create_draft)
builder.add_node("approval", request_approval)
builder.add_node("publish", publish_report)
builder.add_edge(START, "researcher")
builder.add_conditional_edges(
"researcher",
choose_next_step,
{
"tools": "tools",
"draft": "draft",
"stop": END,
},
)
builder.add_edge("tools", "researcher")
builder.add_edge("draft", "approval")
builder.add_conditional_edges(
"approval",
approval_route,
{
"approved": "publish",
"revise": "researcher",
},
)
builder.add_edge("publish", END)
graph = builder.compile(checkpointer=checkpointer)
LangGraph executes nodes in discrete super-steps. Nodes produce state updates, while edges determine which node runs next. LangGraph Graph API
What this representation changes
In the Agents SDK version, the main control loop is implicit inside Runner.
In LangGraph, the outer workflow is visible:
- Research
- Tool execution
- Drafting
- Approval
- Publishing
The model can still choose a tool or research direction inside a node. But it cannot bypass the approval node unless my graph routing code permits it.
That makes LangGraph especially useful when an application combines:
- Deterministic business processes
- Model-driven decisions
- Parallel branches
- Human review
- Long waits
- Recoverable execution
Checkpointing
A LangGraph checkpointer persists graph state for a thread. Stores separately hold long-term information that should exist outside one execution thread. LangGraph persistence
This distinction matters:
- Checkpoint: where this workflow currently is
- Store: reusable information that may outlive this workflow
With a persistent checkpointer and a thread_id, the runtime can reconstruct the graph's progress after interruption or failure.
Human interruption and resumption
A node can call interrupt():
def request_approval(state: ResearchState):
decision = interrupt({
"type": "publish_report",
"draft": state["draft"],
})
return {"approved": decision["approved"]}
The graph is resumed using the same thread and a resume command:
graph.invoke(
Command(resume={"approved": True}),
config={"configurable": {"thread_id": run_id}},
)
LangGraph persists the graph state and associates it with the thread ID. LangGraph interrupts
The hidden assumption: replay
Durable execution does not mean every Python instruction continues from the exact machine instruction where it stopped.
A node may be re-entered from a checkpoint. Therefore, side effects must be:
- Idempotent
- Moved into separately checkpointed tasks
- Protected by idempotency keys
- Or verified before repetition
For example, this is unsafe:
charge_customer()
interrupt("Approve sending the receipt?")
If the node is replayed, the charge may happen again.
A safer design separates the side effect into its own node and gives it a transaction identifier.
Checkpointing helps recovery, but it does not create exactly-once side effects.
Tracing and evaluation
The Agents SDK automatically creates traces for runs, model generations, tools, handoffs and guardrails. Tracing is enabled by default and supports custom processors. Agents SDK tracing
LangGraph's open-source runtime can be instrumented independently, but the closely integrated tracing and evaluation product is LangSmith. LangSmith can evaluate whole graphs, individual nodes and intermediate trajectories. Evaluating LangGraph applications
Neither system decides what "good research" means.
My framework-independent evaluation contract still needs metrics such as:
- Required claims supported by citations
- Citation URL actually contains the attributed claim
- No unsupported factual claims
- Minimum source diversity
- Maximum tool-call budget
- No publication without approval
- Recovery does not duplicate publication
- Final answer produced within the iteration budget
The test dataset and assertions should call an adapter such as:
result = await research_system.run(test_case.question)
trajectory = result.normalized_trajectory
Tests should not directly depend on RunResult, LangGraph message classes or a custom loop's internal event types. That separation makes framework migration possible.
Comparison
The line counts below are approximate orchestration-only counts for a small implementation. Shared tool clients, prompts, schemas, tests and deployment code are excluded.
| Criterion | Raw Python | OpenAI Agents SDK | LangGraph |
|---|---|---|---|
| Approximate orchestration LOC | 150–220 | 50–90 | 100–160 |
| Agent-loop clarity | Highest | Mostly implicit | Explicit as a graph |
| State control | Complete but manual | Moderate | Strong and structured |
| Checkpointing | Entirely manual | Strong for interrupted RunState |
First-class graph checkpoints |
| Human approval | Manual protocol | Tool-centric approval | Arbitrary workflow interruption |
| Resumability | Whatever I build | Native for approval interruptions | Native from graph checkpoints |
| Tracing | Manual | Built in | Commonly through LangSmith |
| Evaluation integration | Manual | OpenAI tracing/evaluation ecosystem | LangSmith integration |
| Provider flexibility | Highest | Supports custom providers, with feature differences | High; nodes can call any provider |
| Testing | Simple primitives, more infrastructure | Easy agent/tool tests | Easy node and routing tests |
| Operational complexity | Low initially, grows quickly | Lowest for short agent runs | Higher persistence/runtime burden |
| Framework lock-in | Low | Agent, result, handoff and trace types | State, reducer, graph and checkpoint semantics |
| Failure recovery | Entirely application-defined | Good run errors and durable interruptions | Strong step-level recovery |
| Added latency | Minimal | Usually negligible beside model calls | Checkpoint writes add measurable I/O |
Provider support needs qualification. The Agents SDK allows custom model implementations and OpenAI-compatible providers, but provider-specific API shapes do not necessarily support the same tools and features. Agents SDK model-provider support
LangGraph does not require LangChain model integrations: a node can call any Python client. Using LangChain's prebuilt model and tool components is convenient, but it increases dependency on their message and tool abstractions.
Lock-in is more than imports
Framework lock-in appears when persisted or operational behavior depends on framework concepts.
OpenAI Agents SDK lock-in
Potential dependencies include:
AgentandRunnerlifecycle- SDK tool schemas and result items
- Handoff history behavior
RunStateserialization- Guardrail semantics
- OpenAI trace representation
- Features available only through a particular model API
The underlying tools remain ordinary Python functions, so they are relatively portable.
LangGraph lock-in
Potential dependencies include:
- Graph state schema and reducers
- Node names
- Edge and
Commandrouting - Interrupt payloads
- Checkpoint namespaces
- Serialized checkpoint representation
- LangSmith operational integrations
Node functions are usually portable. Persisted in-flight workflows are harder to migrate because the new runtime must understand where every suspended execution should continue.
The best defence is to keep domain logic outside framework objects:
async def perform_search(query: SearchQuery) -> SearchResults:
...
Then write thin adapters for raw Python, the Agents SDK and LangGraph.
When handwritten orchestration is enough
I would keep the handwritten loop when:
- There is one agent
- The run is short-lived
- Tools are mostly read-only
- Control flow is a simple model-tool loop
- There are no long human waits
- Restarting the run is acceptable
- Custom provider portability matters
- The team understands and tests the loop
A 60-line loop is often easier to maintain than a framework introduced only to avoid writing a 60-line loop.
I would not keep it handwritten once the application requires several of these simultaneously:
- Durable resumption
- Multiple approval points
- Parallel branches
- Long-running work
- Persistent execution state
- Time-travel debugging
- Complex retry boundaries
- Multiple agent ownership transitions
- Operational inspection of in-flight runs
At that point I would be rebuilding a workflow runtime.
Choosing between the two frameworks
Choose OpenAI Agents SDK when the central abstraction is:
A model repeatedly uses tools until it completes a task.
It is a strong fit when:
- The workflow is model-led
- You want minimal orchestration code
- Handoffs map naturally to specialized agents
- Tool approvals are the main interruption point
- Built-in agent traces are valuable
- OpenAI models and tools are the primary execution environment
Choose LangGraph when the central abstraction is:
A stateful business process contains both deterministic and model-driven steps.
It is a strong fit when:
- Transitions must be explicit
- Different state fields have controlled update rules
- The workflow must survive failures
- Humans may interrupt at arbitrary points
- Runs may continue for hours or days
- You need to inspect, replay or branch from checkpoints
- Multiple providers or non-LLM nodes are first-class components
They can also be combined. An OpenAI Agents SDK agent can run inside a LangGraph node: LangGraph owns the durable outer workflow, while the Agents SDK owns the inner agent loop. That is useful, but introduces both dependency surfaces and should be justified by real requirements.
Architecture Decision Record
ADR-001: Orchestration runtime for the research agent
Status: Accepted for initial implementation
Decision: Use OpenAI Agents SDK for the first production version. Keep tools, evaluation schemas and persistence interfaces framework-independent. Reconsider LangGraph when the workflow requires general step-level recovery or multiple deterministic approval stages.
Context
The research agent:
- Runs for minutes rather than days
- Primarily follows a model-tool loop
- Uses read-only research tools
- Requires approval only before publication
- Needs tool and model traces
- Does not currently require parallel durable branches
- Can restart research after most failures
Why OpenAI Agents SDK
It removes the largest amount of undifferentiated loop code while matching the agent's natural structure. Its tool approval and serialized RunState cover the current human-interruption requirement.
LangGraph would provide stronger general checkpointing and explicit transitions, but the initial workflow does not yet justify the additional graph and persistence machinery.
Consequences
Positive:
- Smaller orchestration layer
- Built-in tool dispatch and tracing
- Typed tool arguments and outputs
- Native handoffs if specialist agents are introduced
- Durable approval pauses
Negative:
- Less explicit state-transition structure
- General crash recovery still requires application boundaries or another durable orchestrator
- Some behavior depends on SDK and provider semantics
- Migration of pending
RunStateobjects would require special handling
Migration trigger
Move the outer workflow to LangGraph if two or more of these become necessary:
- Recovery from intermediate research steps without repeating them
- Multiple approval or editing stages
- Parallel research branches
- Scheduled continuation over long periods
- Explicit deterministic compliance routes
- Inspection and modification of in-flight state
- Provider-independent durable orchestration
Portability requirement
The following remain framework-neutral:
- Search and page-reading services
- Publication service
- Domain state models
- Evaluation dataset
- Trajectory event schema
- Permission policy
- Idempotency implementation
- Citation validators
This makes the framework a replaceable runtime layer instead of the application's domain model.
The most useful decision rule is therefore:
Choose the smallest runtime that provides the recovery and control guarantees your system actually requires.
Raw Python maximizes control. OpenAI Agents SDK minimizes model-loop machinery. LangGraph makes state transitions and durable workflow execution explicit. None of them replaces careful tool design, authorization, idempotency, evaluation or operational ownership.