BlogAgents7 min read

The approval gate is a state machine, not a button

Everyone builds the confirm dialog. The hard part is what holds the run's state for three days while it waits, and what happens if the answer never comes.

"Add a human in the loop" gets scoped as a confirm dialog. Show what the agent wants to do, wait for a click, proceed.

That works right up until the person does not click, which is most of the time.

What actually happens

The agent decides to do something at 4pm on a Friday. The approver is not at their desk. They see it Monday. In between: the process restarted twice, a deploy went out, the token the agent was holding expired, and the underlying record changed.

The gate is not a pause in a function. It is a suspension of a process across an interval you do not control.

Once you say it that way, the requirements fall out.

The states you actually need

proposed  → the agent has decided; nothing has happened yet
pending   → a human has been asked; we are waiting
approved  → a human said yes
rejected  → a human said no, with a reason
expired   → nobody answered within the window
applied   → the side effect completed, exactly once
failed    → the side effect was attempted and did not complete

Two of those are the ones people skip.

expired — because "waiting forever" is not a state, it is a leak. Every gate needs a window and a defined behaviour at the end of it. Usually that is "cancel and notify", almost never "proceed anyway", and the decision has to be explicit rather than emergent.

failed as distinct from rejected — a human declining and the API returning 500 are completely different events. Collapsing them means you cannot retry one and must not retry the other.

The proposal has to be self-contained

The most common bug: the agent stores "call sendEmail with these arguments", and by Monday the arguments reference a draft that has been regenerated, or a customer record that changed.

The proposal must capture everything needed to execute it, plus enough context to review it:

  • the exact operation and its fully-resolved arguments
  • a hash of the state it was computed against
  • a human-readable description of what will happen
  • what the agent was trying to achieve

That state hash matters. If the world moved while the request sat in someone's queue, the approver approved something that no longer describes reality. Checking the hash at apply time turns a silent wrong action into an explicit re-proposal.

Applying exactly once

Approval makes double-execution far more likely, not less — because now there is a retry path, a webhook, and a person who might click twice.

Every gated action needs an idempotency key generated at proposal time and carried through. The apply step is:

if (proposal.status === "applied") return proposal.result  // already done
const result = await perform(proposal.operation, { idempotencyKey: proposal.id })
await markApplied(proposal.id, result)

Note the order. Marking applied after performing means a crash in between leaves an action performed but unmarked — which is why the underlying operation needs the idempotency key too. Belt and braces, because this is the exact place where "we sent the invoice twice" comes from.

What this buys

Once approvals are a state machine with a record per proposal, several things become free that are otherwise projects:

  • a complete audit trail of what was proposed, by what, approved by whom, and when
  • the ability to replay what an agent wanted to do and compare it to what a human decided — which is the best available signal for where the agent is weak
  • the option to auto-approve categories once the record shows they are always approved

That last one is the payoff. You start fully gated, watch which proposals humans always approve, and relax those specifically — with evidence rather than nerve.


Written up in more detail as agents that stop before they change anything, and it is how we build agentic workflows.

Something here

the audit is the cheapest way to find out for certain.