# Exactly once: how an approved agent action runs with frozen arguments (/blog/exactly-once-approvals)

![Illustration: A small friendly robot with a round camera-eye head stands still at a low garden gate holding one paper slip pressed flat under a glass paperweight.](/blog/exactly-once-approvals/hero.webp)

An approved agent action runs exactly once because the call is frozen when held, the agent waits on one id, and approval hands that frozen call to the waiting request. The arguments a person approves are the arguments that run. Two simultaneous approvals hand it over once. A re-sent call is a new request and a new card.

**Exactly once**

The call is frozen when held. The arguments a person approves are the arguments that run.

* Agent → CoreSpeed: tools/call refund $240
* CoreSpeed: judged: Ask. Call frozen, id minted
* CoreSpeed → Agent: Approval required, not run, id
* Agent → CoreSpeed: manage\_\_approval\_wait(id)
* CoreSpeed → Person: card: dashboard, email, Slack
* Person → CoreSpeed: Approve
* CoreSpeed → Stripe: the frozen call, once
* CoreSpeed → Agent: the original tool's result

Two simultaneous approvals hand the call over once. The second person sees who decided.

One card, one id, one execution.

## What does the agent receive when a call is held? \[#what-does-the-agent-receive-when-a-call-is-held]

A write that your policy wants a person to see does not hang. It returns at once with an error-shaped receipt that says, in effect: "Approval required, not run, call `manage__approval_wait` with this id." The tool keeps its own name. There is no `_with_approval` variant and no second path around the gate.

The receipt is the whole protocol. Nothing has to be taught to the agent, because the next step is written in the result it just read:

```text
tools/call  stripe__stripe_api_write       -> isError: Approval required, not run,
                                              call manage__approval_wait with this id
tools/call  manage__approval_wait (that id) -> blocks, then returns the original
                                              tool's result, or a denial or expiry error
```

The id is in the agent's hands before any waiting starts. That ordering matters for everything below.

## How does approval\_wait work? \[#how-does-approval\_wait-work]

`manage__approval_wait` blocks until a person decides. It then returns the original tool's real result, as if the first call had run, or an error saying the action was denied or the card expired. A denial can carry the reason the person gave.

How long one call can block is up to the client. Clients that send a progress token, as Claude Code does, get a progress notification every ten seconds and hold one call for the whole wait. A client that times out calls `approval_wait` again with the same id. Nothing is lost, because the id, and the frozen call behind it, never depended on the connection staying open.

## Why are the arguments frozen? \[#why-are-the-arguments-frozen]

The call is frozen at the moment it is held. The arguments shown on the card are the arguments that will run, and they cannot drift between the moment a person reads them and the moment the action executes.

The dashboard queue is the only place the full call is shown, and the arguments are displayed in full while the card is open and cleared when it closes. The email carries the tool and who asked, with approve and deny links; the arguments stay behind your session. The Slack card is posted by the CoreSpeed bot in the workspace an admin installed. An agent's own Slack tools cannot post a card or write into the approvals channel, so a card in that channel is always CoreSpeed asking.

Approving hands your decision to the agent request that is waiting. That request runs the frozen call once and receives the result. The approval is a one-time credential for one frozen call.

What if two people approve at once? They hand it over once. Two clicks at the same moment, or a Slack click racing a dashboard click, resolve to one execution, and the second person sees who decided. A Slack card is rewritten in place to say so. An email that already went out is not recalled, which is why the queue is the place to check what is still open.

Who can decide is also fixed: the member who made the call, or the owner of the agent or API key that made it. An agent cannot approve its own action. Admins can see every card, and seeing is not deciding.

## Why must the agent not re-send the call? \[#why-must-the-agent-not-re-send-the-call]

A fresh `tools/call` is a new request. It is judged on its own, it can raise a second card, and approving both runs the action twice. The judge counts how often this caller used the same tool recently, and a re-sent identical call is a new call with a new judgement.

This is the one place where an agent's usual retry instinct is wrong. The receipt says so, and `manage__approval_wait` exists so there is something else to do. One card, one id, one execution. The retry rules in [the errors reference](/docs/errors) say the same: holds do not clear on retry.

## When does a card expire? \[#when-does-a-card-expire]

| Situation                                               | What happens                                                |
| ------------------------------------------------------- | ----------------------------------------------------------- |
| The agent is waiting on `approval_wait`                 | the card stays open                                         |
| The agent stops waiting (model gives up, client closed) | the card expires about thirty seconds later                 |
| Nobody decides                                          | the card expires after an hour                              |
| Someone approves an expired card                        | nothing runs; ask the agent again and a new card appears    |
| Someone denies                                          | the agent gets the denial, with the reason if one was given |

The thirty-second rule follows from the design. The agent's own waiting request is what runs the call. If no request is waiting, there is nothing to hand the approval to, so the queue says "no agent was waiting for the result" rather than running something no one will collect.

Every decision lands in the [activity trail](/docs/activity), and a gated call carries its approval id there, so you can trace a card to the action it released. The gate itself is described in full on the [Approvals page](/docs/approvals), and the design reasons are in the [launch post](/blog/introducing-smart-approval).

## FAQ \[#faq]

**My client timed out during approval\_wait. Did I lose the approval?**
No. Call `manage__approval_wait` again with the same id. The card and the frozen call are still there if the person has not decided and the card has not expired.

**Can a person edit the arguments before approving?**
No. The arguments on the card are the arguments that run. To change them, deny, and have the agent make a different call.

**What if the agent sends the same call again while the first card is open?**
That is a second request with its own judgement and possibly its own card. Approving both runs the action twice. Wait on the first id instead.

**Does the Slack card show the argument values?**
The dashboard queue is the only place the full call is shown. Review there if you need to read the values before deciding.