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
- What the state machine controls
- How I measured it
- What the model sees in each design
- Where the tokens go
- The cache changes the arithmetic
- Keep the tools, restrict the call
- The real saving: steps with no model
- From thirteen arrows to 119 decisions
- The state the breaker trips into
- What the free-form loop relies on instead
- When a state machine is the wrong tool
- Run it yourself
- Footnotes
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
confirmmeans something different from being innotify: 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_yesandslot_goneare 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:
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:
user: Friday at 7 for four, and tell the grouptool: Fri 19:00 free for 4, deposit $20user: yes, book it and pay the deposittool: committed R1 Fri 19:00 x4tool: receipt P1 $20Free-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:
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:
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:

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:
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:
{ "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.
| Design | Tokens sent, seven steps | Change |
|---|---|---|
| Free-form | 11,876 | Baseline |
| Per-state tools | 4,675 | 61% 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:
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:

Two details determine whether that works:
- 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.
- 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:
| State | Tools in the prefix | Prefix | Cached at a 512-token minimum? |
|---|---|---|---|
gather | notify_user | 363 | No |
search | check_availability | 480 | No |
confirm | notify_user | 365 | No |
book | reserve_table, lookup_reservation, pay_deposit | 761 | Yes: written at step 4, read at step 5 |
notify | get_contacts, text_group | 498 | No |
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:

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:
== 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.
| Step | State | What the step needs |
|---|---|---|
| 1. Read the request | gather | Model: extract day, time and party size from free text |
| 2. Check availability | search | Code: call the tool with those three values |
| 3. Ask for confirmation | confirm | Code: write the question from the pending action |
| 4. Reserve | book | Code: use the confirmed slot and operation key |
| 5. Pay the deposit | book | Code: use the approved amount |
| 6. Look up the group | notify | Code: look up the known group name |
| 7. Text the group | notify | Model: 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:

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 run | Model calls | Model time | Tokens sent | Input cost | Total with output |
|---|---|---|---|---|---|
| Free-form, run 1 | 8 | 33.8 s | 13,636 | 3,646 | 12,681 |
| Free-form, run 2 | 8 | 30.5 s | 13,636 | 3,646 | 12,381 |
| Code runs five steps, run 1 | 2 | 8.7 s | 1,143 | 1,143 | 3,503 |
| Code runs five steps, run 2 | 2 | 7.2 s | 1,143 | 1,143 | 3,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:
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:

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.

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:
search committed move report a reservation exists that this state didn't make; it is real, so report itreport user_revises defer report a booking exists; finish telling the user, then revise it rather than start againThe 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:
| Event | What arrives | What 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_gone | A restaurant webhook says the table is gone | In confirmation, search again. After a booking exists, tell the user and ask before rebooking |
model_unavailable | The provider is down, or a budget has stopped model calls | Only gather and notify move to degraded. The other five states run as code |
model_back | The model can be used again | Resume 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:
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:
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”:
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.
cd mainlog-code/agent-state-machines
python3 demo.pypython3 demo.py --table
python3 -m pip install pytest==8.3.5python3 -m pytest -q testsTo 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
-
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. ↩
Related posts

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
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
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
AIDiagrams 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-agentsarchitectureSep 2026
AICircuit 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-agentsreliabilityOct 2026
AIHanding 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-agentsreliabilitySep 2026
Comments