Step 5 of 7: Human approval
By the end you will be able to:
- Gate one consequential action behind
needsApproval - Approve or reject it from an approval card and see the effect
- Explain why the risky case waits and the small case does not
Use the Cloudflare account on your team card (also on the Workspace page). No account yet? Ask a host: you can read on, but you can't deploy until it is ready.
The curl and PowerShell examples use $DEMO_URL (macOS / Linux) or $env:DEMO_URL (PowerShell): your demo URL from your team card, set once per terminal in Step 1. Open a new terminal later? Set it again.
Why it matters
- Agents rarely fail on capability; they fail on accountability. The moment an agent can change something (refund, purge, publish, block), someone has to be able to say no before it happens, and be able to point at where they did.
- You approve the exact call, not a vague "are you sure?". The card shows the tool and the arguments the model chose, so a person decides on what will really run, not on a summary of it.
- Gate only the risky case. If every call asks, people learn to click Approve without reading. A small credit that flows and a large one that stops teaches the right habit, and it keeps the demo fast.
- "Who can say no?" is the first question a process owner asks about an agent. A card with Approve and Reject is a concrete answer you can show, not a promise.
The approval touches two places: the tool in the Durable Object, which pauses, and the chat page in the browser, which shows the card and sends your answer back over the WebSocket. Click any box to see what it does.
Click a box or an arrow to see what it does.
The concepts
You put needsApproval on a tool. When the model asks for that tool, the platform checks it, and if the answer is yes it shows the call to a person instead of running it. Here is why it works the way it does:
- The model proposes; the platform decides. The model only asks to call a tool. Your Worker is what runs it, and
needsApprovalsits in between. That is why the pause does not depend on the model behaving: it cannot skip a check that is not its to make. - The turn ends at the card and resumes after the click. Nothing runs while the card is waiting. When you click, the decision goes back to the agent and the same turn carries on. A turn that is waiting on a person is not treated as stuck: Cloudflare's docs say recovery parks it instead of failing it, and the eventual click resumes it.
- A function can look at the arguments.
needsApprovalistrue, or an async function of the same input asexecute.amountCents > 5000gates only the large credits; the same idea fits a production target, a customer tier or a record count. - Keep "ask for approval" out of the
description. If the description tells the model to ask first, it asks in chat text and never calls the tool, so no card appears. Approval is the platform's job, not the model's. - There is no click over
POST /api/chat. The card only exists in the chat page, so a gated call over/api/chatends the turn without running. That is deliberate: nothing consequential runs without a person, and it gives you a way to prove it withcurl. - Gate by the shape of your agent. The risky step is different for each kind of agent:
| Agent shape | A good human gate |
|---|---|
| Watcher | A human confirms before the agent opens an incident, pages someone or posts outside the team. |
| Operator | A human approves the plan (what changes, where, how to roll back) before anything irreversible runs. |
| Investigator | A human reviews the case file and makes the call; the agent never acts on its own conclusion. |
| Concierge | The agent hands off to a person for anything it cannot ground in a source, and never promises refunds, codes or exceptions. |
| Coordinator | A human gives the go/no-go; the agent only gathers status and proposes. |
Docs: Tool approval in chat agents · Human-in-the-loop patterns (the durable kinds)
Go deeper: reject with a reason, durable approvals and AI SDK versions
You do not need any of this to finish the step. It is what you reach for when the card is not enough.
Reject, and what the model learns from it
- The starter's Reject button sends
approved: false. The tool ends in a "denied" state with a generic message.executenever ran, so the tool cannot return a reason; any reason has to come from the page. - To give the model a reason, answer from the client.
addToolOutputwithstate: "output-error"sends your text back as the tool result, so the model can suggest a smaller credit or ask a question. It does not continue by itself: callsendMessage()afterwards. This is not in the starter; you would add it to the card insrc/app.tsx.
const { addToolOutput, sendMessage } = useAgentChat({ agent });
addToolOutput({
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Rejected: over the monthly credit limit. Offer a smaller credit.",
});
sendMessage();
Docs: Custom denial messages
A durable gate: a Workflow that waits
- Use it when the approver is not in the chat. A colleague can approve an hour later, after the chat is closed. The Workflow pauses on
step.waitForEvent, and the answer arrives withinstance.sendEvent({ type, payload }). - Facts to know. The event
typemay only use letters, digits,-and_. The wait can last from one second to 365 days and defaults to 24 hours. A timeout fails the whole instance unless you catch it, so wrap the wait intry...catchwhen "no answer" should end quietly.
try {
const answer = await step.waitForEvent<{ approved: boolean }>(
"await approval", { type: "credit-approval", timeout: "1 hour" });
if (answer.payload.approved) await step.do("issue credit", () => issueCredit(plan));
} catch {
// nobody answered in an hour: stop here, do not issue the credit
}
AgentWorkflowhas a built-in version. Inside it,this.waitForApproval(step, { timeout })pauses until the agent callsapproveWorkflow()orrejectWorkflow(). Cloudflare's docs describe waits of months or longer for it. Step 4 has the plain Workflow to start from.
Docs: Events and parameters · Run Workflows from an agent · Human-in-the-loop patterns
Mind the AI SDK version
- The starter uses AI SDK 6 (
ai^6). There,needsApprovalontool()is the way to gate a tool. - The live AI SDK docs describe version 7. In 7,
needsApprovalis deprecated in favour oftoolApproval, andstepCountIsis renamedisStepCount. Code copied from the newest docs will not build against the starter's version. - So tell your coding agent which version you have. "This project uses
ai6: useneedsApproval." The version is inpackage.json.
Build it
Do the three parts in order. Each command shows the system you picked on the Workspace page; where a command is the same on both, it is shown once.
Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder.
I am on Step 5 of Build with Cloudflare Stockholm. If you have the build-stockholm MCP server, call get_step with step 5.
In src/server.ts add a tool issueGoodwillCredit with inputs customerId (string), amountCents (positive integer, described as cents) and reason (string). Give it needsApproval: async ({ amountCents }) => { console.log("approval check", { amountCents }); return amountCents > 5000; }, and an execute that logs "credit approved and issued" and returns { customerId, amountCents, reason, issued: true }. Synthetic data only.
Run npm run dev and tell me to open the address it prints and send: "Use issueGoodwillCredit: issue a credit of 9000 cents to customer C-100 because the delivery was late." Tell me to click Reject, then ask again and click Approve, and tell you what happened. Then run npm run deploy and prove over POST /api/chat that a large credit stops without a click.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.
1. Add the gated tool
Which single action in your agent is expensive or hard to undo? Everything else can run freely; gate that one. This example is a goodwill credit, with the gate at 5000 cents ($50). Open src/server.ts, find tools(), and add it next to calculate. (The starter has a commented copy of it that you can uncomment instead: paste this one, it has the extra .describe() lines.) Keep the approval out of the description.
issueGoodwillCredit: tool({
description:
"Issue a goodwill credit to a customer for a service failure. " +
"Call this whenever the user asks for a credit.",
inputSchema: z.object({
customerId: z.string().describe("Customer id, for example C-100"),
amountCents: z
.number()
.int()
.positive()
.describe("Amount in cents; 9000 = $90"),
reason: z.string().describe("Why the credit is issued"),
}),
needsApproval: async ({ amountCents }) => {
console.log("approval check", { amountCents }); // shows in wrangler tail
return amountCents > 5000;
},
execute: async ({ customerId, amountCents, reason }) => {
// Synthetic demo: replace with your real call. This only runs after a click.
console.log("credit approved and issued", { customerId, amountCents, reason });
return { customerId, amountCents, reason, issued: true };
},
}),
What this does: a call runs straight away only when needsApproval returns false. For a large credit it returns true, so the call waits for a click. The .describe() on amountCents tells the model the unit, so it is less likely to send dollars. Replace the body with your own action; Use synthetic data for this workshop. Your demo URL and chat may be publicly reachable; do not upload real customer, employee or proprietary data.
2. Click through it in the chat page
npm run dev
Open the address it prints (usually http://localhost:5173) and send this message. Naming the tool helps, because the model sometimes answers in plain text instead:
Use issueGoodwillCredit: issue a credit of 9000 cents to customer C-100 because the delivery was late.
Open your agent in a browser and paste this into its message box.
- A card appears that says "Approval needed: issueGoodwillCredit" and lists the exact arguments. Nothing has run yet.
- Click Reject. The card changes to a "Rejected" badge. Nothing is printed in the terminal where
npm run devruns. - Ask for another large credit and click Approve. The terminal prints
credit approved and issued, and the agent tells you it was issued. - Now ask for a small one: "Use issueGoodwillCredit: issue a credit of 2000 cents to customer C-101 because the box was damaged." There is no card; the terminal line appears straight away.
What this does: you saw the three outcomes of one tool: stopped and refused, stopped and allowed, and allowed without asking. You should see: the card, the badge, credit approved and issued once per approved call, and nothing for the rejected one. The card may disappear for a moment right after you click while the agent carries on: that is normal.
3. Deploy, and prove it stops without a click
Deploy:
npm run deploy
Then follow the deployed agent's log in a second terminal (leave it running):
npx wrangler tail
Now call the same large credit over /api/chat, which has no browser and so no card:
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use issueGoodwillCredit: issue a credit of 9000 cents to customer C-100 because the delivery was late."}'
What this does: /api/chat runs the same agent, but there is nobody to click Approve, so the turn ends at the approval request. You should see: in wrangler tail, an approval check line with amountCents: 9000 and no credit approved and issued line; the reply is empty or short. No approval check line at all means the model never called the tool: ask again and name issueGoodwillCredit. Now the small one, which needs no approval:
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use issueGoodwillCredit: issue a credit of 2000 cents to customer C-101 because the box was damaged."}'
You should see: an approval check line with amountCents: 2000 followed by credit approved and issued.
Check it worked
- Large credit, chat page: a card with the exact arguments. Reject: a badge and no log line. Approve: one
credit approved and issuedline. - Small credit: it runs with no card.
- Large credit over
/api/chat: the turn ends andwrangler tailshows nocredit approved and issuedline. - For the demo: you can say in one sentence who can say no, and where in
src/server.tsthe rule lives.
Stuck?
- No approval card appears. Check you are in the chat page (
curlnever shows a card) and thatneedsApprovalreturnstruefor your test input: 9000 cents is above 5000. - The model answers in text and never calls the tool. Name it in the prompt ("Use issueGoodwillCredit: ..."). If it still asks "please confirm" in chat, the tool
descriptiontells it to ask for approval: remove that sentence. - A card appears, but for
calculate. The starter'scalculatetool also has a gate (a number above 1000). A prompt full of big numbers can make the model reach for it. Say what you want in words, and nameissueGoodwillCredit. - The card shows dollars where you expected cents (or a validation error on
amountCents). Keep the.describe("Amount in cents; 9000 = $90")line. If the model sends the number as a string, Cloudflare's own example usesz.coerce.number()for the amount; we have not tested that here. - It asked for approval every time. Your function returns
truetoo often; log the input and check the condition. - The action ran without asking. The model may have called a different tool that does the same thing. Gate the tool that does the work.
- The chat goes silent after a card appeared. A card is still waiting for Approve or Reject, and sending another message first leaves the turn without a result (
npx wrangler tailshowsTool result is missing). Click Approve or Reject on the card, or press Clear at the top of the chat page and ask again. - Approve did nothing. Run
npx wrangler tail(deployed) or look at thenpm run devterminal, and read the error insideexecute. - Over
/api/chatthe reply is empty or short. That is the point: the turn stopped at the approval request. Test approvals in the chat page. needsApprovalis marked deprecated, ortoolApprovalis "not found". You are reading the AI SDK 7 docs; the starter uses version 6. See "Mind the AI SDK version" under Go deeper, and tell your coding agent which version you have.- Type errors.
needsApprovaltakes the tool input, likeexecute. Both must accept the same object.
Go further
- Record each approved action in a SQL table (see Step 3) so your demo can show an audit trail: who asked, what, when.
- Move the gate into a Workflow so a colleague can approve later, even after the chat is closed. See Go deeper above, and the Workflow in Step 4.
- Let Reject carry a reason the model can use. See Go deeper above (it needs a small change to the card in
src/app.tsx). - Gate a second tool with a different rule: a function of the arguments, such as a production target or a record count.
- Docs: Tool approval in chat agents · Human-in-the-loop patterns.
Finished step 5?
Checking your team…
Back: Step 4: Add a primitive. Next: Step 6: Secure it.
Need help? Raise a hand for a host, or ask the Mentor dock (it escalates to a host when unsure). Remote: remote help channel to be confirmed by the event owner.