Three labs registered tools that just run. This one is different on purpose. A delete_account tool must never delete on a single agent call. Its execute does not perform the deletion. It arms a consent gate inside the page and returns a pending state. Nothing is deleted until a human clicks confirm. This is the human-in-the-loop pattern, and it is the one the rest of the room will get wrong.
The mistake is one function that both an agent and a button call, that deletes on the spot. For a destructive action, split it. requestDeletion() only arms a gate and records intent. confirmDeletion() is the only function that actually deletes, and only the human Confirm button calls it. The agent can reach the first. It can never reach the second.
// state let accountActive = true; let pending = false; // STEP A: arm the gate. This is all the agent can trigger. function requestDeletion(source) { if (!accountActive) return "Account is already deleted."; pending = true; showConsentGate(source); // reveal Confirm / Cancel in the page return "PENDING. A human must confirm in the page. Nothing deleted yet."; } // STEP B: the actual effect. ONLY the human Confirm button calls this. function confirmDeletion() { if (!pending) return; accountActive = false; pending = false; hideConsentGate(); render(); // account now shows DELETED }
Same registration shape as every other tool, one critical difference: execute calls requestDeletion, never confirmDeletion. It returns a string that tells the agent the truth: the action is pending human confirmation and nothing has been deleted. A well-behaved agent reads that and waits. A misbehaving one cannot force the deletion, because the delete code is not on the path it can call.
if (navigator.modelContext && typeof navigator.modelContext.registerTool === "function") {
navigator.modelContext.registerTool({
name: "delete_account",
description: "Request permanent deletion of the current account. "
+ "This does NOT delete immediately. It asks the human to confirm "
+ "in the page. Returns a pending status.",
inputSchema: { type: "object", properties: {} },
execute: async () => {
const status = requestDeletion("agent"); // arms the gate, does NOT delete
return { content: [{ type: "text", text: status }] };
}
});
}
Invoke delete_account from the Tool Inspector, or press Request deletion below, or use Simulate agent call. Every path arms the same gate and stops. The account stays active. Only clicking Confirm delete in the gate changes anything. Cancel, and the pending request clears with nothing lost.
The agent never received a "deleted" result from its call. It received a pending status. The only way the account reaches the deleted state is a human hand on the Confirm button. That gap between the agent's request and the human's confirmation is the entire safety property. It is one if and one extra function.
{ "content": [ { "type": "text",
"text": "PENDING. A human must confirm in the page. Nothing deleted yet." } ] }
An agent can be wrong. It can be prompt-injected by a page it visited earlier, confused by an ambiguous instruction, or simply mistaken about what the user meant. If delete_account deleted on a single tool call, any of those failures is irreversible. The consent gate makes the failure recoverable: the worst an errant agent can do is arm a prompt the human then cancels. The rule is a gradient, not a switch. Reads need no gate. Cheap, reversible writes (add to cart, save for later) can fire directly. Expensive or irreversible actions (delete, pay, send, publish) get a human in the loop. Instrument the whole gradient, and put the gate exactly where reversibility ends.
Across four labs: a feature-detected registration, a live cart-counter an agent can press, three tools on a product page, and a destructive action that cannot fire without consent. No agent SDK ever entered the page. Just navigator.modelContext and semantic HTML. Your site now reads to an agent the way it reads to a human, and the one action that should scare you is behind a gate. Tax paid once.