Blog \ Building Otto technical posts

What 100k autonomous travel conversations taught us about stateful API chains

How Otto, a fully autonomous travel agent, handles long chains of stateful supplier API calls, and the hard-won lessons (classify by the double-call test, reconcile never retry, enforce budgets at the transport boundary, discover expiry, model invalidation as a graph) now open-sourced as chain-re-action.

By

Chundong "CD" Wang

August 1, 2026

We build Otto, a fully autonomous AI business travel agent. Not a copilot that drafts an itinerary for a human to click through. It's an agent that actually runs the booking end to end: searches, prices, holds, ticks, charges the card, and handles it when any of that goes sideways.

The interesting part of Otto isn't that it can chat about trip plan. It's the fact that Otto can actually book and manage the trip for you. Beneath that is long chains of supplier API calls where the later steps mutate real-world state (they ticket real flights, capture real payments, hold real inventory), and where an autonomous agent, not a human, is the thing deciding what to do when a step fails. There is no human at the keyboard to notice that the fare expired, or to hesitate before retrying a booking call whose response never came back. The system has to get that right on its own, every time.

Across 100k+ autonomous travel conversations (reschedules, rebookings against tight inventory, cancellations with fees, every failure shape the real world produces), we'd accumulated a pile of hard-won knowledge about what makes stateful API chains safe or unsafe.

Today we're open-sourcing that knowledge as chain-re-action, a spec and a skill that teach a coding agent to compile a safe client for a stateful API instead of improvising one at runtime. If you're building on top of stateful, multi-step API calls (travel, payments, provisioning, logistics), you're facing the same problems we did, and this is meant to save you the incidents we learned them from. This post walks through what's in it and why.

Why stateful chains are their own problem

A booking is a chain: search → select → price → hold → commit. It looks like a pipeline. It is not a pipeline, because of four properties that ordinary request/response code doesn't have to reckon with:

1. Later steps mutate external state. The commit mints a real PNR, captures a real payment. There's no "undo," only compensation, a business action with its own fees and time windows.

2. Intermediate results expire underneath you. The fare you priced 90 seconds ago could be gone. The search results point at a session that just timed out. The exact room requested to book was unavailable. Airline asynchronously requests the travel agent to re-review the ticket. Handles die when you use them, not on a schedule you control.

3. "Nothing available" is a valid answer, not an error. No inventory, no capacity, card declined: these are outcomes to route on, not exceptions to retry. Otto could remove overly restrictive filter by doing the right trade-off, but also should manage the frequency of going back to the user for consent on every tiny decision.

4. The most dangerous failure is the one you never learned the result of. You send the commit. The connection drops before the response comes back. Did it book? You genuinely don't know, and the two obvious moves, retry it or assume it failed, are exactly how you double-book a traveler or strand one.

For a human-in-the-loop tool, some of this is survivable: a person notices the stale fare, a person hesitates before hitting "book" twice. For a fully autonomous agent there is no such backstop. The recovery logic has to be complete, correct, and encoded, because the agent is going to execute it at 3am with no one watching. That constraint is what forced us to be precise about all of it.

The lessons, and why we stopped improvising them

The mistake we made early, the one we think most teams make, is treating recovery as something you handle inline, per endpoint, in the moment: a retry here, a try/except there, a timeout bumped when something flakes. That works until the chain gets long and the failures get correlated, and then it doesn't, and the failures are expensive because they touch money and inventory.

What we learned points to something bigger than a bug fix. Recovery for a stateful chain isn't glue you sprinkle between endpoints. It needs to be a first-class design surface, something you name, reason about, and execute deterministically, much like the industry learned to treat schemas and types. Get it right, and it stops being tribal knowledge trapped in the heads of whoever survived the last incident.

That's the direction we're building toward. As more software evolves into autonomous agents acting on real-world assets, safe, recoverable chains stop being just a booking detail. They become the load-bearing discipline underneath the entire system. That discipline should be portable, not re-earned by every team through its own outages.

The lessons below are our down payment on it.

Classify a step by the double-call test, not the endpoint name

The most important property of any step is: what exists in the world if this call runs twice? Not what the endpoint is named. "checkout," "validate," "initiate" sound consequential but are usually mints: they create expiring server-side scratch state, and the worst case is an orphaned session that self-expires. "create," "capture," "book" are usually commits. But you verify, because plenty of "create" calls just mint.

And the rule that took us real incidents to internalize: consequence, not lifetime, decides the class. A hold order that self-expires in fifteen minutes is still a commit if it holds real airline space and shows the user a PNR. A credit-card authorization hold is a commit even though it's temporary, because it ties up the traveler's limit. Self-expiry does not buy you mint-level safety.

Unknown outcome is a first-class state: reconcile, never retry

This is the lesson that cost us the most to learn. A commit whose response you lost may have succeeded. So the only safe next step is a confirmation probe: go read whether the thing exists. Never a blind retry, never a silent failure.

Unkeyed commit, ambiguous outcome (a timeout, a dropped connection, an ambiguous 5xx) → reconcile: go confirm whether it landed; never re-send.

Keyed commit, same signalretry with the same idempotency key.

reconcile is deliberately not collapsible into retry. Retrying an unconfirmed unkeyed commit is the canonical double-booking bug. For an autonomous agent this is non-negotiable: it can't "just check the confirmation email." So every commit has to declare, up front, how you'd find out whether it landed: which read endpoint, filtered how, with what lag. And the probe has to be runnable without the commit's own output: a get-by-locator read is useless when the locator was in the response you lost. If the only available read needs that lost locator, you persist a correlation record before dispatching, carrying the lookup keys the probe will need.

If a commit has no idempotency key and no way to observe whether it landed, it isn't implementable safely, full stop. Better to know that before you write the client than to discover it in production.

"attempts = 1" is worthless unless you name where it's enforced

This one is subtle and it's a genuine trap. Writing "attempts = 1 on unkeyed commits" in a design doc means nothing if it isn't actually enforced at the transport layer. Shared HTTP clients routinely carry a default retry policy that silently kicks in the moment you omit a per-call option. And a retry pin applied to one provider's client is not applied to another's just because you meant it to be.

So the enforcement point has to be named explicitly (per-call option, dedicated client, or middleware), and the test that guards it has to exercise the real transport boundary, not a mock sitting above it. A test that stubs the HTTP layer will happily pass while a double-booking vector sits one layer below it. We know because we've been on the wrong side of exactly this (more on that below).

Expiry is discovered, not scheduled

Providers rarely tell you a handle's TTL, and observed lifetimes drift. So you never hard-fail on a timer. You model staleness as detection signals on use (an HTTP 410, a FARE_EXPIRED code, sometimes buried inside a 200 payload), and treat any TTL number as a planning hint only ("don't start a commit with a 14-minute-old handle"), never as an enforcement clock.

A consequence for the decision table: a staleness signal must be matched before transport success, because a 200 can carry an expiry code in its body. Match "200 OK" first and you'll cheerfully proceed on a dead handle.

Invalidation is a graph, not a per-item TTL

When a handle dies and you re-mint it, everything derived from it is now stale, transitively. Round-trip flights are the clean example: the return list is priced against the selected outbound. Change the outbound and the returns, the checkout, the seat map, the booking prep are all invalid at once. And they must be replaced, never appended, because mixing returns coupled to two different outbounds silently corrupts the round-trip total.

Derivation graph: re-minting selected_outbound invalidates every handle derived from it

You can't express that with a TTL on each cached object. You express it by declaring what each handle was derived_from and letting a re-mint cascade down the edges. The classic bug is a missing edge: two handles that look independent but aren't. You find them by asking, for every pair with no edge, "if I re-mint A, is B really still valid?"

Keep the model out of the mechanics

Otto is autonomous, so the temptation is to let the model drive. It doesn't, and it mustn't. The boundary is strict:

The model may: choose among options a step returned, propose repairs to intent fields (a new date, a different card), answer the gates it's authorized to answer, and phrase outcomes for the user.

Deterministic code owns: step sequencing, attempt budgets, backoff, stop conditions, signal → verdict classification, handle values, invalidation, idempotency enforcement, and compensation.

The model can never extend a budget, re-classify a signal the table already matched, retry a commit, or even read a handle value. It sees only opaque aliases like flt_x7a2. A budget stated in prompt text is not a budget. A model message can never be a state transition, only an input to one, validated against a schema before code applies it. This is what makes "fully autonomous" and "safe with real money" coexist.

From lessons to a meta-skill

The payoff is that all of the above is now portable. We wrote it up as a spec (the data model, the verdict decision table, the invalidation rules) plus a skill that teaches a coding agent to compile a chain config for a new domain: it interviews the API docs with a fixed set of elicitation questions, produces the handle graph, classifies each action's effect, builds the verdict table, and (the important part) emits an explicit list of everything it couldn't answer, tagged by evidence source. A config with an unanswered commit-safety question is marked BLOCKED, not silently guessed. The skill also refuses when a chain is overkill (a single idempotent call doesn't need this machinery).

In other words: the knowledge that used to live in the heads of the people who'd survived the incidents now lives in something an agent can apply to a new stateful API (travel, payments, provisioning, logistics) before a line of client code is written.

To check the spec was actually load-bearing and not just tidy prose, we tested it by blind compile: hand an agent nothing but the spec and a provider's public docs, and score the config it produces. Across Duffel, Stripe, Travelport, and Booking.com the safety core reproduced every time. The blunt validation came on Booking.com, the one provider where we could compare the compile against our own production integration: it flagged a call our client was getting wrong. An unkeyed create request that a shared HTTP client was silently auto-retrying on timeouts, the exact double-booking vector described above. It had shipped months earlier. We've since fixed it and added a regression test. The lesson we'd written down was real enough to catch us breaking it.

If you're building on a stateful API

You don't have to adopt our spec. The transferable point is that recovery logic for a stateful, multi-step API is knowledge worth capturing as a design artifact, especially if an autonomous agent is going to execute it. Classify commits by the double-call test. Make "I don't know if it landed" a first-class state with a mandatory probe. Name where your budgets are enforced and test at the real transport boundary. Model staleness as a signal, not a timer. Draw the derivation graph explicitly. And keep the model on the right side of the deterministic line.

The spec, the skill, the conformance suite, and the blind-compile artifacts are on GitHub at https://github.com/ottotheagent/chain-re-action, if you want to see what we distilled, or point it at your own API.

Try Otto free for 1 year

$10/mo. Free – no credit card required. No contracts, no agent-assist fees, no minimum spend

Other technical posts