The propose-then-confirm pattern for AI-initiated writes

An action tool's execute function shouldn't perform the real mutation. This lesson covers what it should do instead — and the small piece of state that makes the whole pattern work.

Intermediate

3 min read

The core idea, restated concretely

When a model calls an action tool, the tool's execute handler doesn't touch the real data. Instead, it does three things: writes a pending action record describing exactly what's proposed, returns a status telling the model this is awaiting confirmation, and includes a short, human-readable summary the client can show the user. The actual mutation only happens later, from a separate, explicit confirmation step — a distinct endpoint the client calls only after a human has actually reviewed and approved what's proposed.

Why the confirm step reuses the exact same service method as every other write path

The pending-action row and the confirm endpoint are new; the actual business logic that creates an expense, cancels a booking, or records a payment is not duplicated for the agent's benefit — it calls the identical service method a regular UI form, an admin panel, or any other write path in the system already calls. This matters for two reasons: it means every validation rule, side effect, and invariant that already applies to that operation applies here too, automatically, with zero risk of the AI-initiated path silently skipping a check the "normal" path enforces; and it means there's genuinely one source of truth for "what happens when an expense is recorded," not two implementations that can drift apart over time.

Why the pending action needs to be a real database row, not something held in memory

A pending action has to survive: the request that created it finishing, the server process potentially restarting, and — in any horizontally-scaled deployment — the confirm request landing on a different server instance than the one that proposed it. An in-memory map keyed by proposal ID works fine on a single dev machine and silently breaks the moment there's more than one server process, or a deploy happens between propose and confirm. Persisting the pending action to the database "from day one," rather than optimizing it away as premature, is what makes confirm reliably work under real production conditions.

Expiry: a pending action shouldn't live forever

A proposal a user never acts on — they closed the tab, got distracted, came back three days later — shouldn't still be confirmable then, especially if the underlying data has since changed (the account it referenced was deleted, the price it quoted is stale). A time-to-live on pending actions (a common concrete value: 15 minutes) means an attempt to confirm an expired proposal fails cleanly, rather than silently executing a decision that's no longer meaningfully "the current state of the world."

What this pattern buys, stated plainly

The model's judgment (should this tool be called, with what arguments) and the system's actual state change (did this really happen) are two genuinely separate events, connected only by an explicit human action in between. A hallucinated or misjudged tool call, on its own, never modifies anything — it only ever proposes. This is the single design decision that makes it safe to let a model call action tools at all.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. A model calls a record_expense action tool. What does the tool's execute function do?

2. Why does the confirm endpoint call ExpensesService.create() — the exact same method a regular UI form uses — instead of a separate, agent-specific implementation?

3. A pending action is stored in an in-memory map on the server that proposed it. What breaks this design in a real production deployment?

4. A user proposes an action via the agent, then abandons the conversation for three days before returning to confirm it. What should happen?