Most agent tutorials teach you the same shape. There is a loop. Inside the loop the model thinks, maybe calls a tool, and you check whether it is done. If not, you go around again. It is compact, it demos well, and it is the reason so many agents are miserable to operate in production.
I want to argue for a different default: model your agent as an explicit state machine. Give it named states, typed data flowing between them, and a checkpointer that persists progress. I will contrast the two approaches, show what typed state plus a checkpointer actually buys you, and ground it in a small project I built called relayg, a support-ticket triage agent written as a LangGraph state machine.
The while-loop agent
Here is the shape almost everyone starts with:
state = {"messages": [user_msg]}
while True:
reply = model(state["messages"])
if reply.tool_calls:
result = run_tool(reply.tool_calls)
state["messages"].append(result)
else:
return reply
Nothing is wrong with this for a toy. The trouble starts when you ask three ordinary questions about it.
Where is the agent right now? You cannot answer without reading the log line by line, because "where" is an implicit position in a loop, not a value you can inspect. What is in scope at this step? Whatever happens to be in that untyped messages list, discovered at runtime, never checked ahead of time. And can I pause here, get a human to approve something, and resume tomorrow? Not really. The loop lives inside one process. If that process dies, the run is gone. To fake durability you start stuffing intermediate values into a database by hand and reconstructing them on the way back in.
Those three questions are debuggability, safety, and resumability. The loop makes all three hard because it never names its states.
The state-machine agent
A state machine flips the model inside out. Instead of one loop that does everything, you declare a small set of named nodes and the edges between them. Progress is a position in a graph, and the data is an explicit, typed object that every node reads and writes.
relayg has exactly three nodes:
builder = StateGraph(TicketState)
builder.add_node("classify", classify) # LLM (or a deterministic mock) reads the ticket
builder.add_node("policy_check", policy_check) # pure Python refund rules, no LLM
builder.add_node("act", act) # takes a mock action, writes an audit line
builder.add_edge(START, "classify")
builder.add_edge("classify", "policy_check")
builder.add_edge("policy_check", "act")
builder.add_edge("act", END)
You can read the whole agent at a glance, which you cannot say for a loop whose behavior is smeared across iterations. Notice something else: only the first node touches the LLM. Classification is fuzzy work, so a model does it. The refund decision is a policy, so it is plain Python rules with a table behind them. A state machine makes that separation natural. In a loop everything tends to get shoved through the model because the model is the only thing the loop knows how to call.
What typed state buys you
The data flowing between nodes in relayg is not a loose dictionary. It is a typed schema:
class TicketState(TypedDict, total=False):
ticket_id: str
subject: str
body: str
classification: Classification # a pydantic model: intent, urgency, refund_amount
policy_decision: PolicyDecision # Literal["auto_approve", "needs_approval", ...]
approved: bool
actions: Annotated[list[dict], operator.add]
Two things fall out of this that a raw messages list never gives you.
First, contracts are checked while you write the code, not discovered when a ticket blows up at 2am. policy_decision can only be one of four literal values. The classifier returns a pydantic object with a real refund_amount field, so when the policy node reads that field it knows the shape it is getting. Typos and missing keys become editor errors instead of production incidents.
Second, that actions field has an operator.add reducer. It says "when a node returns actions, append them, do not overwrite." So state accumulates predictably as the run moves forward. You get an honest, growing record of what happened, which is the raw material for both debugging and audit. Every action relayg takes also appends a line to an audit log, so the typed state and the on-disk trail agree.
What a checkpointer buys you
This is the part the loop simply cannot do cleanly. When you compile the graph you can hand it a checkpointer:
graph = build_graph(checkpointer=SqliteSaver(...))
Now after every node the full typed state is persisted. That single change unlocks two capabilities.
Durability first. If the process restarts, a run in flight is not lost. It resumes from the last checkpoint with its state intact, because the state was never trapped in process memory.
Then the interesting one: pausing safely. relayg has a refund policy where amounts between fifty and two hundred dollars must be approved by a human. The act node handles that by literally suspending itself:
verdict = interrupt({
"reason": "refund_needs_approval",
"amount": amount,
"ticket_id": state["ticket_id"],
})
interrupt() stops the graph mid-node. The checkpointer saves everything. The process can go home for the night. Whenever a reviewer is ready, the run continues from that exact checkpoint with the verdict injected:
graph.invoke(Command(resume={"approved": True, "note": "ok"}), config)
The agent picks up precisely where it paused, with the human's decision now part of its typed state, and issues the refund or sends a decline. No webhook plumbing, no wait-node, no hand-rolled reconstruction of where it was. Pause and resume is the execution model, not something you bolt on.
That is the whole thesis in one feature. A while-loop cannot pause for a day and come back, because it has no notion of a durable, named position. A state machine with a checkpointer does it as a matter of course.
The honest caveat
State machines are not free. You pay up front. You have to name your states, define your typed schema, and think about your edges before you have a working demo, where a while-loop gives you something running in ten lines. For a quick script or a genuinely linear task with no human in the loop and no need to survive a restart, that ceremony is not worth it, and you should just write the loop. The state-machine payoff shows up exactly when an agent has to be operated: inspected, paused, resumed, and trusted. Reach for it when those matter, not before.
If you want to see the whole thing, typed state, a pure-Python policy layer, a SQLite checkpointer, and a real interrupt-and-resume path covered by tests, relayg is on GitHub at github.com/AgentPostmortem/relayg. Clone it, run the demo offline, and watch a refund pause the graph and wait for you.
Top comments (4)
The three questions — where is it now, what's in scope here, can I pause and resume tomorrow — are the right lens, and the honest answer for a while-loop agent is "you can't answer any of them without replaying the log." That's the tell.
The one I'd add to your list is cost attribution. Once state is an explicit typed object with named nodes, you can finally answer "which node burned the tokens" instead of staring at an undifferentiated message list. We moved a multi-step agent off a raw loop and the biggest surprise wasn't the resumability — it was suddenly being able to see that one node was re-reading context it already had.
One tension worth naming: the state machine trades the loop's flexibility for an upfront commitment to your graph shape. For genuinely open-ended tasks that's a real cost, and I've seen people over-rotate into rigid graphs that can't handle a branch they didn't foresee. Did you hit a point with relayg where the explicit edges felt too constraining, or did three nodes stay comfortably within that?
The state machine is the right default — a while-loop hides exactly the three things that break in production: where the agent is, what it can still do, and whether it can safely pause.
One gap I'd add: this fixes resumability inside one graph, but the same problem returns at the boundary between agents. When agent A hands off to agent B, the handoff itself needs to be a typed, validated transition, not just a message passed along. If B's state schema expects a field A never produced, you get the same silent drift the loop had, just one level up.
I'd also bind the human approval to a digest of the exact state being approved, not a summary of it — otherwise the approver can sign off on a state that has already moved on.
One catch with the checkpointer: LangGraph saves after a node finishes, not during it. Process dies in
actright after the refund call but before the checkpoint? Resume runsactagain and the customer gets refunded twice. Named states make the pause safe. Only an idempotency key makes the resume safe.A state machine also limits what goes to the model: each state sends only what its call needs, so the history stops growing with the loop. Does yours pass the full history, or just the inputs?