labs | 04 | hitl
lab 04 | ~7 min | the trap

The tool that refuses to fire.

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.

Checking for WebMCP...
step 1

Split the action from the effect.

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.

action, then effect
// 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
}
step 2

Register delete_account. The execute returns pending, not done.

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.

the gated tool
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 }] };
    }
  });
}
step 3

Try to delete the account.

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.

account settings | destructive action, gateddelete_account: not registered
Confirm account deletion
A deletion was requested by the agent. This permanently removes Ada Lovelace and cannot be undone. Nothing has been deleted yet. You must confirm to proceed.

activity log

  • Account active. Request a deletion to see the gate arm without acting.
step 4

Read what the agent got back.

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.

what the agent reads back from execute
{ "content": [ { "type": "text",
  "text": "PENDING. A human must confirm in the page. Nothing deleted yet." } ] }

why destructive actions are never one-shot

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.

what you built tonight

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.