AI AI & agentic architectures

What a state machine buys an agent

Fewer model calls and a transition table you can test, measured with a dinner-booking example.

On this page · 13 sections

Giving each state only the tools it needed cut the dinner agent’s input tokens by 61%. With prompt caching enabled, those requests cost 23% more on input, step for step.

The longer prompt was being reused from the cache. Most of the shorter prompts weren’t eligible for caching at all. Sending less text looked like an obvious saving until the API priced it.

This is the dinner agent from When a turn doesn’t start with the user and When the user changes their mind mid-task. The request is still Friday at seven, for four, and tell the group. Chez Lili now requires a $20 deposit for Friday tables. Unlike the deposit text in the memory and event-bus posts, which was an attacker’s, this one is genuine, and the user approves it before payment.

I built the agent as a free-form loop and as a state machine, then compared the requests they sent over the same seven scripted steps. The model calls were real; their replies were recorded but didn’t decide how the run proceeded. That let the comparison isolate the prompts, tool definitions, caching and calls each design required.

The substantial saving came when five of the steps became ordinary code. Eight model calls became two, and 33.8 seconds of model time became 8.7 seconds. The state machine also produced something less visible in a token count: a table that forced a decision about every event in every state, including the late replies and interruptions the original diagram had left out.

The short version

  • Fewer input tokens didn’t mean cheaper input. Per-state tools sent 61% fewer tokens, but cost 23% more on input over the seven matched steps on Opus 5.5. The free-form loop reused a long cached prefix; most per-state prefixes were too short to cache.
  • Output made the remaining difference small. Counting output, per-state tools were about 3% cheaper on Opus 5.5 and 11% cheaper on Sonnet 5. That result is too small, and too dependent on what the model writes, to settle the architecture.
  • The large saving came from skipping model calls. Five of seven steps could run from facts already held by the code. The complete Opus booking went from eight calls to two, with about 72% less total cost and three-quarters less model time.
  • The transition table exposed the missing decisions. Seven states and seventeen events make 119 cells. The first drawing covered thirteen. The other 106 needed an outcome, a reason and a test.

What the state machine controls#

A free-form loop gives the model the tools and a system prompt describing how to use them. At each step, the model decides what to do next. The prompt might say to check availability before reserving and to confirm before paying, but those instructions still have to be followed on each call.

In this state machine, the task moves through gather, search, confirm, book and notify. Two more states handle trouble: report tells the user what happened after a booking exists, and degraded handles the model becoming unavailable. The current state determines which tools may execute. Code decides when the task can move on.

An airport kiosk is a useful comparison. Its current screen determines what you can do, and some steps, such as printing a boarding pass once the details are known, need no decision from you. The booking workflow has the same distinction between a step needing interpretation and a step whose inputs are already available.

Three terms make the implementation precise:

  • A state records where the task stands. Being in confirm means something different from being in notify: in the latter, a booking already exists.
  • An event is something code can observe, such as a tool result, the user’s confirmation or a restaurant webhook. slot_free, user_yes and slot_gone are events. A sentence in the model’s reply isn’t automatically one.
  • A transition defines what an event does in a particular state. The model can work within the state, but it doesn’t choose the next state in this design.

Anthropic’s Building effective agents draws the same line: “Workflows are systems where LLMs and tools are orchestrated through predefined code paths,” while “Agents, on the other hand, are systems where LLMs dynamically direct their own processes and tool usage.” A booking can use both: language understanding where it helps, with a workflow around the parts that have definite prerequisites.

How I measured it#

The seven steps are: read the request, check availability, ask for confirmation, reserve, pay the deposit, look up the group and text them. At each step, every design receives the same short history of what has happened so far.

The steps are scripted. Model replies are recorded, but they don’t steer the next request. The free-form loop also gets an eighth, closing call to recognize that the work is done. This measures the model work each design asks for on that path; it doesn’t measure how often an autonomous booking agent would complete the task correctly.

I sent the requests to Claude Opus 5.5 twice and Claude Sonnet 5 once. The API supplied the token counts, including its tool-use instructions and cache usage. Input-token counts repeated exactly. Output and wall-clock time varied between runs.

The first comparison keeps a model call at each of the seven steps and changes how the request is built. These are the relevant branches in booking.py:

python
booking.py
def request(design: str, i: int) -> dict:
# ...
state = RUN[i][0]
instr, allowed = STATES[state]
if design == "free":
return {"tools": list(TOOLS.values()), "system": FREE_SYSTEM, "user": history(i)}
if design == "states":
return {"tools": [TOOLS[t] for t in allowed],
"system": f"You are a dinner-booking assistant. Current step: {state}. {instr}", "user": history(i)}
if design == "fixed":
# ...
return {"tools": list(TOOLS.values()), "system": FREE_SYSTEM,
"user": f"{history(i)}\n\nCurrent step: {state}. {instr} Allowed tools: {', '.join(allowed)}."}
# ...

What the model sees in each design#

Code like that is easy to misread, and the model never sees the code. It sees the request. Here is what each design sends at step 6, looking up the group, with the booking and the deposit already done. The history is identical in all three:

text
the history, the same in every design
user: Friday at 7 for four, and tell the group
tool: Fri 19:00 free for 4, deposit $20
user: yes, book it and pay the deposit
tool: committed R1 Fri 19:00 x4
tool: receipt P1 $20

Free-form sends all eight tools and the same long system prompt at every step. The rules for ordering live in that prompt, and the model has to follow them on every call:

text
free: tools and system prompt
tools: check_availability, reserve_table, cancel_reservation, lookup_reservation,
pay_deposit, get_contacts, text_group, notify_user
system: You are a dinner-booking assistant acting for the user. Book restaurant tables,
handle deposits, and tell the user's group. Rules: never reserve before checking
availability; never reserve or pay without the user's explicit confirmation of the
exact slot and amount; text the group only after the booking is committed, and only
once; if a booking outcome is unknown, look it up before trying again; never cancel a
reservation unless the user asked. Ask the user when anything is ambiguous. When the
task is complete, say so and stop.

Per-state sends only the tools the current state allows, and a one-line instruction for that state. There are no ordering rules, because the order is the state machine’s job:

text
states: tools and system prompt, in the notify state
tools: get_contacts, text_group
system: You are a dinner-booking assistant. Current step: notify. Tell the dinner group the
confirmed booking, once.

Followed through the whole booking, each state carries its own instruction and its own short tool list. In the final version of the machine, described further down, code runs five of the seven steps, so only gather and notify send the model an instruction at all:

Six cards in two rows, joined left to right by arrows labelled with events. First row, gather, blue and tagged model, with its instruction, Extract day, time and party size from the user's request. Ask only for what is missing, and step 1, notify_user. slots_complete leads to search, grey and tagged code, step 2, check_availability. slot_free leads to confirm, code, step 3, notify_user. user_yes wraps down to the second row, to book, code, step 4 reserve_table and step 5 pay_deposit. paid leads to notify, blue and tagged model at step 7, with its instruction, Tell the dinner group the confirmed booking, once, then step 6, code, get_contacts, and step 7, model, text_group. sent leads to done, 2 model calls, 5 steps run as code.
The scripted run, one card per state. Each arrow is the transition the table makes on that state’s result, and only the blue states send the model an instruction.

Fixed sends exactly what free-form sends, all eight tools and the long system prompt, and adds the state to the end of the user message. Code refuses any call outside the allowed list:

text
fixed: the end of the user message
Current step: notify. Tell the dinner group the confirmed booking, once. Allowed tools: get_contacts, text_group.

Each tool in those lists is a block of text too. This is one of the eight, as the model receives it:

json
booking.py: reserve_table
{
"name": "reserve_table",
"description": "Books a table at the restaurant for the party, day and time given. This commits a real reservation and may charge a no-show fee, so only call it after the user has confirmed the exact slot. Send the idempotency key so a retry never books twice. Returns the reservation ID.",
"input_schema": {
"type": "object",
"properties": {
"day": {"type": "string"},
"time": {"type": "string"},
"party": {"type": "integer"},
"key": {"type": "string", "description": "Idempotency key"}
},
"required": ["day", "time", "party", "key"]
}
}

The fixed design looks redundant until caching enters the comparison: it keeps the start of every request identical, which is what the cache needs.

Costs below are relative units. One uncached input token costs 1; an output token costs 5. That is Anthropic’s published input-to-output ratio: 4 USD and 20 USD per million tokens for Opus 5.5, and 2 USD and 10 USD for Sonnet 5. Other platforms publish their own prices, so read these as ratios, not an invoice. “Input cost” excludes output; “total” includes it.

Where the tokens go#

A tool definition is part of the model’s input: its name, description and argument schema. Eight carefully described tools carry a fair amount of text before the conversation history even begins. When tools are present, the API also adds its own tool-use instructions. Anthropic’s pricing documentation lists 286 tokens of that overhead for Opus 5.5.

The free-form design’s fixed part, the tools, system prompt and API-added instructions, was 1,561 tokens.1 The history specific to each step was only 93 to 189 tokens. Most of each request was material the model had already seen.

DesignTokens sent, seven stepsChange
Free-form11,876Baseline
Per-state tools4,67561% fewer

Without caching, the per-state design has the expected advantage: it sends fewer tool definitions and a shorter instruction. With caching, the service charges different rates for different parts of that input.

The cache changes the arithmetic#

Prompt caching reuses an already processed request prefix. Under the pricing used here, writing the prefix costs 1.25 times the normal input rate. Reading it costs 0.05 times on Opus 5.5 and 0.1 times on Sonnet 5. Reusing a long prefix can therefore be cheaper than processing a much shorter one afresh.

The prefix is everything up to a cache breakpoint, a marker in the request. In the demo, it sits on the system prompt:

python
live.py
kwargs = dict(model=PREFIX + model, max_tokens=4000,
system=[{"type": "text", "text": f"[{tag}] {req['system']}", "cache_control": {"type": "ephemeral"}}],
messages=[{"role": "user", "content": req["user"]}])
if req["tools"]:
kwargs["tools"] = req["tools"]

The API builds the prefix in a fixed order: “Cache prefixes are created in the following order: tools, system, then messages.” So at step 6, the three designs’ requests, and the part of each one billed as a cache read, look like this:

Three horizontal bars drawn to scale, one per design, at step 6 on Opus 5.5. Free-form, 1,729 tokens, is 286 tokens of API tool-use instructions, then 1,275 of the 8 tool definitions and the long system prompt, both green for read from cache, then a cache breakpoint, then 168 tokens of history in orange, full price. Its input cost is about 246. Per-state, 666 tokens, is 286 of API instructions, 212 of two tools and a short prompt, and the same 168 of history, all orange, because the 498-token prefix is under the 512-token minimum. Its input cost is 666. Fixed, 1,774 tokens, matches free-form with a 1,563-token cached prefix, then the history and a 43-token state line, 211 tokens at full price, for an input cost of about 289.
Step 6 in each design, widths to scale. The shortest request costs the most, because none of it is read from the cache.

Two details determine whether that works:

  1. Tools come first in the prefix. The cache documentation says “Modifying tool definitions (names, descriptions, parameters) invalidates the entire cache.” A different per-state tool list changes the prefix before the rest of the request is considered.
  2. There is a minimum cacheable length. It was 512 tokens on Opus 5.5 and 1,024 on Sonnet 5. A shorter prefix doesn’t produce an error; it simply isn’t cached.

The free-form loop’s 1,561-token prefix is identical over the seven steps. It is written once and read six times. Each per-state tool list has a different prefix, used only once or twice, and most of them are short. Subtracting each step’s history from what the per-state request sent gives its prefix on Opus 5.5:

StateTools in the prefixPrefixCached at a 512-token minimum?
gathernotify_user363No
searchcheck_availability480No
confirmnotify_user365No
bookreserve_table, lookup_reservation, pay_deposit761Yes: written at step 4, read at step 5
notifyget_contacts, text_group498No

On Sonnet 5, with its 1,024-token minimum, none was cached.

Laid out step by step, that is one prefix written once and read six times, against a prefix per state, of which only one clears the 512-token line:

Two lanes of bars over steps 1 to 7, with bar height showing the prefix on Opus 5.5. In the free-form lane every bar is 1,561 tokens, blue for written to the cache at step 1 and green for read at steps 2 to 7. In the per-state lane the bars are 363, 480, 365, 761, 761, 498 and 498 tokens, with a dashed red line at the 512-token minimum. Only the two 761-token book bars rise above it, written at step 4 and read at step 5. The rest are orange, not cached, including notify's 498 twice.
Free-form reuses one prefix six times. Per-state builds a new one for each state, and only the book prefix is long enough to cache.

Step six shows the effect without needing a whole-run total. Per-state tools send 666 tokens, all charged at the normal rate. Free-form sends 1,729 tokens, but 1,561 are a cache read. Its input cost is about 168 + 1,561 × 0.05 = 246. The larger request costs substantially less. Over the seven steps, this is what the recorded run gives:

text
python demo.py
== Claude Opus 5.5, the seven scripted steps, repetition 1
free-form sent 11,876 cached-read 9,366 input cost 3,368.6 total 11,218.5
per-state tools sent 4,675 cached-read 761 input cost 4,142.3 total 10,922.3
fixed tools + code check sent 12,236 cached-read 9,378 input cost 3,717.7 total 9,277.6
rep 1 per-state tools: 61% fewer tokens sent; input cost +23% step for step, +14% counting the closing call; total -2.6%
rep 2 per-state tools: 61% fewer tokens sent; input cost +23% step for step, +14% counting the closing call; total -3.7%

Per-state input costs 23% more over the matched steps. Include the free-form loop’s closing call in the comparison and that becomes 14% more. Sonnet 5 shows the same pattern: 30% more step for step, or 19% more when the closing call is included.

Output changes the size of the overall difference. It accounts for about 70% of the free-form seven-step total, and prompt caching doesn’t discount it. Over those seven steps, per-state tools were 2.6% and 3.7% cheaper in the two Opus runs, and 11% cheaper on Sonnet. The token reduction is real, but it doesn’t support a large total-cost claim once the cache is working.

Why the first free-form step costs twelve times the second

Step one writes the 1,561-token prefix at 1.25 times the normal rate. Its input cost is 93 + 1,561 × 1.25 ≈ 2,044. Step two reads the same prefix at 0.05 times: 93 + 1,561 × 0.05 ≈ 171. The step histories happen to be the same length, so the difference is the cache write versus read.

The history in this experiment is a few short lines and isn’t cached. A real agent can have a much longer growing history. Reusing that history through a second cache breakpoint could make caching more valuable still.

The minimum lengths and multipliers are the ones documented for these models when the run was recorded. They can change with the model or release.

Keep the tools, restrict the call#

The fixed design separates the tool definitions sent to the model from the calls the application will execute. Every request carries all eight definitions, preserving the cached prefix. The current state and allowed tools go in the message, and code refuses a call outside that set. In kiosk terms, every button stays on the screen, but the ones that don’t belong to this step do nothing when pressed.

OpenAI’s function-calling guide describes this directly: “You might want to configure an allowed_tools list in case you want to make only a subset of tools available across model requests, but not modify the list of tools you pass in, so you can maximize savings from prompt caching.” Anthropic’s documented tool_choice options are auto, any, tool and none; none expresses a subset. On Opus 5.5, according to the tool-use documentation, “any and tool return a 400 error.” The per-state restriction in this design is enforced by application code.

Its Opus input cost was about 3,718 units, 10% above free-form over the seven steps. With output included, its total was 17% lower on Opus but 31% higher on Sonnet. What the model chose to write moved that result considerably.

The consistent benefit is the execution check. A tool definition can remain in the cached prompt without granting permission to run that tool in every state. That permission is decided at the point of execution.

What the model did with the allowed list

The recorded choices are useful even though they didn’t drive the scripted run. Across the 66 recorded calls, no fixed-design call requested a tool outside its state’s list. Four calls across the two designs with allowed lists did something those lists couldn’t catch.

In the Sonnet run, the deposit step called reserve_table again instead of pay_deposit. Three Opus calls sent the group’s text at step six, before looking the group up. Those tools were allowed in their respective states.

A per-state allowlist doesn’t establish that an allowed action is due now, or that it hasn’t already happened. Ordering still needs to be enforced within the state. An idempotency key, naming the operation rather than the attempt, prevents retries from creating a second reservation when the restaurant supports that contract. The runtime posts put that key in the request instead of asking the model to remember to book only once.

The real saving: steps with no model#

Keeping a model call at every step preserves a lot of work that doesn’t need a model. Once the day, time and party size have been extracted, checking availability is a tool call with known arguments. Reserving uses the confirmed slot and its operation key. Paying uses the amount the user approved.

StepStateWhat the step needs
1. Read the requestgatherModel: extract day, time and party size from free text
2. Check availabilitysearchCode: call the tool with those three values
3. Ask for confirmationconfirmCode: write the question from the pending action
4. ReservebookCode: use the confirmed slot and operation key
5. Pay the depositbookCode: use the approved amount
6. Look up the groupnotifyCode: look up the known group name
7. Text the groupnotifyModel: compose the short message

Five steps can run from facts the application already holds. The free-form loop asks the model at each one anyway, then makes another call to notice the task is finished.

On the first Opus run, that difference looks like this:

Two lanes of boxes over the seven booking steps and a closing call. In the free-form lane all eight boxes are blue model calls, taking 3.8, 3.4, 5.2, 5.7, 5.2, 3.6, 2.9 and 4.0 seconds, 8 calls and 33.8 seconds in all. In the state machine lane only step 1, reading the request, at 5.4 seconds, and step 7, texting the group, at 3.3 seconds, are model calls. Steps 2 to 6 are white boxes marked code, no call, and the closing call is not needed, for 2 calls and 8.7 seconds. A bar under each lane shows the model time to scale.
Blue boxes call the model; white ones are code. The state machine keeps the model for the two steps that need interpretation.

Confirmation is a particularly useful step to make deterministic. The runtime already holds a pending action: the exact work it will perform if the user says yes. Writing the confirmation question from that record means the user approves what will actually run, including the deposit. That is the arrangement in the earlier runtime post.

Let the state machine execute those five steps directly, and the complete Opus booking looks like this:

Design and runModel callsModel timeTokens sentInput costTotal with output
Free-form, run 1833.8 s13,6363,64612,681
Free-form, run 2830.5 s13,6363,64612,381
Code runs five steps, run 128.7 s1,1431,1433,503
Code runs five steps, run 227.2 s1,1431,1433,323

Costs rounded to whole relative units. These are model-call times, not end-to-end booking latency: the tools in the experiment are fakes and add no time. Neither of the two remaining requests is long enough to cache, so tokens sent and input cost are the same.

The two-call design gets no cache saving, and still reduces the total by 72% and 73% across the two Opus runs. Sonnet’s single run went from 30.4 to 8.2 seconds of model time, with a total of 16,069 versus 4,300 units: about 73% less.

Each eliminated call removes both its input and whatever output the model would have generated. That is a much larger change than adjusting the tool list on a call that still happens.

Two calls is a property of this path, not every dinner request. The first call extracts all three required values at once. If something is missing, the task goes through gather again. The design also chooses to use the model for the group message. The useful question is whether each step needs interpretation or can proceed from facts already available to code.

The lab puts the four designs beside each other. Start with per-state tools and switch caching off, then on. Compare input cost with total cost. Next try fixed tools with the code check, and finally the design that runs five steps without the model. The model selector shows how those comparisons changed between Opus and Sonnet:

Seven steps, four designs, one cachelive lab

This lab runs a real call in your browser, so it needs JavaScript.

From thirteen arrows to 119 decisions#

Here is the booking as a state machine, with the thirteen transitions of its first drawing and the four into and out of degraded that the table added later:

A state diagram. Six circles run left to right along the main path, gather, search, confirm, book, notify and done, joined by slots_complete, slot_free, user_yes, paid and sent. gather and notify are filled blue because they call the model; the others are outlined because code runs them, and done is a grey double circle. Three nested arrows above the path lead back to gather, none_free from search, user_no from confirm and refused from book. gather loops to itself on slots_missing. book loops to itself twice underneath, on unknown and, around that, on committed, because the deposit is still due. Below notify, send_failed leads to report, outlined in orange, and told leads from report to done. Set apart at the bottom, degraded, also orange, receives dashed model_unavailable arrows from gather and notify, and sends dashed model_back arrows back to each, resuming the state it left.
The main path runs left to right; every arrow is an event code can observe. Only the two blue states call the model.

The booking’s first diagram had thirteen transitions: the normal path and the obvious branches. It looked reasonably complete. Turning it into a state-event table left 106 cells undecided.

Some event names carry important distinctions. unknown means the reservation request timed out and its result is not known. The task stays in book and looks up the reservation by its key before trying again. refused means the restaurant actually said no. committed means the table is reserved, but the task stays in book, because the deposit is still due; paid means the deposit is settled, or that none was due, and only that moves the task on to notify. told means the message has been shown to the user.

The table puts all seven states down one side and all seventeen observable events across the top. That makes 119 combinations. A diagram records the transitions you drew; the table also asks about the event arriving in a state that wasn’t expecting it.

Every cell gets an explicit outcome:

  • Move: enter another state.
  • Stay: the event is expected here and doesn’t change the state.
  • Defer: hold the event until the work in flight resolves, then handle it.
  • Reject: the event shouldn’t arrive here; log it and take no action.
  • Ignore: it’s irrelevant in this state; drop it without logging. The booking doesn’t use this outcome, but the state machine analyzer supports it.
A grid of 7 states (gather, search, confirm, book, notify, report, degraded) by 17 events. The 13 drawn transitions are green, such as gather on slots_complete to search. Most other cells are a red x, reject, a result the state never asked for. In the committed, paid and sent columns, every state that wasn't expecting them leads to report. The four columns user_revises, slot_gone, model_unavailable and model_back, bracketed as having no arrow in the drawing, are full of decisions: moves to gather before anything is committed, defers in book, notify, report and degraded, moves to degraded from gather and notify, and resume from degraded.
The thirteen drawn transitions and the 106 additional decisions. Each declared cell can become a test case.

Most of the missing cells are straightforward. A slot_free result while the agent is texting the group is a result that state never requested. Fifty-nine cells in this table are rejections.

Three event columns need different treatment. A late committed reply is evidence that a reservation exists, even if the task has moved away from the state that requested it. Rejecting the reply doesn’t undo the reservation; it loses track of it. Outside book, the table sends that event to report, and a late paid goes there too, because the money has already moved. An unexpected sent result also needs accounting for, because the text has already gone out. Here are two cells as the demo prints them:

text
python demo.py --table
search committed move report a reservation exists that this state didn't make; it is real, so report it
report user_revises defer report a booking exists; finish telling the user, then revise it rather than start again

The second rule is easy to miss in a diagram. Before a reservation exists, “make it Saturday” can send the task back to gather. In report, the booking already exists. Starting the original workflow again can create another reservation. The revision must operate on what the earlier work left behind.

Four events can arrive across the workflow, and had no arrows in the initial drawing:

EventWhat arrivesWhat the table decides
user_revises”Actually, make it Saturday.”Restart gathering before a commitment. Defer during booking, notification or reporting until the work being revised has resolved
slot_goneA restaurant webhook says the table is goneIn confirmation, search again. After a booking exists, tell the user and ask before rebooking
model_unavailableThe provider is down, or a budget has stopped model callsOnly gather and notify move to degraded. The other five states run as code
model_backThe model can be used againResume the state that was left when the task entered degraded

The revision and webhook cases are worked through in When the user changes their mind mid-task and When a turn doesn’t start with the user. The table puts those cases next to every state, so they have to be decided before an incident supplies the missing event.

The demo checks that every cell declares an outcome and a reason:

python
tests/test_table.py
def test_every_cell_has_a_declared_outcome():
t = booking.table()
assert len(t) == len(booking.STATES) * len(booking.EVENTS) == 119
for (state, event), (kind, nxt, why) in t.items():
assert kind in KINDS, (state, event)
assert why, (state, event)
# ...

Completeness is only part of the check. Another test walks the table and fails if a late committed, paid or sent event is rejected. That encodes a specific requirement: an acknowledged external effect must not disappear just because its reply arrived in an inconvenient state.

There is one thing the table can’t do: decide an event nobody listed. What happens if the deposit is declined? The task would sit in book, holding a reservation, with no event to move it, and every cell in the table would still be decided. The grid checks every combination of the states and events you wrote down. It can’t tell you which event is missing. That question still needs someone to walk the real failure cases, here the restaurant, the payment and the user, and ask what each can send back.

You can run the same check on your own machine. Write each state as a header, with what each event does beneath it, and the tool counts how many state and event pairs are decided, shows the grid with the least-decided events on top and a red ? wherever no rule says what happens, draws the states with the moves between them, and lists the states that can’t be reached or left and any cell two rules disagree about. Run the booking as first drawn: 106 of 119 cells are ?, and degraded can’t be reached or left. Then run the version with every cell decided, and “Two people edited this one”, where one cell reads “2 rules”. Set “Also write” to markdown to take the grid into your own docs:

State machine analyzer/tools/state-machine-analyzer/

The pytest skeleton, under “Also write”, gives each state-event pair a case, with an undecided value where a decision is still missing. It checks the table’s specification; the application’s handlers still need to be connected to those expected outcomes.

The state the breaker trips into#

degraded is where the task waits when it can’t use the model. The provider might be unavailable, or a budget might have stopped further calls. Circuit breakers for inference covers when to stop. The state machine defines what the application can still do afterwards.

If task progress is represented only in a conversation history, reporting what happened requires interpreting that history. With an explicit state and recorded booking and payment facts, code can produce the message itself. A failure in notify doesn’t have to turn into an ambiguous “something went wrong”:

text
rendered from known state
Booked: Friday 19:00 for 4 (R1). Deposit paid: $20 (P1).
Not done yet: texting the group. I'll send it when I can, or you can send it yourself.

The reservation and payment references come from stored results. The unfinished action comes from the state. None of that requires asking the unavailable model to explain itself.

Only two states in this design call the model: gather and notify. The other five can keep running during a provider outage. An outage during book doesn’t prevent the already authorized reservation or payment, because those operations don’t need the model.

Recovery also has a defined destination. When model_back arrives in degraded, the task returns to the state it left. Resuming notification after a paid reservation is different from restarting the booking.

What the free-form loop relies on instead#

The free-form design in this demo keeps the ordering rules in its system prompt. The recorded calls did not reserve before checking availability or text before a booking existed. The experiment doesn’t show those rules failing.

It does show how much room the prompt is being asked to constrain. Eight tools, callable at every step, allow 8⁴ = 4,096 sequences of four calls. Of those, 1,400 reserve a table before the first availability check, whether that check happens later or never.

That 34% is a count of expressible sequences, not a measured failure rate or a claim that the model chooses tools randomly. In the enforced state machine, those sequences cannot execute: reserve_table isn’t permitted until book, and book is not reachable until confirmation.

That still leaves ordering and repeated actions within a state, as the allowed-list examples showed. State restrictions, operation keys and step-specific checks cover different parts of the workflow. A state machine is useful because those rules can be enforced and tested without relying on a particular model reply.

When a state machine is the wrong tool#

A booking, checkout, refund or onboarding flow usually has stages that can be written down. An open-ended research task or coding session may not. If the only honest state name is “work on the problem,” a one-row table adds little.

Anthropic’s guidance is to start simple: “When building applications with LLMs, we recommend finding the simplest solution possible, and only increasing complexity when needed.” A state machine can control a bounded operation inside a more open-ended agent without describing everything that agent will ever do.

There are also designs that keep explicit states while allowing the model to decide some transitions. StateFlow is one example. It reports that it “achieves 13% and 28% higher success rates compared to ReAct in InterCode SQL and ALFWorld benchmark, with 5x and 3x less cost respectively.” Those are results for that approach on those benchmarks, rather than measurements from this booking experiment.

The 12-factor agents control-flow guidance states the design principle plainly: “Build your own control structures that make sense for your specific use case.”

There is a practical limit in the other direction too. On a real-time media platform I worked on, each application had acquired its own hand-written state machine. The useful change was to replace those separate implementations with a deterministic lifecycle contract the applications could share.

Splitting the dinner agent into Bookings, Calendar and Messaging, as the handoff and event-bus posts do, can produce the same problem. The agents need different task states, but they can share what counts as an event, what defer and reject mean, and what must be recorded when entering degraded. That gives their tables a common meaning without forcing every agent through the same workflow.

Statecharts, and the resume arrow

The transition back to whichever state was left is a history state in David Harel’s Statecharts: a visual formalism for complex systems, Science of Computer Programming 8(3), 1987. Statecharts also support nested states and regions that run in parallel.

A flat table is enough for this booking. If it starts needing a row for every combination of independent concerns, such as booking progress and model availability, consider nesting or parallel regions before adding all those combinations as separate states.

Run it yourself#

The demo is in mainlog-code/agent-state-machines. booking.py contains the tools, prompts, scripted path and transition table. live.py makes the model requests and records usage. demo.py prints the recorded results without calling a model.

Shell
Terminal window
cd mainlog-code/agent-state-machines
python3 demo.py
python3 demo.py --table
python3 -m pip install pytest==8.3.5
python3 -m pytest -q tests

To collect a new live run, python3 live.py makes 66 model calls and needs an API key. The times and output will differ from the recorded run.

CI never calls a model. It runs the tests, checks the lab’s data against the recorded results and verifies the totals quoted in the post. The cost comparison can be reproduced from that record; the state-event decisions can be inspected and tested locally.

Footnotes#

  1. The system prompt begins with a short per-run tag, such as [free-1], so one run doesn’t read another run’s cache entry. The reported prefixes include those tag tokens. That is why the fixed design’s prefix is 1,563 tokens even though it otherwise uses the same tools and system prompt. ↩

Share this article

Related posts

3 items
  • Sep 25, 2026 15 min read AI

    Diagrams worth reading

    My agent could already write Mermaid. Diagrams worth reading took a skill that chooses the picture, keeps the facts intact, and checks the drawing before handing it back.

    AI & agentic architecturesai-agentsarchitecture
  • Oct 19, 2026 21 min read AI

    Circuit breakers for inference

    Timeouts, retries and budgets for model calls that finish too slowly, produce unusable answers or keep repeating themselves. With the real Anthropic SDK, timeout=3.0 let an 18-second stream finish. In a simulated support chat, with invented failure rates, five checks took the conversations waiting over two minutes from 249 to 0.

    AI & agentic architecturesai-agentsreliability
  • Sep 9, 2026 23 min read AI

    Handing off between agents

    A handoff carries two things: the words the next model reads, and the state the next runtime has to enforce. Every common handoff style moves only the words. In a small scripted demo, even the full transcript, 1,838 bytes, left the restaurant holding two tables, because the key that names the in-doubt booking was never in it. A 310-byte record written by the sending runtime fixed it.

    AI & agentic architecturesai-agentsreliabilityarchitecture
  • AI

    Diagrams worth reading

    My agent could already write Mermaid. Diagrams worth reading took a skill that chooses the picture, keeps the facts intact, and checks the drawing before handing it back.

    ai-agentsarchitecture
    Sep 2026
  • AI

    Circuit breakers for inference

    Timeouts, retries and budgets for model calls that finish too slowly, produce unusable answers or keep repeating themselves. With the real Anthropic SDK, timeout=3.0 let an 18-second stream finish. In a simulated support chat, with invented failure rates, five checks took the conversations waiting over two minutes from 249 to 0.

    ai-agentsreliability
    Oct 2026
  • AI

    Handing off between agents

    A handoff carries two things: the words the next model reads, and the state the next runtime has to enforce. Every common handoff style moves only the words. In a small scripted demo, even the full transcript, 1,838 bytes, left the restaurant holding two tables, because the key that names the in-doubt booking was never in it. A 310-byte record written by the sending runtime fixed it.

    ai-agentsreliability
    Sep 2026

Comments