It was described as irreversible. It got called twice anyway.
The tool was called cancel_booking. Its description read: "Cancel a booking. Irreversible: the slot is released to the market immediately." The project granted approvals. There was an onApproval handler that refused everything. It was never once consulted.

Nothing was broken. The model just did not ask.
That sentence is the whole post. Everything below is what we changed because of it.
Two different things called "approval"
The agent can decide to stop and ask permission. That is request_approval, it is a real capability, and it works — the agent describes what it is about to do, and nothing happens until a person answers:
onApproval: async (request) => {
// request.action — one line, with the specifics in it
// request.detail — the message it would actually send
// request.consequence — what cannot be undone
return await showApprovalDialog(request);
}This is genuinely valuable and it covers a large middle ground: the agent usually knows perfectly well that sending the email is the irreversible step. But the mechanism is the model deciding to ask. Which means it is a habit, measured in percentages, and the percentage is not 100.
That is fine for "let me confirm the tone of this draft". It is not fine for money moving, a message sent as the user, or anything deleted.
So there is a second mechanism where the gate fires because the tool was called, not because the model chose to be careful:
{
name: "cancel_booking",
description: "Cancel a booking. Irreversible: the slot is released to the market immediately.",
parameters: { /* … */ },
requiresApproval: true,
handler: async ({ booking_id }) => cancel(booking_id as string),
}A model that has never heard of request_approval, or that simply does not think to ask, cannot get past this. The only way to skip it is not to declare the tool.
The shape of the pause
The call comes back in approvals and not in tool_calls:
{
"requires_action": true,
"tool_calls": [], // ← the call is NOT here yet
"approvals": [{
"tool_call_id": "call_abc123",
"action": "Cancel a booking. Irreversible: the slot is released to the market immediately.",
"detail": "booking_id: NW-3009", // the arguments — this is what is being decided
"tool": "cancel_booking", // null when the MODEL asked, via request_approval
"arguments": { "booking_id": "NW-3009" }
}]
}The empty tool_calls is load-bearing. Tool auto-dispatch runs over tool_calls, so a client that helpfully executes everything it is handed can never reach a call that is being decided about. Nothing should be able to approve an irreversible action by accident, and "by accident" here means "by a loop that was written before the gate existed".
Approve it and the identical call arrives in tool_calls on the next round. Refuse it and the agent is told, in the same words as any other refusal, that stopping was the point.
action is the tool's own description — so write that sentence for the person who will read it. You already wrote it to tell the model when the tool applies; it turns out to be the sentence a human reads at the moment that matters.
Saying no has to mean no
This needed to be spelled out explicitly to the agent, because the helpful behaviour is the wrong one. Told it may not send the email, a capable agent reaches for a different way to send the email.
So: a refusal stops the work. The agent is told not to try another way, not to reach for a different tool that achieves the same thing, and not to continue with any part of the plan that depended on it. It comes back and asks what you want instead.
A note on the decision is the difference between "no" and "no, use the other address":
{ "approved": false, "tool_call_id": "call_abc123", "note": "use finance@acme.com instead" }And an outstanding approval you send no decision for stays outstanding. The turn pauses again rather than reading silence as either answer.
The null that turned the gate off
This is the bug in this area I am least happy about, and it is worth describing because the shape of it is common.
requiresApproval is set with a PATCH. It takes true or false. Most typed HTTP clients serialise an absent optional field as null — so {"requiresApproval": null} is the body a client sends to mean "I am not touching this."
It used to be read as false. And answered 200.
So a partial update to an unrelated field turned the approval gate off an irreversible tool and reported success. null, 0 and the string "false" are now all refused with a 400 that changes nothing. A partial PATCH is honoured: send only what you are changing and the gate is left exactly as it was.
There is a second spelling trap in the same area, and it is now a 400 rather than a silent no-op: the write surface spells it requiresApproval, while GET /tools reports requires_approval. Sending the underscored name to the PATCH fails loudly, which is the only thing that keeps two spellings from becoming a trap.
Unattended runs pause too
A gated webhook tool pauses on a scheduled run, a webhook trigger and a subagent. Nobody is there, so the turn waits.
That is deliberate, and it is the opposite of what we do with questions. An unanswered question is safe to proceed without — the agent uses its judgement and the answer would only have made it better, which is why ask_user is not offered to an unattended run at all. An unanswered permission is not safe to proceed without.
The two alternatives are both worse. Withholding the tool takes away the capability you configured on purpose. Skipping the gate where nobody is watching removes a safety control exactly where it matters most — "requires approval when convenient" is not a security property.
So the turn waits, and the decision can be given out of band from your turn.stopped handler with approval_decisions. And because a stalled task is otherwise invisible, GET /readiness raises a tasks-waiting step naming the task, when it stopped, and the session holding the question.
