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.
