Topic 8.6
Human Approval
In one line
For actions that matter, the server pauses the tool call and asks the person first. The tool part enters approval-requested, the card shows exactly what will happen, and the answer (yes or no, with a reason) is sent back. If the answer is no, the tool never runs and the model is told why.
Think of it like this
A bank clerk who types in a transfer and then turns the screen to you: "Send 5,000 rupees to Mr. Rao, account ending 4421. Sign here." The clerk did the typing, but only your signature releases the money. Human approval is that signature. The assistant prepares the action and a person releases it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Human approval
- A pause before a tool runs, in which a person sees what will happen and says yes or no.
- toolApproval
- The
streamTextoption that says which tools need approval (or a function that decides per call). - needsApproval
- The older flag on the tool itself. Still works, marked deprecated. Prefer
toolApproval. - approval-requested
- The tool-part state while the server waits for the person.
- addToolApprovalResponse
- The
useChatfunction that records the person's yes or no for one approval id. - requestReason
- Text the server attaches to the request so the card can say what will happen.
- execution-denied
- What the model is told when the person says no: the tool did not run, with a reason.
Step by step
01The whole round trip
Two requests again, like a browser tool. The first ends with a question to the person. The second, sent after they answer, either runs the tool or tells the model it was refused.
02Approve
The first live example cancels ORD-1043. The server decides with toolApproval, and the text on the card ("Cancel ORD-1043: Ginger biscuits, Rs 180, status processing") comes from the server's order table.
The scripted visitor presses Yes twice in a row. Read the console: execute printed once. The card disappears after the first press.
03Deny
The second example presses No with a reason. The state goes approval-responded with the reason attached, then output-denied. The server never prints EXECUTE, and the model line shows exactly what it was told: execution-denied with the reason. The page says "Not cancelled. Your order is unchanged." in words that do not depend on the model.
04Expire
The third example shows a card with a timer. In a real app the timer would be a minute or two. Here it is 300 ms so you can see it. When it fires, the card answers "no" itself with the reason "The request expired before the customer answered." The buttons are gone and a late click is impossible.
A timer is fine here because it is the feature being shown. The page script waits for the expired state before it looks, instead of guessing how long it takes.
05The server side in a real app
The only things that change in a real app are the model and the data source. toolApproval can also be a function of the call, so you can skip the question for harmless cases ("not-applicable"), auto-deny some ("denied") and ask for the rest.
const result = streamText({
model: openai("your-model-name"),
messages: await convertToModelMessages(messages),
tools: { lookupOrder, cancelOrder },
toolApproval: {
cancelOrder: async ({ orderId }) => {
const order = await db.orders.find(orderId);
if (!order || order.status !== "processing") return "not-applicable"; // execute will explain
return { type: "user-approval", reason: "Cancel " + orderId + ": " + order.item + ", Rs " + order.price };
},
},
stopWhen: stepCountIs(4),
});Try it yourself
- 1
Change the text on the card
In the first example, change the
reasonstring the server builds, for example add". This cannot be undone."at the end.The card text changes, with no change in the browser. The card reads
requestReason, which the server controls. - 2
Ask for approval only when it matters
In the first example make
toolApprovalreturn"not-applicable"when the order's status is notprocessing. Change the order's status toshippedin the data and press Run.No card appears and the tool runs straight away. Look at the last line: the stand-in model still says
Done. ORD-1043 is now cancelled.and the console showsEXECUTE cancelOrder, even though the order wasshipped. This smallexecutehas no check of its own, so skipping the card also skipped the only safety. Usenot-applicableonly whenexecuterefuses the same cases itself (Topics 8.8 and 8.9).
Code & diagrams
Pressing Yes twice runs the cancel once. A real request would also sign the approval on the server (Topic 8.8).
Preview
Console
Nothing logged yet.Expected console output
tool state: approval-requested
card says: Cancel ORD-1043: Ginger biscuits, Rs 180, status processing
tool state: approval-responded
(server) EXECUTE cancelOrder ORD-1043
tool state: output-available
answer: Done. ORD-1043 is now cancelled.The (server) line shows the exact message the model receives. The words on the page (Not cancelled) are ours, not the model's.
Preview
Console
Nothing logged yet.Expected console output
tool state: approval-requested
tool state: approval-responded (reason: The customer pressed No.)
(server) the model is told: {"type":"execution-denied","reason":"The customer pressed No."}
tool state: output-denied (reason: The customer pressed No.)
page: Not cancelled. Your order is unchanged. | Understood. I have not cancelled ORD-1043. Anything else?The timer starts when the card appears. After it fires, the card is gone and the order was never touched (no EXECUTE line).
Preview
Console
Nothing logged yet.Expected console output
tool state: approval-requested
approval expired
tool state: approval-responded
tool state: output-denied
late click possible: false
page: This request expired. Nothing was cancelled. | That request timed out, so nothing was cancelled. Ask me again whenever you are ready.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to resend after the answer
In the first example delete the line sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, and press Run.
Myth vs fact
Myth
Asking the model "are you sure?" is an approval step.
Fact
The model can be wrong or misled about that too. Real approval is a person pressing a button your code waits for.
Myth
Denying makes the model give up.
Fact
The model is told the tool was not run and why, and then writes an answer. It may also try again or suggest something else, so your server must keep gating the tool every time.
Myth
The approval card can safely show what the model said it will do.
Fact
Show facts from your own data. The model's wording is a claim, and the input it sent may differ from what it described.
Myth
Approvals last until the user answers.
Fact
Nothing expires them by default. If a stale card stays clickable, a later click does something the person no longer remembers asking for.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Show the same facts in the same place every time (order, item, price, effect), and put the dangerous button on the right in a distinct colour, with Cancel / Keep wording that says what it does. "OK" and "Cancel" buttons are ambiguous when the action is itself called cancel.
- ▸
Put keyboard focus on the safe button (No, keep it) when the card opens, and mark the card as
role="alertdialog"with a label, so a screen reader announces it. - ▸
A pending approval is part of the saved conversation. If the user reloads, the card should still be there, and the server must still accept the answer. Persist messages with their parts.
- ▸
Log both sides: each request (what, for whom, when) and each answer (yes/no/expired). You will need this record the first time a customer says "I never agreed to that".
- ▸
toolApprovalcan also be a function of every tool call, which lets you centralise policy ("anything that changes money needs approval") in one place instead of repeating it per tool.
Remember this
- 1
The problem this solves: A model can be wrong, misled by text it read, or simply misunderstand. If a tool can cancel an order, delete a file or send an email, one wrong guess does real harm that cannot be taken back. Asking the model "are you sure?" does not help, because it can be wrong about that too. The reliable fix is to put a person, and a button, between the model's request and the action.
- 2
How it works in the SDK. On the server you mark the tool as needing approval:
toolApproval: { cancelOrder: "user-approval" }instreamText. (Older code writesneedsApproval: trueon the tool. It still works but is marked deprecated in SDK 7, andtoolApprovalis the current place.) When the model asks for the tool, the SDK does not runexecute. It ends the stream with the tool part in the stateapproval-requestedand anapproval.id. - 3
Showing the request. Your component sees the
approval-requestedstate and draws a card: what will happen, and two buttons. The server can attach text to explain the request. IftoolApprovalreturns{ type: "user-approval", reason: "…" }, that text arrives aspart.approval.requestReason. Build that sentence from your own data (the order's real item and price), never from words the model wrote. - 4
Answering. The buttons call
addToolApprovalResponse({ id: part.approval.id, approved: true })orapproved: falsewith an optionalreason. The part moves toapproval-responded. Like client tools, this changes the message but sends nothing. You addsendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, anduseChatsends the conversation again. - 5
What happens next. On approval the server runs
execute, the part becomesoutput-available, and the model writes the final answer ("Done. ORD-1043 is now cancelled."). On denial the server does not runexecute. The part becomesoutput-denied, and the model is told{ type: "execution-denied", reason: "…" }, so it can reply "Okay, I have not cancelled it." Yourreasontext is what the model reads, so make it clear. - 6
Double clicks and conflicting clicks. In the SDK, the first answer to an approval wins. A second
addToolApprovalResponsefor the same id, even with the opposite answer, is ignored, andexecuteruns at most once. Still make the UI honest: remove the buttons as soon as the state leavesapproval-requested, so the person is not left wondering which click counted. - 7
Approvals that expire. People walk away. A pending approval that is still clickable an hour later can be pressed by mistake or by someone else at the same screen. Give every request a short life: after a time, answer "no" on the person's behalf with a reason such as "The request expired", and say so on screen. Also let
executere-check the world (is the order still processing?), since things change while a card is open.
Explain it without notes
Walk through what happens between the model asking to cancel an order and the order being cancelled.
What does the model learn when the person presses No, and what must your server do to keep the order safe?
Why should the text on the approval card come from your own data?
A person opens the card and leaves for an hour. What can go wrong, and what do you build?
Practice
Make the card disable both buttons the moment either is pressed, without relying on the SDK ignoring a second answer.
Add a text input to the deny path so the person can say why, and send it as the reason.
Trade-offs
- ↔
Every approval is a pause. Ask for the actions that cannot be undone or that cost money, and let harmless ones run. People who are asked too often click Yes without reading.
- ↔
A short expiry is safer and annoys slow readers. Pick the shortest time that still lets a person read the card carefully.
Done when you can
I can mark a tool as needing approval with
toolApproval.I can draw the
approval-requestedcard from server-built text.I can send the answer with
addToolApprovalResponseandsendAutomaticallyWhen.I can say what the model is told when the answer is no.
I can handle a double click and an expired request.