LangGraph, CrewAI, OpenAI Agents SDK
What create_react_agent actually does
Every agent framework ships a prebuilt ReAct agent. Underneath all of them is one while loop, about forty lines of TypeScript, no dependencies.
· 8 min read
Every agent framework ships a one-liner that builds you a working agent. LangGraph has create_react_agent. CrewAI has an Agent with tools attached. The OpenAI Agents SDK has Agent plus a runner. The names differ, the surface differs, and the thing they construct is the same.
The whole mechanism
Here it is with nothing around it. No dependencies, no framework, no hidden state. The only thing abstracted away is the HTTP call to the model, because that part is genuinely provider-specific.
type Msg =
| { role: "user" | "assistant" | "system"; content: string }
| { role: "assistant"; content: null; toolCalls: ToolCall[] }
| { role: "tool"; toolCallId: string; content: string };
interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
type Tool = {
name: string;
description: string;
parameters: object; // JSON Schema
run: (args: any) => Promise<string>;
};
async function runAgent(
messages: Msg[],
tools: Tool[],
callModel: (messages: Msg[], tools: Tool[]) => Promise<Msg>,
maxSteps = 10,
): Promise<Msg[]> {
for (let step = 0; step < maxSteps; step++) {
const reply = await callModel(messages, tools);
messages.push(reply);
// No tool calls means the model is answering, not working. Done.
if (!("toolCalls" in reply) || !reply.toolCalls?.length) return messages;
// Otherwise: run every requested tool, append each result, loop.
for (const call of reply.toolCalls) {
const tool = tools.find((t) => t.name === call.name);
const content = tool
? await tool.run(call.args).catch((e) => `error: ${e.message}`)
: `error: no tool named ${call.name}`;
messages.push({ role: "tool", toolCallId: call.id, content });
}
}
throw new Error(`agent did not finish in ${maxSteps} steps`);
}That is the agent. Everything a framework adds on top is scaffolding around these twenty lines: a way to declare tools, a way to stream partial output, a way to persist the message list between calls, a way to draw the thing as a graph.
Why it gets drawn as a graph
LangGraph presents the same loop as two nodes and a conditional edge, which is a genuinely useful way to see it:
┌───────────┐
────▶│ model │──── no tool calls ────▶ done
│ └─────┬─────┘
│ │ tool calls
│ ┌─────▼─────┐
└────│ tools │
└───────────┘The conditional edge is the if in the loop above: does the last message contain tool calls or not. The cycle back from tools to model is the for. Drawing it this way pays off the moment you want a third node, because adding a branch to a graph is easier to reason about than adding a branch to a loop that is already four levels deep. That is the real argument for graph frameworks, and it is a good one. It is just not a different mechanism.
What the loop does not handle, and neither does the one-liner
The prebuilt version is a starting point in every framework that ships it, and the reasons are the same everywhere:
The step budget is doing real work. A model that keeps calling a failing tool will keep calling it until something stops it. That maxSteps is the difference between a bug and an invoice. Frameworks default it to somewhere between 10 and 25; almost nobody changes it deliberately.
Tool errors are model input. Notice that a thrown tool becomes a message rather than an exception. That is deliberate: the model can often recover from error: file not found by trying a different path. It is also how a badly worded error message turns into three wasted steps, because the model reads it and guesses.
The message list only grows. Every step appends a tool call and a tool result. A twenty-step run with verbose tool output will bury the original question under transcript, and cost grows with the square of the step count because every step resends everything before it. This is the single most common reason a demo agent works and a production one does not.
Parallel tool calls are a choice. The loop above runs them in sequence. Running them concurrently is one Promise.all, and it is right for reads and wrong for writes. No framework can make that call for you, because it depends on what your tools touch.
Why bother knowing this
Because the loop is the stable part. Between them, the popular agent frameworks have renamed their core abstractions several times over, deprecated their own prebuilt constructors, and moved from chains to graphs to handoffs. Through all of it, the mechanism has not moved: call model, run tools, append, repeat, stop on a plain answer.
If you learned the mechanism, every one of those migrations was a rename. If you learned the API, each one was a rewrite.
The same mechanism, with its tradeoffs
Every pattern page runs its code on the page, in TypeScript, Python and Rust.