ESC

Type to search the knowledge base.

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.

advanced6 min read
  • 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 for with stopWhen already 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_search vs needs_sql vs refuse)
  • 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 -> retrieve needs 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 guides