Confirmation before effect
Walk a tool call through input-streaming → approval → execution, and see how the effect's reversibility decides what the interface must show first.
GoalTreat consent as a UI primitive tied to consequences, not a dialog sprinkled on top.
Press play. The agent decides to call delete_account() and its arguments start streaming.
- 1reversible-by-user → noneBefore: Nothing. Don't interrupt.
- 2reversible-by-operator → explicit-consentBefore: A confirmation the person can see and accept.
- 3irreversible-bounded → explicit-consentBefore: A confirmation that names the consequence: who, how much, and that it can't be undone.
- 4irreversible-unbounded → human-approvalBefore: Approval from a human outside the run (an admin). A dialog is consent; it is not approval.
effect DELETE /api/account declared irreversible-unbounded → policy requires human-approval decision — No request has left the browser yet.
Verdict wording follows RUI's report format. RUI does this for real: it drives the page in Chrome, pauses the request at the network layer and reads what was on screen at that instant. RUI checks this in a real browser →
How it worksclick to collapse
A tool call is a state machine. Modern agent SDKs expose it to the UI: arguments stream in, become available, optionally wait for approval, execute, then produce output or an error. Your interface renders each state, so consent belongs inside that machine, not bolted on after.
Consent scales with consequence. Renaming a draft needs nothing but an undo. Deleting an account needs someone other than the person in the chat to sign off. Same dialog component, different requirement — so the requirement has to be declared per effect.
Screens lie, wires don't. “Fires early” renders the exact same dialog as “Correct”. Only the timeline shows the request leaving before the person agreed. That is why a screenshot test or an LLM judge looking at pixels passes both.
const POLICY = {
"reversible-by-user": "none",
"reversible-by-operator": "explicit-consent",
"irreversible-bounded": "explicit-consent",
"irreversible-unbounded": "human-approval",
}
async function execute(call, ui) {
const needs = POLICY[call.effect.class]
if (needs !== "none") {
const ok = await ui.confirm(call) // visible, accepted
if (!ok) return { state: "output-denied" }
}
if (needs === "human-approval") {
const ok = await approvals.request(call) // outside the run
if (!ok) return { state: "output-denied" }
}
return run(call) // only now does the request leave
}