LangGraph-style state machines, with code

What it is

Modelling an agent as an explicit graph of nodes over a shared state object, rather than as a while loop around a model call. Nodes are functions that read state and return an update; edges decide what runs next, either unconditionally or by a routing function you write.

        ┌─────────┐
        │  plan   │
        └────┬────┘
             ▼
      ┌──────────────┐         ┌─────────┐
      │ execute_step │◀────────│ replan  │
      └──────┬───────┘         └────▲────┘
             │                      │ failed
       ┌─────┴──────┐               │
       ▼            ▼               │
  ┌─────────┐  ┌──────────┐─────────┘
  │ approve │  │  route   │
  └────┬────┘  └────┬─────┘ done
       │            ▼
       │      ┌───────────┐
       └─────▶│ synthesise│
              └───────────┘

The three things this buys over a loop, and they are the reason the pattern exists rather than being an abstraction for its own sake:

The control flow is data. You can render it, test it without a model, and reason about which transitions are possible. A while loop with nested conditionals has the same behaviour and none of the inspectability.

State is explicit and typed. Nodes receive a state object and return a partial update, so what an agent knows at any point is a value you can serialise, checkpoint, diff and assert on.

Interrupts are a first-class transition, not an exception. Human approval, which is awkward in a loop (the process must stay alive or you invent resumption), is a node that returns interrupt and a checkpoint.

What this is confused with: "LangGraph" as a product versus the state-machine pattern. The pattern is implementable in a hundred lines and is worth understanding independently; LangGraph, Temporal, AWS Step Functions and Burr are implementations with different tradeoffs. The interview question is almost always about the pattern.

The problem it solves

The plain agent loop from agent patterns works and stops working at a predictable point:

def agent(task):
    ctx = [SYSTEM, task]
    for _ in range(MAX_STEPS):
        r = model(ctx)
        if r.is_final:
            return r.text
        ctx += [r, execute(r.tool_call)]

What it cannot express, and what gets bolted on badly:

  • Human approval mid-run. The loop must block, so the process stays alive for however long the human takes. Bolting on resumption means reconstructing ctx from somewhere.
  • Different behaviour per phase. An investigating agent and an executing agent want different tools and different prompts, so the loop grows conditionals.
  • Retry with a different strategy. Not "call the same thing again," but "on failure, route to a repair node."
  • Parallel branches that rejoin. Three independent investigations that must all complete before synthesis.
  • Cycles with a guard. Replan up to twice, then escalate.

Each is a conditional in the loop, and after four or five of them the loop is a state machine written badly. Writing it as a state machine explicitly is the same complexity, made inspectable.

The second problem is testing. A loop's behaviour can only be observed by running it with a model. A graph's routing functions are ordinary functions over state and can be unit-tested with no model at all, which moves most of the orchestration logic into the fast test suite.

Mechanics

State, nodes, edges

from typing import Annotated, TypedDict, Literal
import operator

class AgentState(TypedDict):
    task: str
    plan: list[Step] | None
    completed: Annotated[list[StepResult], operator.add]   # REDUCER: appends
    current_step: int
    failures: int
    needs_approval: ToolCall | None
    result: str | None

The Annotated[..., operator.add] reducer is the important detail. Nodes return partial updates and the framework merges them, so a node returning {"completed": [r]} appends rather than replaces. Without reducers, two parallel branches each returning completed would clobber each other, and the last writer would win non-deterministically.

def plan_node(state: AgentState) -> dict:
    plan = model(PLANNER_PROMPT + state["task"], schema=Plan)
    return {"plan": plan.steps, "current_step": 0}

def execute_node(state: AgentState) -> dict:
    step = state["plan"][state["current_step"]]
    if step.tool in REQUIRES_APPROVAL:
        return {"needs_approval": step}            # transition, not an exception
    try:
        result = tools[step.tool].run(**step.args)
        return {"completed": [result], "current_step": state["current_step"] + 1}
    except ToolFailed as e:
        return {"failures": state["failures"] + 1, "completed": [Failed(step, e)]}

def replan_node(state: AgentState) -> dict:
    plan = model(REPLAN_PROMPT + format(state), schema=Plan)
    return {"plan": plan, "current_step": 0, "failures": 0}

Routing functions are pure and testable:

def route_after_execute(state: AgentState) -> Literal["approve", "replan", "execute", "synthesise"]:
    if state["needs_approval"]:
        return "approve"
    if state["failures"] >= 1:
        return "replan" if state["failures"] <= 2 else "synthesise"   # bounded cycle
    if state["current_step"] < len(state["plan"]):
        return "execute"
    return "synthesise"
def test_routing_bounds_replans():
    """No model call. Milliseconds. Runs on every commit."""
    s = AgentState(task="x", plan=[Step()], completed=[], current_step=0,
                   failures=3, needs_approval=None, result=None)
    assert route_after_execute(s) == "synthesise"     # gives up after 2 replans

That test is the whole argument for the pattern. The orchestration logic (bounded retries, approval gating, completion detection) is ordinary code with ordinary tests, and only the nodes need a model.

Assembling the graph

from langgraph.graph import StateGraph, START, END

g = StateGraph(AgentState)
g.add_node("plan", plan_node)
g.add_node("execute", execute_node)
g.add_node("replan", replan_node)
g.add_node("approve", approve_node)
g.add_node("synthesise", synthesise_node)

g.add_edge(START, "plan")
g.add_edge("plan", "execute")
g.add_conditional_edges("execute", route_after_execute,
                        {"approve": "approve", "replan": "replan",
                         "execute": "execute", "synthesise": "synthesise"})
g.add_edge("approve", "execute")
g.add_edge("replan", "execute")
g.add_edge("synthesise", END)

app = g.compile(checkpointer=PostgresSaver(conn),
                interrupt_before=["approve"])        # suspend HERE

interrupt_before plus a checkpointer is the human-in-the-loop mechanism, and it is the feature that most justifies the pattern in production:

config = {"configurable": {"thread_id": run_id}}
result = app.invoke({"task": task}, config)
# Returns with the run suspended at "approve". The process can now exit.

# Hours later, from an approval webhook:
app.update_state(config, {"needs_approval": None})    # human edited or cleared it
result = app.invoke(None, config)                      # resumes from the checkpoint

The state is in Postgres, the process is gone, and the run continues. In a while loop this requires inventing serialisation, resumption and a way to re-enter mid-iteration. See tracing and replay for the checkpointing semantics and the side-effect problem.

Parallel branches with a join

g.add_node("investigate_perf", perf_node)
g.add_node("investigate_errors", errors_node)
g.add_node("investigate_deploys", deploys_node)

# Fan out: all three run concurrently.
g.add_edge("triage", "investigate_perf")
g.add_edge("triage", "investigate_errors")
g.add_edge("triage", "investigate_deploys")

# Fan in: synthesise runs once, after ALL three complete.
g.add_edge("investigate_perf", "synthesise")
g.add_edge("investigate_errors", "synthesise")
g.add_edge("investigate_deploys", "synthesise")

The join is implicit and depends entirely on the reducer. Three nodes each returning {"findings": [x]} merge into a list because findings is annotated with operator.add. Get the reducer wrong and you silently lose two of three findings, which is the characteristic bug of this pattern and it does not raise an error.

Where the model is, and is not

The valuable discipline the pattern imposes: most nodes should not call a model.

plan          -> model call        (genuinely needs judgement)
execute       -> pure code         (runs a tool the plan named)
route_after_* -> pure code         (a conditional over state)
approve       -> waits for a human
replan        -> model call
synthesise    -> model call

Three model calls in a graph with six nodes. Compare with a ReAct loop, where every iteration is a model call deciding what to do next. The state machine is cheaper precisely because it moves control-flow decisions out of the model and into code, which is the workflow-versus-agent argument from agent patterns made structural.

The pattern without a framework

NODES = {"plan": plan_node, "execute": execute_node, ...}
ROUTES = {"plan": lambda s: "execute", "execute": route_after_execute, ...}

def run(state: dict, start: str = "plan", max_transitions: int = 50) -> dict:
    node = start
    for _ in range(max_transitions):
        update = NODES[node](state)
        state = merge(state, update)              # reducers live here
        checkpoint(state, node)
        if state.get("suspended"):
            return state
        node = ROUTES[node](state)
        if node == "END":
            return state
    raise TransitionLimitExceeded()

Forty lines, and it has the properties that matter: explicit transitions, testable routing, checkpointing, and a bound on cycles. Reaching for a framework buys persistence backends, streaming, visualisation and interrupt handling; the pattern itself does not require one, and understanding that is what an interview is testing.

A worked example: a loop with eleven conditionals

An incident-response agent. Investigates alerts, proposes remediations, executes approved ones. Grown over a year from a simple ReAct loop.

Before:

def run(alert):
    ctx, steps, failures, approved = [SYSTEM, alert], 0, 0, False
    while steps < MAX_STEPS:
        if failures > 2 and not escalated:
            ctx += [ESCALATION_PROMPT]; escalated = True
        if phase == "investigate" and steps > 8:
            phase = "remediate"; ctx += [REMEDIATE_PROMPT]
        r = model(ctx, tools=TOOLS_BY_PHASE[phase])
        if r.tool in DESTRUCTIVE and not approved:
            # block here, hope the process survives
            approved = await_approval(r)
            if not approved:
                ctx += ["Approval denied. Propose an alternative."]
                continue
        ...
lines in the loop function:      340
conditionals in the loop:        11
approval mechanism:              blocking wait, process must stay alive
process restarts losing runs:    ~12/week
orchestration test coverage:     0% (requires live model calls)
mean model calls per run:        14.2
cost per run:                    $0.62
task success:                    71%

The approval mechanism was the acute problem. Approvals took a median of 9 minutes and a p95 of over an hour, so the process held a run open that long, and any deploy or restart lost it. Twelve runs a week were being lost.

The rewrite as a graph:

class IncidentState(TypedDict):
    alert: Alert
    phase: Literal["investigate", "remediate", "escalated"]
    findings: Annotated[list[Finding], operator.add]
    proposed: Remediation | None
    approval: Literal["pending", "granted", "denied"] | None
    failures: int
    result: str | None
def route_after_investigate(s) -> str:
    if s["failures"] > 2:                    return "escalate"
    if len(s["findings"]) >= MIN_FINDINGS:   return "propose"
    if s["phase_steps"] >= 8:                return "propose"    # time-box
    return "investigate"

def route_after_propose(s) -> str:
    if s["proposed"].tool in DESTRUCTIVE:    return "await_approval"
    return "execute"

def route_after_approval(s) -> str:
    if s["approval"] == "granted":           return "execute"
    if s["approval"] == "denied":            return "propose"    # try another
    return "await_approval"
app = graph.compile(checkpointer=PostgresSaver(conn),
                    interrupt_before=["await_approval"])

Immediate results:

                              before      after
loop/orchestration LOC        340         190 (nodes) + 60 (routing)
conditionals in one function  11          0 (spread across 5 routing fns)
runs lost to restarts         ~12/week    0
orchestration test coverage   0%          94% (routing fns, no model)
mean model calls per run      14.2        6.8
cost per run                  $0.62       $0.29
task success                  71%         83%

Model calls halved, because the routing decisions the model had been making implicitly (am I done investigating, should I escalate, was that a destructive tool) became code. That is where the cost and most of the success improvement came from: the model stopped being asked questions that code could answer.

Two bugs the rewrite introduced, and both are characteristic.

Bug 1: a missing reducer. Three parallel investigation nodes each returned {"findings": [f]}, and findings was declared as a plain list[Finding].

findings: list[Finding]                            # WRONG: last writer wins
findings: Annotated[list[Finding], operator.add]   # RIGHT: merged

Two of three findings were silently discarded with no error, for eleven days. It surfaced as "the agent seems to miss things" and was found by comparing a trace's spans against the final state. The reducer bug is the signature failure of this pattern: it is a type-level mistake with a data-level symptom and nothing raises.

Bug 2: an unbounded cycle. route_after_approval returning "propose" on denial, and route_after_propose routing back to await_approval, created a loop where an operator denying every proposal cycled forever.

def route_after_approval(s) -> str:
    if s["approval"] == "granted":  return "execute"
    if s["approval"] == "denied":
        if s["denials"] >= 3:       return "escalate"     # the missing bound
        return "propose"
    return "await_approval"

Every cycle in the graph needs an explicit bound, and the graph structure makes them findable: enumerate the cycles and check each has a counter in the routing function. That is a review checklist a while loop does not admit.

After twelve months:

                              before      after (stable)
runs lost to restarts         12/week     0
mean model calls per run      14.2        6.8
cost per run                  $0.62       $0.29     (-53%)
task success                  71%         86%
orchestration bugs to prod    ~2/month    0.3/month
mean approval wait            9 min       9 min     (unchanged, now free)
p95 approval wait             71 min      71 min    (unchanged, now free)

The approval wait did not change and stopped costing anything, because the process no longer holds a run open. That is the clearest statement of what checkpointed state machines buy: a human taking an hour is now a state transition rather than a held resource.

Production evidence

LangGraph is the most widely used implementation of this pattern for LLM agents, with StateGraph, typed state with reducers, conditional edges, checkpointers with Postgres and SQLite backends, and interrupt_before/interrupt_after. Its documented motivations are the three above: explicit control flow, persistence, and human-in-the-loop.

Temporal solves the same problem from the durable-execution side: the agent loop is a workflow, each step an activity, and replay is deterministic by construction. Several teams run agents on Temporal specifically for the side-effect and resumption guarantees, which are stronger than a checkpointer's. The trade is that Temporal's determinism constraints are strict (no non-deterministic calls in workflow code) and that is a real adjustment.

AWS Step Functions predates the LLM use and is the same shape: an explicit state machine with typed transitions, retries, parallel branches and human-approval tasks. Teams already on Step Functions frequently keep the model calls as Lambda tasks rather than adopting an agent framework, which is a defensible choice.

Burr (DAGWorks) is a smaller framework built around the same state-machine framing with a strong emphasis on tracing and time-travel debugging.

OpenAI's Agents SDK takes the opposite position, offering a loop with handoffs rather than an explicit graph, on the argument that the graph abstraction is heavier than most applications need. That disagreement between vendors is genuine and the answer depends on how many transitions your agent actually has.

The debate

Graph or loop? The threshold is roughly four conditionals in your loop. Below that, a loop is simpler and the graph abstraction costs more than it returns. Above it, the loop is a state machine written implicitly, and writing it explicitly costs nothing extra and makes the routing testable. The incident-response example had eleven conditionals, and by that point the loop was 340 lines with zero test coverage.

Is the framework worth it, or do you write the 40 lines? Write the 40 lines to understand it; adopt a framework when you need persistence backends, streaming, and interrupt handling that survives process death. The thing you should not do is build your own checkpointer, because the side-effect semantics on resume are subtle and getting them wrong duplicates actions in production.

LangGraph or Temporal? Temporal if the agent's actions have serious side effects (money, infrastructure, external commitments), because durable execution with deterministic replay and built-in idempotency is a stronger guarantee than checkpoint-and-resume. LangGraph if the agent is mostly reading and reasoning, because it is far lighter and its LLM-specific features (streaming, message state, prebuilt patterns) fit better. My default: LangGraph for read-heavy agents, Temporal when a duplicate action is a real incident.

Does the graph make the agent less capable? This is the substantive objection: an explicit graph constrains the model to transitions you enumerated, so it cannot do something you did not anticipate. That is a feature for production and a limitation for exploration. For an open-ended research agent, a loop's flexibility is genuinely worth more. For an agent taking actions in a business system, being unable to do something unanticipated is the point.

Where does the model belong? In as few nodes as possible. The worked example halved model calls by moving control-flow decisions (am I done, should I escalate, is this destructive) from implicit model judgement into routing functions. A node that asks the model a question code could answer is a node to rewrite, and the graph makes those visible because each is a named function.

What is the characteristic failure? The reducer. Parallel branches returning the same state key without a merge function silently drop all but one, with no error, and it presents as a quality problem. Reducers should be reviewed whenever a parallel branch is added, and a test that runs two branches and asserts both contributions survive is cheap.

Follow-up Q&A

"When would you use a state machine instead of an agent loop?"

When the loop has accumulated about four or more conditionals, because at that point it is a state machine written implicitly. The concrete triggers are: human approval mid-run (which needs the process to be able to exit and resume), different tools or prompts per phase, retry that routes to a repair path rather than retrying the same call, and parallel branches that rejoin. Each is a conditional in a loop and a named edge in a graph, and the graph version is testable without a model.

"What does the graph buy that a loop does not?"

Three things. Control flow becomes data, so it can be rendered, enumerated and reviewed for unbounded cycles. State is explicit and typed, so it can be checkpointed, diffed and asserted on. And interrupts are a transition rather than an exception, so human approval is a suspend-and-resume rather than a blocked process. In one system that took runs lost to restarts from 12 a week to zero, with approval waits unchanged at a 71-minute p95.

"What is a reducer and why does it matter?"

Nodes return partial state updates and the framework merges them. A key annotated with operator.add appends rather than replaces, which is what makes parallel branches work: three nodes each returning {"findings": [f]} merge into three findings. Without the annotation the last writer wins, silently, with no error. That is the characteristic bug of this pattern and it presents as "the agent misses things" rather than as a failure.

"How do you test an agent built this way?"

The routing functions are pure functions over state, so they unit-test with no model at all: assert that three failures routes to escalate, that a destructive tool routes to approval, that completion routes to synthesis. That is most of the orchestration logic in the fast suite. Only the nodes that call a model need an eval set, and the recorded-trace replay from the tracing page covers changes to the graph structure itself.

"How does human approval work?"

The node is marked as an interrupt point and the graph is compiled with a checkpointer. Invoking runs until the interrupt, persists state, and returns; the process can then exit. When the human approves, hours later, you update the state and invoke again with the same thread ID, and it resumes from the checkpoint. The subtlety is side effects: if the checkpoint sits between the decision and the execution, resume must not re-execute, which needs idempotency keys on the tool.

"LangGraph or Temporal?"

Temporal when the agent's actions have serious side effects, because durable execution gives deterministic replay and idempotency as part of the model rather than as something you add, and the constraint it imposes (no non-determinism in workflow code) is worth accepting for money or infrastructure actions. LangGraph when the agent is mostly reading and reasoning, because it is much lighter and its LLM-specific features fit the shape. The question to ask is whether a duplicated action is an annoyance or an incident.

Common misconceptions

"State machines make agents less capable." They constrain the agent to transitions you enumerated, which is a limitation for open-ended exploration and the point for production systems taking actions. Choose by which you are building.

"You need LangGraph to do this." The pattern is about forty lines: a node table, a routing table, a merge function and a transition bound. A framework buys persistence backends, streaming and interrupt handling. Understanding the pattern independently is what an interview tests.

"The graph is just a loop with extra steps." The loop's routing lives in conditionals inside a function that can only be tested by running a model. The graph's routing lives in pure functions that test in milliseconds, which in one case took orchestration coverage from 0 to 94 percent.

"More nodes means more model calls." Usually the opposite. Moving control-flow decisions out of the model into routing functions halved model calls in the worked example, because the model stopped being asked questions code could answer.

"Cycles are fine, the step limit catches them." A global step limit catches an infinite loop and does not stop a two-node cycle burning 40 steps. Every cycle needs its own bound in the routing function, and the graph structure makes them enumerable so this can be a review checklist.

Interview delivery note

Say this verbatim: "Once an agent loop has four or five conditionals it is a state machine written implicitly, so I write it explicitly: nodes as functions over typed state, routing as pure functions I can unit-test without a model. The two things that justify it in production are that human approval becomes a suspend-and-resume rather than a blocked process, and that half the model calls disappear because control-flow decisions move into code." The threshold, the mechanism and the two returns.

The senior-versus-staff separator is the reducer bug. A senior engineer builds the graph correctly and knows what conditional edges are. A staff engineer knows that parallel branches returning the same state key without a merge function silently drop all but one, that it raises nothing and presents as "the agent misses things," and that it therefore belongs on a review checklist whenever a parallel branch is added. A type-level mistake with a data-level symptom and no error is the hardest class to find.

The second signal is noticing that the graph reduces model calls. Saying "the model was being asked whether it was done investigating, and that is a conditional over state, so it became a routing function and model calls halved" shows you are using the pattern to move work out of the model rather than to organise calls to it.

Further reading

  • LangGraph documentation on StateGraph, reducers, conditional edges, checkpointers and interrupt_before.
  • Temporal's documentation on durable execution and workflow determinism, for the stronger guarantee when side effects matter.
  • AWS Step Functions' state machine language, as the pre-LLM version of the same pattern.
  • The tracing and replay page in this chapter, for the checkpointing side-effect problem that resumption creates.