Topic 8.9
Build: Mitra Takes Actions
In one line
Mitra gets two tools. It looks up an order and shows it as a card, and it cancels an order only after the customer reads exactly what will happen and presses Yes. Failures, refusals and double clicks are handled, and the server checks everything again.
Think of it like this
The market stall from Phase 0 now has a till and a back room. A helper can fetch an item from the back room when you ask (a lookup), but takes anything off the books only after the owner has read the slip and signed it. Mitra learns the same two kinds of work: fetching is free, and changing needs a signature.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- lookupOrder
- Mitra's read-only tool: finds an order by id.
- cancelOrder
- Mitra's changing tool: cancels an order that is still processing, only after approval.
- OrderCard
- The component that draws a tool part in each of its states.
- Approval
- The component that shows the request text and the Yes and No buttons.
- not-applicable
- A
toolApprovalanswer meaning "no approval needed here". Mitra uses it when the cancel could not happen anyway. - Waiting for your answer
- Mitra's status while an approval card is open.
Step by step
01Start from Phase 7's Mitra
You have Mitra with a route, useChat, a message list and a composer (#messages, #prompt, #send, #stop, #error, #status), and cards from structured output. This topic adds two tools to the route and two component branches to Message. Everything else stays.
02Step 1: the tools
Add lookupOrder (read-only) and cancelOrder (changes data) to the tools object, and set stopWhen: stepCountIs(4). Both return { error } objects for expected problems and never throw for them.
tools: {
lookupOrder: tool({
description: "Look up one order by its id",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => {
const o = ORDERS[orderId];
return o ? { orderId, item: o.item, status: o.status } : { error: "I could not find " + orderId + "." };
},
}),
cancelOrder: tool({
description: "Cancel an order that is still being prepared",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => {
const o = ORDERS[orderId];
if (!o) return { error: "I could not find " + orderId + "." };
if (o.status === "cancelled") return { error: orderId + " is already cancelled." };
if (o.status !== "processing") return { error: orderId + " is " + o.status + ", so it can no longer be cancelled." };
o.status = "cancelled";
return { orderId, item: o.item, status: "cancelled" };
},
}),
},03Step 2: ask the person
Add toolApproval for cancelOrder. The function returns "not-applicable" when the cancel cannot happen, so the model gets the tool's own explanation and the person is not asked about nothing. Otherwise it returns a user-approval request with text built from the order.
toolApproval: {
cancelOrder: ({ orderId }) => {
const o = ORDERS[orderId];
if (!o || o.status !== "processing") return "not-applicable";
return { type: "user-approval", reason: "Cancel " + orderId + ": " + o.item + ", Rs " + o.price + ". This cannot be undone." };
},
},04Step 3: draw the parts and answer the card
In Message, send approval-requested parts to Approval and every other tool part to OrderCard. Pass useChat's addToolApprovalResponse down as respond. Add sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses.
Approval keeps an answered flag so both buttons lock on the first press. Use part.toolCallId as the key.
05Step 4: lock the composer while a card is open
Compute waitingForYou from the parts. While it is true the status reads "Waiting for your answer" and the send guard in submit still refuses a second question because the chat is not idle. Try asking something else while a card is open in the example: the answer to the card comes first.
06Step 5: run it
The scripted visitor in the last example asks about ORD-1042, asks to cancel ORD-1043 (the card appears; it presses Yes), then tries to cancel the shipped ORD-1042 and the already-cancelled ORD-1044, and finally says "Hello". Each step waits for the page, not for a timer. The last line shows the order table at the end: only ORD-1043 changed.
Try it yourself
- 1
Press No
Run the example, wait for it to finish, then type
Cancel ORD-1043again. It is already cancelled, so there is no card. To see the No path, change ORD-1043's status back toprocessingin the table, press Run, and click No, keep it by hand when the card appears.The status goes back to Ready, the card shows
Cancellation not approved., and Mitra says it did not cancel. - 2
Ask for something unknown
Type
Cancel ORD-9999.There is no card (the order does not exist, so the approval function answers
not-applicable) and Mitra says "I could not find ORD-9999."
Code & diagrams
The model is a scripted stand-in and the order table lives in the page for the demo. In a real app the table is your database and the route is app/api/chat/route.ts. The visitor waits for the page each time, so this prints the same on any machine.
Preview
Console
Nothing logged yet.Expected console output
you: Where is ORD-1042? | Mitra: ORD-1042 · Masala chai 250 g · shipped / ORD-1042 is shipped.
approval card: Cancel ORD-1043: Ginger biscuits, Rs 180. This cannot be undone. | status: Waiting for your answer
(server) cancelled ORD-1043
you: Cancel ORD-1043 | Mitra: ORD-1043 · Ginger biscuits · cancelled / ORD-1043 is cancelled.
you: Cancel ORD-1042 | Mitra: ORD-1042 is shipped, so it can no longer be cancelled.
you: Cancel ORD-1044 | Mitra: ORD-1044 is already cancelled.
you: Hello | Mitra: I can look up an order or cancel one that has not shipped. Which order number?
order table now: ORD-1042 shipped, ORD-1043 cancelled, ORD-1044 cancelledBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Delete toolApproval
In the example delete the whole toolApproval: { … }, block from the route and press Run.
Myth vs fact
Myth
Mitra decides when to cancel.
Fact
Mitra only asks. The server decides whether a request is allowed, and the customer decides whether it happens.
Myth
Approval is only needed when the model is unsure.
Fact
Approval depends on what the action can do, not on how sure the model sounds. A confident wrong cancel is just as permanent.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep the order table and its rules in one server module that both tools and
toolApprovaluse. If they each hold their own idea of "cancellable", the card and the action will disagree. - ▸
The approval function runs before
execute, and the world can change in between.executerepeats the status check, so a cancel approved a minute late is still refused if the order shipped meanwhile. - ▸
Log each approval request, each answer and each execution with the user and order id. You need it the first time someone says they never agreed.
- ▸
Keep Phase 3's Stop button: it also stops a pending request, and a stopped chat must not leave an approval card that can still be pressed.
Remember this
- 1
The problem this solves: Until Phase 7, Mitra could talk and show cards but could not touch an order. Customers do not want a chat about cancelling, they want the order cancelled, safely. This topic assembles Topics 8.1 to 8.8 into one small program: tools on the server, parts drawn in the browser, an approval card and checks on every side.
- 2
What Mitra can do now that it could not before. Look up an order by id and show a card (
lookupOrder). Cancel an order that is still processing (cancelOrder), but only after a person approves a card built from the server's own data. Refuse politely when the order does not exist, has shipped, or is already cancelled. Not run anything if the person says No. Lock the input while it waits for the answer, and keep the buttons from being used twice. - 3
What is still missing, and where it comes from. Mitra still uses a scripted stand-in model, not a real one (a real provider is a one-line change on the route, Topic 8.2). It does not yet know who is signed in, so the owner check from Topic 8.8 is not in this version (sign-in is outside this course, and per-user limits are Topic 13.2). Hostile text is not yet filtered (Phase 9). Cost and budgets across a whole conversation are Topics 13.1 and 13.2. Tests and evals for tool use are Topics 13.6 and 13.7. Accessibility of streaming UI and international text are Topics 10.6 and 10.7.
- 4
The server part. One route with two tools.
lookupOrderwaits a little and returns the order or an{ error }.cancelOrderchecks the order and changes its status.toolApprovalforcancelOrderreturns"not-applicable"when the cancel could not happen anyway (so the person is not asked about something impossible), and otherwise auser-approvalrequest whose reason is built from the order data: item, price, and "This cannot be undone.". - 5
The browser part.
useChatwithsendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses.Messagedraws each part: text as a paragraph, tool parts throughOrderCard, approval requests throughApproval.OrderCarddraws every state (Topic 8.3) and hides{ error }results, since the assistant explains them in words.Approvalhas a localansweredflag that disables both buttons on the first press. - 6
Mitra's states now include a new one, "Waiting for your answer", shown while an approval is pending. In that state the composer is locked (a new question while a card is open would be confusing), and
#statussays why.#stopappears while a request runs, as in Phase 3. - 7
Every scenario this version handles. *Normal:* a lookup, a card, a sentence. *Success with approval:* the cancel is asked, approved, run once. *Order missing:* "I could not find…" with no card. *Wrong state:* shipped orders cannot be cancelled, and no approval is asked. *Already cancelled:* a clear sentence. *Denied:* nothing runs, the model is told. *Double click:* the second press does nothing. *Unknown request:* Mitra asks which order. The last example runs five of these in one session.
Explain it without notes
Describe what happens from the sentence "Cancel ORD-1043" to the order being cancelled, naming each piece (tool, approval, card, resend).
Why does Mitra's approval function return not-applicable for a shipped order?
How does Mitra avoid cancelling twice when the person double clicks Yes?
What does Mitra still lack at the end of this phase, and which phase adds each missing piece?
Practice
Add a third tool returnPolicy that returns { days: 7 } and let Mitra answer "What is the return window?". Which parts of the browser code change?
Make the composer show "Answer the card above first" as its placeholder while an approval is pending.
Trade-offs
- ↔
Asking before every cancel is slower than a button that cancels at once. It is the right price for an action nobody can take back, and a wrong choice for a harmless one.
- ↔
Hiding
{ error }results in cards keeps the screen clean and makes the model's sentence carry the message. If the model words it badly, the user gets the wrong words, so test those phrases.
Done when you can
I can add a read tool and a changing tool to the Mitra route.
I can draw each tool state and an approval card from server-built text.
I can lock the composer while a card is open.
I can show the checks that hold even when the person approves.
I can say what Mitra still needs and which phase adds it.