Graph Workflows for Agents
An agent graph workflow makes loops, branches, and human pauses explicit. Use it when a hidden while-loop is how you lose the plot.
- agents
- graphs
- orchestration
If you cannot draw the states, you cannot test the states. A while (true) around generateText looks like agency until the model loops “let me try again,” skips the approval node, and refunds twice. An agent graph workflow makes the loop, the branches, and the human pause into nodes you can name, persist, and fail closed.
The model still picks tools. You pick the topology.
Key takeaways
- A graph is a state machine: nodes do work, edges choose the next node, a reducer merges state.
- Checkpoint after each node. Crashes and human delays must resume, not replay side effects.
- Human approval is a real state with a payload hash, not a system-prompt suggestion.
- Hidden while-loops hide max-steps, cycles, and the path that skipped billing review.
- Skip the graph when a single tool call or a 20-line
forwithstopWhenalready names the control flow.
When a while-loop is lying to you
The think, act, observe loop is the inner engine. It is the right shape for “call tools until the model stops,” with a step cap. It is the wrong shape when:
- Some steps must not run until a person clicks Approve
- Branches diverge (
needs_kb_searchvsneeds_sqlvsrefuse) - You need to retry one node without re-running
charge - Two specialists should not share one 40-message scratchpad
That is when you promote the loop into a graph. LangGraph is the widely used implementation of this idea. OpenAI agents cover tool loops. Neither exempts you from drawing the boxes.
Nodes, edges, reducers
Keep the types small enough to fit on a whiteboard.
type TicketState = {
ticketId: string;
transcript: string;
intent?: 'bug' | 'billing' | 'other';
evidence?: string;
proposedReply?: string;
approval?: { approved: boolean; by: string };
refund?: { id: string };
};
type NodeName = 'classify' | 'retrieve' | 'draft' | 'approve' | 'refund' | 'send';
type Checkpoint = {
threadId: string;
node: NodeName;
state: TicketState;
step: number;
};
function reduce(prev: TicketState, patch: Partial<TicketState>): TicketState {
return { ...prev, ...patch };
}
A node is a function State -> Partial<State>. It may call a model, a retriever, or a payment API. It must not “also send the email” unless sending is that node’s name.
import { generateObject } from 'ai';
import { z } from 'zod';
async function classify(state: TicketState): Promise<Partial<TicketState>> {
const { object } = await generateObject({
model: 'openai/gpt-4.1-mini',
schema: z.object({ intent: z.enum(['bug', 'billing', 'other']) }),
prompt: state.transcript,
});
return { intent: object.intent };
}
function routeAfterClassify(state: TicketState): NodeName {
if (state.intent === 'billing') return 'approve';
if (state.intent === 'bug') return 'retrieve';
return 'draft';
}
Edges are data, not comments in a prompt. Cycles need an explicit back-edge (draft -> retrieve if evidence is missing) and a cap on visits. A graph without a cycle counter is the same infinite bill you had in a while.
| Piece | Responsibility |
|---|---|
| Node | One side effect or one model call |
| Edge / router | Pure function of state (or a tiny model call that returns an enum) |
| Reducer | How patches merge. Last-write-wins is fine until lists append |
| Checkpoint | Serialized state plus cursor, stored before the next node |
Reducers that replace arrays will drop a tool result. Reducers that concat forever will blow the window. Be explicit: evidence = patch.evidence ?? prev.evidence.
Persist after each node
Human pauses are hours. Processes die. You cannot keep the graph in RAM and call it durable.
type NodeFn = (state: TicketState) => Promise<Partial<TicketState>>;
type Router = (from: NodeName, state: TicketState) => NodeName;
async function runStep(cp: Checkpoint, nodes: Record<NodeName, NodeFn>, route: Router) {
if (cp.step >= 12) throw new Error('max_steps');
const patch = await nodes[cp.node](cp.state);
const state = reduce(cp.state, patch);
const next: Checkpoint = {
threadId: cp.threadId,
node: route(cp.node, state),
state,
step: cp.step + 1,
};
await db.checkpoints.upsert(next); // the write is the commit
return next;
}
Side-effecting nodes use an idempotency key derived from threadId + node + payloadHash. Resume after approve must not refund again because the process restarted between Stripe and the checkpoint.
This is the same discipline as human in the loop: interrupt, persist, bind the approval to the exact payload, continue.
class Pause extends Error {
constructor(message: string) {
super(message);
this.name = 'Pause';
}
}
async function approve(state: TicketState): Promise<Partial<TicketState>> {
if (!state.approval) {
await queueApproval({
threadId: state.ticketId,
payloadHash: hashRefund(state),
});
throw new Pause('awaiting_human');
}
if (!state.approval.approved) return { proposedReply: 'We will not refund this.' };
return {};
}
Pause is a control-flow exception your runner understands. It is not an HTTP 500. The UI loads the checkpoint, shows a diff, and posts { approved, by } to a Route Handler that writes the checkpoint and schedules the next step.
Testing a graph
You can unit-test routers without a model:
test('billing tickets never skip approve', () => {
const state = { ticketId: 't1', transcript: '', intent: 'billing' as const };
expect(routeAfterClassify(state)).toBe('approve');
});
You can fixture a node: stub generateObject, assert the patch. You can replay a checkpoint: given node: 'refund' and approval.approved: true, the next node is send, and payments.refund is called once.
You cannot do any of that to a prompt that says “always get approval when it makes sense.” ReAct vs plan-and-execute is about inner reasoning style. The graph is the outer machine those styles run inside.
Should you use an agent graph workflow?
Use an agent graph workflow when you need named pauses, named branches, or durable resume.
Stay with streamText({ tools, stopWhen }) when:
- Every tool is a read, or writes are naturally idempotent and rare
- There is one success terminal
- You would draw a straight line
A second agent is not a substitute for an edge. If “billing specialist” is a different prompt and tool list, a node that is that agent can be justified. Five personas arguing in one thread is token burn. The graph should still be three boxes, not a social network.
Footguns
- Giant shared state objects. Nodes start depending on incidental fields. Pass a view.
- Model-picked next node as free text. Return an enum. Validate it.
- No cycle detection.
retrieve -> draft -> retrieveneeds a counter. - Checkpointing after the side effect but before the write. Crash = double charge. Effect then checkpoint, with idempotency on the effect.
- Copying a framework tutorial’s graph into production without persistence. Demos skip the database. Production is the database.
Interview answer (out loud)
“I use an agent graph workflow when the control flow has branches, human pauses, or durable resume. Nodes do one job, edges are typed routers, a reducer merges state, and I checkpoint after each node. Side effects carry an idempotency key so resume does not replay a refund. A capped tool loop is enough when the path is a straight line. If I cannot draw it, I cannot test it.”
Further reading (primary sources)
Related on this site
Related guides
- Agent HandoffsA handoff passes the conversation to another agent with a slimmer prompt and different tools. What to copy, what to drop, and how to avoid ping-pong.
- Multi-Agent SystemsMultiple agents means multiple prompts, tools, and traces. Use them when roles truly diverge. A second agent is not a substitute for a better tool.
- Agent Failure ModesAgents loop, lie about tool results, skip confirmation, and get stuck apologizing. Catalog the failures and the circuit breakers.
- Agent MemoryAgents forget when the context window ends. Working memory, session summaries, and long-term stores are three different systems. Do not mix them.
- Agent System Design InterviewDesign a support or coding agent in a loop: tools, approvals, traces, evals, and cost. The frontend surfaces matter as much as the model.