← All writing

27 October 2026

Human-in-the-Loop for Agents: Keep the Model Out of the Approval Path

An approval that travels back through the model is a suggestion, not an authorisation. How I'd design agent approvals — server-side resolution, compare-and-swap answers, no default on silence, version-bound approvals, and write-backs with idempotency keys.

In this article Don’t let the click travel through the model

Give an agent access to someone’s brokerage account and the first feature request will be “let it place the trade after I approve”. It’s a reasonable request. I’d still start by not building it.

For actions that move money, the simplest human-in-the-loop design is that the human does the action. The agent can research, draft, compute position sizes, and lay out the order it would place. The order form is filled in by a person, in the broker’s own interface or in yours, with no agent tool anywhere near it. If order endpoints exist in your API at all, keep them out of the agent’s reach entirely: out of the tool catalogue, behind an operator-level switch that’s off by default, and restricted to admins.

That sounds like dodging the problem, but it clarifies it. Once the irreversible, high-stakes actions are off the table, what’s left are actions where approval can be designed properly: deploying an alert, enabling a calculator, writing a note back to a CRM. Here’s how I’d design those.

Don’t let the click travel through the model

The most tempting implementation is also the weakest. The agent shows a card with “Deploy” and “Keep as draft”. Clicking Deploy sends a message back into the conversation — something like “Approved. Deploy this workflow with confirm=true.” — and the model reads it and calls the deploy tool.

It works in demos. But look at where authority sits: the last step between a person’s click and the action is a language model interpreting a sentence. The approval isn’t tied to any particular version of the thing being approved. A model can misread it. And nothing structurally stops a model from producing confirm=true without the click ever happening.

The fix is to resolve approvals on the server. The card’s buttons call an authenticated endpoint, and that endpoint performs the action. The click is the confirmation. The model is told what happened afterwards, as a fact in its context, not as an instruction to act on.

A few rules make that robust:

  • The tool that requests the action still refuses without explicit confirmation, and returns a structured “confirmation required” result that the UI renders as a card.
  • A shared or read-only view never authorises anything, even if the viewer can see the card.
  • After acting, read the result back. If a deploy endpoint can’t confirm the thing is actually active, return a conflict instead of a cheerful success.

There’s also a race to plan for. If the user approves something while the run is still going, the agent’s next edit can overtake the approval: “kept as draft”, then a new review card for a slightly different version. Mark cards from a still-running run as waiting, and make them actionable only when the run has finished. Useful card states: pending, resolved, superseded by a newer review of the same thing, expired, and waiting.

Questions: one answer wins, and silence decides nothing

Sometimes the agent needs a decision mid-run. Give it a way to ask, with a short timeout. Derive the question’s id from the run, its attempt, and the tool call, so a retried tool call asks the same question rather than a duplicate. Then make answering a compare-and-swap:

UPDATE questions
   SET status = 'answered', answer = :answer
 WHERE id = :id
   AND status = 'pending'
   AND expires_at > now()
   AND run_attempt = :attempt;
-- 0 rows → someone else answered, it expired, or the run moved on

That handles two tabs, two clicks, and a stale page in one statement, and it works the same on SQLite, where SELECT … FOR UPDATE doesn’t help you.

The other rule is that there’s no default answer. If nobody responds, the agent should be told that silence doesn’t select an option or authorise anything. It’s tempting to let a timeout pick the “safe” choice. But a default that fires on expiry is an approval nobody gave, and a late answer arriving after the default has been applied is a conflict you then have to explain to the user.

Approve exactly the thing that was tested

Some approvals are about artefacts the agent created. Say the agent can propose a small calculator: some code plus test cases, evaluated in a sandbox. Passing the tests shouldn’t enable it. A person should, and that approval should be bound to the exact proposal id, version, and evaluation report — and only if the evaluation passed. If anything changed between evaluation and approval, enabling fails with a clear message: review the new proposal.

Automation needs its own boundary. Planning-only modes and child agents shouldn’t be able to approve anything. If test suites need to auto-approve, restrict that to designated test accounts and specific action types, and record each one as automation, explicitly not as human authorisation.

Write-backs: prepare, ask, re-check, send

The most complete flow is for an agent writing to someone else’s system — updating a record, posting a note. I’d break it into steps, each with a state you can see:

  1. Prepare. Validate the payload against the action’s schema, canonicalise it, and derive an idempotency key from the run, the user, and the canonical payload. Store a row as awaiting confirmation. If the key already exists, return the stored result.
  2. Ask. “Send this to X?” showing the payload, with Send and Don’t send and no recommended option.
  3. Confirm. Compare-and-swap from awaiting confirmation to confirmed.
  4. Re-check. Evaluate permissions and information barriers again, fresh. Access can change in the minutes between asking and answering.
  5. Claim. Compare-and-swap from confirmed to sending.
  6. Act. Call the external system with the idempotency key.
  7. Record. Sent or failed, plus an audit receipt.
Fig. 1 — A write is prepared and stored before anyone is asked. The answer is a compare-and-swap, permissions are re-checked after approval, and the external call carries an idempotency key.

Refuse this flow outside an interactive session. A scheduled run or a child agent has nobody to ask, so it can read but never write. And a row stuck in “sending” after a crash is an unknown outcome. Show it as unknown and let a person reconcile it, rather than retrying automatically.

Handing control to a person

Browser agents need a different kind of approval. When the agent hits a login page or an action it isn’t allowed to take, the right move is often to hand the browser to the person, not to ask permission to continue. Approving that handover should give control to the human: bump a control epoch, revoke the agent’s lease, and make any agent command still in flight fail because its epoch is stale. (The browser agents post goes into that fencing.) Consequential browser actions — submitting forms that commit to something — I’d deny in code for now, and record a forced denial if someone approves one anyway.

Who’s allowed to approve

All of this sits on ordinary access control: roles, per-member spend limits with a warning before the hard stop, and, in finance, information barriers. Desks are walled off from each other, and some securities are restricted for some people. Admins shouldn’t get a read bypass. If a member is restricted from a security, consider not streaming tokens to them at all, since a ticker can be split across stream fragments. Screen the whole answer before it’s stored and shown.

And check that membership is active, not just that the user exists. A sign-up flow that creates pending members is easy to get wrong: if most routes only check for a user, a pending account can do far more than intended.

The rule I’d start from

Approvals are rows on the server. A click resolves a row. The model hears about it afterwards. If the model sits between a person’s click and the action, you don’t have an approval system. You have a suggestion box with very good manners.

← Back to writing