Skip to main content

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 needsApproval sits 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. needsApproval is true, or an async function of the same input as execute. amountCents > 5000 gates 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/chat ends the turn without running. That is deliberate: nothing consequential runs without a person, and it gives you a way to prove it with curl.
  • Gate by the shape of your agent. The risky step is different for each kind of agent:
Agent shapeA good human gate
WatcherA human confirms before the agent opens an incident, pages someone or posts outside the team.
OperatorA human approves the plan (what changes, where, how to roll back) before anything irreversible runs.
InvestigatorA human reviews the case file and makes the call; the agent never acts on its own conclusion.
ConciergeThe agent hands off to a person for anything it cannot ground in a source, and never promises refunds, codes or exceptions.
CoordinatorA 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. execute never 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. addToolOutput with state: "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: call sendMessage() afterwards. This is not in the starter; you would add it to the card in src/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 with instance.sendEvent({ type, payload }).
  • Facts to know. The event type may 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 in try...catch when "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
}
  • AgentWorkflow has a built-in version. Inside it, this.waitForApproval(step, { timeout }) pauses until the agent calls approveWorkflow() or rejectWorkflow(). 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, needsApproval on tool() is the way to gate a tool.
  • The live AI SDK docs describe version 7. In 7, needsApproval is deprecated in favour of toolApproval, and stepCountIs is renamed isStepCount. 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 ai 6: use needsApproval." The version is in package.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.

Prompt for your coding agent
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:

Type it in your agent’s chat page
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 dev runs.
  • 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:

macOS / Linux
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:

macOS / Linux
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 issued line.
  • Small credit: it runs with no card.
  • Large credit over /api/chat: the turn ends and wrangler tail shows no credit approved and issued line.
  • For the demo: you can say in one sentence who can say no, and where in src/server.ts the rule lives.
Stuck?
  • No approval card appears. Check you are in the chat page (curl never shows a card) and that needsApproval returns true for 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 description tells it to ask for approval: remove that sentence.
  • A card appears, but for calculate. The starter's calculate tool 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 name issueGoodwillCredit.
  • 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 uses z.coerce.number() for the amount; we have not tested that here.
  • It asked for approval every time. Your function returns true too 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 tail shows Tool 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 the npm run dev terminal, and read the error inside execute.
  • Over /api/chat the reply is empty or short. That is the point: the turn stopped at the approval request. Test approvals in the chat page.
  • needsApproval is marked deprecated, or toolApproval is "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. needsApproval takes the tool input, like execute. 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.