Labs/Tool-call lifecycle and consent
05

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.

tool-call statesfrontend vs backend toolsreversibility classesinterrupts
Action
1 · Pick what the agent wants to do
UI behaviour
2 · How the generated UI behaves
step 00 / 06
3 · What the person seesidle

Press play. The agent decides to call delete_account() and its arguments start streaming.

4 · Launch policythe ladder only tightens
  1. 1reversible-by-user → noneBefore: Nothing. Don't interrupt.
  2. 2reversible-by-operator → explicit-consentBefore: A confirmation the person can see and accept.
  3. 3irreversible-bounded → explicit-consentBefore: A confirmation that names the consequence: who, how much, and that it can't be undone.
  4. 4irreversible-unbounded → human-approvalBefore: Approval from a human outside the run (an admin). A dialog is consent; it is not approval.
5 · Verdictgraded on the effect, not the pixels
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 →

6 · Timeline · screen vs wire confirmation on screen admin approval pending
tool state
screen shows
consent
network
123456
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
}