- Book: AI That Plans
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Adopting a graph framework for a first agent is a common default and
frequently the wrong call. Not because LangGraph.js is bad — it is
well built and it solves real problems — but because most first
agents do not yet have the problems it solves, and the abstraction
cost is paid immediately.
Here is the same agent both ways, and the four capabilities that
decide it.
The agent, as a loop
export async function run(task: string, ctx: Ctx) {
const messages: MessageParam[] = [{ role: "user", content: task }];
let turns = 0;
let costUsd = 0;
while (turns < 12 && costUsd < 0.5) {
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 2048,
tools: toolDefs,
messages,
});
turns++;
costUsd += costOf("claude-opus-5", res.usage);
messages.push({ role: "assistant", content: res.content });
if (res.stop_reason !== "tool_use") {
return { text: textOf(res.content), turns, costUsd };
}
const results = await Promise.allSettled(
res.content
.filter((b) => b.type === "tool_use")
.map((b) => execute(b, ctx)),
);
messages.push({ role: "user", content: toResults(results) });
}
return { text: null, turns, costUsd, stopped: "limit" };
}
Thirty lines. Whole control flow visible at once. Debuggable with a
breakpoint and a console.log. No library semantics to know.
The same agent, as a graph
import { Annotation, StateGraph, START, END, MemorySaver }
from "@langchain/langgraph";
const State = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer,
default: () => [],
}),
turns: Annotation<number>({
reducer: (a, b) => a + b,
default: () => 0,
}),
});
async function agent(state: typeof State.State) {
const res = await model.bindTools(tools).invoke(state.messages);
return { messages: [res], turns: 1 };
}
async function runTools(state: typeof State.State) {
const last = state.messages.at(-1) as AIMessage;
const out = await Promise.all(
(last.tool_calls ?? []).map((c) => callTool(c)),
);
return { messages: out };
}
function route(state: typeof State.State): "tools" | typeof END {
const last = state.messages.at(-1) as AIMessage;
if (state.turns >= 12) return END;
return last.tool_calls?.length ? "tools" : END;
}
const graph = new StateGraph(State)
.addNode("agent", agent)
.addNode("tools", runTools)
.addEdge(START, "agent")
.addConditionalEdges("agent", route)
.addEdge("tools", "agent")
.compile({ checkpointer: new MemorySaver() });
Roughly the same length, and more concepts: annotations, reducers,
node signatures, routers, compile-time wiring. If that were the whole
story, the loop wins on simplicity.
It is not the whole story.
What the graph actually buys
1. Durable execution
const config = { configurable: { thread_id: "run-42" } };
await graph.invoke({ messages: [msg] }, config);
State is checkpointed after each node. The process dies at turn nine,
comes back, invokes with the same thread_id, and continues.
Adding this to the loop means serialising your state after each turn,
loading it at the top, and versioning the payload. Perfectly doable —
and it is the point where you have written a checkpointer, which is
the thing you were avoiding.
2. Branching with independent state
The loop is linear by construction. A workflow that fans out into
three independent investigations and merges the results is
expressible with Promise.all inside one turn, but each branch
sharing the same message array is exactly the coupling you were
trying to avoid.
A graph makes branches first-class, and reducers define how their
state merges when they rejoin.
3. Human-in-the-loop interrupts
import { interrupt, Command } from "@langchain/langgraph";
async function approve(state: typeof State.State) {
const decision = interrupt("Approve this refund?");
return { messages: [{ role: "user", content: String(decision) }] };
}
// later, possibly days later, from a different process
await graph.invoke(new Command({ resume: "yes" }), config);
interrupt suspends the run, persists it, and returns. Command({ continues from that exact point in a new process.
resume })
This is the capability hardest to retrofit. It needs durable state,
a resume entry point, and the pause to be a first-class state rather
than a blocked promise. In a loop you would restructure the whole
function.
4. Streaming intermediate state
Showing a user what the agent is doing while it does it means the
loop must emit events. Turning a while loop into an async generator
is possible and it changes every call site.
Graphs stream node transitions and state updates natively, because
the framework already knows where the boundaries are.
The honest comparison
| Loop | Graph | |
|---|---|---|
| Lines to first working agent | ~30 | ~50 + concepts |
| Debuggable with a breakpoint | yes | mostly |
| Survives a process restart | no | yes, with a durable checkpointer |
| Parallel branches with own state | no | yes |
| Pause days for human approval | no | yes |
| Stream intermediate state | with rework | yes |
| Dependencies | SDK only | plus framework |
| New-joiner ramp | read one function | learn the model |
The middle four rows are the decision. If you need none of them, the
top and bottom rows say loop.
Where the loop actually hurts
Two experiences, in order of how often they happen.
You add persistence "just for restarts", then branching "just for
this one case", then a pause for approval. Six months later you have
a graph engine with no name, no tests of its own, and one author who
understands it. Adopting the framework at that point is a rewrite.
Or you never need any of it, and the loop is still thirty lines that
anyone can read.
Both are real. Which one you get depends on whether the product grows
toward long-running, interruptible workflows — which is a product
question, not a technical one, and it is worth asking before picking.
What is fair to say about LangGraph.js
It is not overhead for its own sake. Every concept maps to something
you would build yourself: annotations are state channels, reducers are
merge semantics, checkpointers are durable state, interrupts are
suspended continuations. Those are the right primitives for
long-running agents, and it implements them properly.
The complaint worth taking seriously is the learning curve relative
to a first agent. Reducers in particular are the concept that bites
newcomers — a missing one on messages silently replaces history
instead of appending, and nothing errors.
My recommendation
Write the loop first. It takes an afternoon and it teaches you what
an agent actually is.
Move to a graph when you hit one of the four — durability, branching,
interrupts, streaming — and move because you hit it, not in
anticipation. The migration is real work but it is bounded, and by
then you will know which capability you are buying.
Reaching for the graph on day one because agents are supposed to have
one is how a two-week project becomes a six-week project with a
framework nobody on the team has debugged.
If this was useful
AI That Plans covers
LangGraph.js in depth — and is equally clear about where a plain loop
is the better engineering. State design, checkpointing, interrupts,
and multi-agent patterns, with the trade-offs stated.
The loop version is book three. The full series is at
xgabriel.com/ai-in-typescript.



Top comments (0)