💪 Ask before acting
Your bot can search the catalog and move customers around the site. Both of those are safe — read-only or "just changes the URL". But the moment a tool does something on the customer's behalf — adding to cart, applying a discount, sending an email — you do not want the model just doing it. You want the customer to see what is about to happen and click Yes or No first.
In this exercise you'll build an
addToCart client tool that needs approval. The model can pick it, but TanStack AI will pause and wait for the customer to confirm before the tool's code actually runs.🐨 What you will do
Same three-piece shape as the last exercise — define the tool, register it on the server, build the client side — but with one new field on the definition and a new piece of UI for the approval prompt.
1. app/tools/cart.ts — define the tool
This new file already has the two schemas built for you (
AddToCartInput and AddToCartOutput).- 🐨 At the bottom of the file there's a 🐨 block. Use it to build and export
addToCartDefwithtoolDefinition({...}). Same pattern asnavigateToDeffrom last time — but this one has one new field you've never used before:needsApproval: true. Setting that on the definition is what makes TanStack AI pause and wait for the customer to click Yes or No before running the tool. Marty 💰 hands you thenameand thedescriptionstring; plug inAddToCartInputandAddToCartOutputas the schemas.
2. app/routes/api.chat.tsx — tell the model the tool exists
- 🐨 Add
addToCartDefto thetools: [...]array on thechat({...})call. Same as last time — even though the tool runs in the browser, the server has to advertise it so the model knows it exists. - 💣 Delete the
void addToCartDefline above the action.
3. app/components/product-chat.tsx — provide the implementation and the approval UI
This is where the new concept lives. You'll do three things here.
- 🐨 Build the
addToCartclient tool withaddToCartDef.client(execute). The 🐨 block above theuseChat({...})call walks you through it — same shape as thenavigateTotool just above, but the callback closes overcart(fromuseCart()) and calls the pre-builtaddProductToCart(...)helper to do the real work. - 🐨 Add
addToCartto thetools: [...]array onuseChat({...}). - 🐨 Find the approval prompt UI further down in the
messages.map(...)block (look for the🛒 Add to cart?card). The card is already built; you just need to wire the Yes and No buttons. On click, calladdToolApprovalResponse({ id: approvalId, approved: true })for Yes andapproved: falsefor No.addToolApprovalResponseis already destructured fromuseChat;approvalIdis already in scope inside the card.
🦉 How approvals work in TanStack AI
The flow looks like this:
- Model decides to call
addToCartwith some args. - Because the tool's definition has
needsApproval: true, TanStack AI does not run your.client(...)callback. Instead it emits atool-callpart withstate: 'approval-requested'and anapprovalobject containing anid. - Your UI sees that part, renders Yes/No buttons, and the customer clicks.
- The button's click handler calls
addToolApprovalResponse({ id, approved: true | false }). - If approved, then TanStack AI runs your
.client(...)callback and the result goes back to the model. - If denied, the callback never runs. The model is told the request was rejected and can respond accordingly ("ok, I won't add it").
This separation — definition says "needs approval", UI surfaces the request, response unblocks (or doesn't) — is the entire pattern. Once you've wired it once you can apply it to any "this tool does something" tool you build later.
🦉 Why the approval prompt shows fields like productName
The model is the one filling in
productName, size, color, and quantity — not your code. The approval prompt shows those back to the customer before anything runs, so they can see exactly what the model was about to do. If the model picked the wrong size, the customer rejects and the bot tries again.This is why
productName is in the tool's inputSchema even though the cart already has a name field internally — the schema is the contract for what the model has to provide, and we want it to provide a human-readable name so the approval card is readable.📜 Docs
- 📜 TanStack AI — tool approvals
- 📜
addToolApprovalResponse— destructure it fromuseChat({...})
✅ How to know it is working
Open the chat panel and try these:
- On a product page, "Add this to my cart, size 10, in red" → the bot picks the
addToCarttool, the chat shows a Yes/No card with the product name and the size/color/qty, and nothing actually happens to your cart yet. Click Yes → the card is replaced with✅ Added to your cartand the cart count goes up. Click No → you see❌ Cancelled — not addedand the cart is unchanged. - "Add the Air Zoom Pegasus 38 to my cart" while on the home page → same flow. The bot can also reach for
searchProductsfirst if it needs to look up the id. - Navigate to
/cartafter a successful add → the item is in the cart, with the right size, color, and quantity.
🚨 If you get stuck
- TypeScript complains that
addToCartDefis not exported → you haven't built it yet inapp/tools/cart.ts. The 🐨 block there is the spec. - The bot adds things to the cart with no prompt → you forgot
needsApproval: trueon the tool definition. Without that field, the tool runs the moment the model picks it. - The Yes/No buttons render but clicking does nothing → the
onClickhandlers in the approval card still have the// 🐨placeholders. Replace them with calls toaddToolApprovalResponse({ id: approvalId, approved: true | false }). - The bot says it added something but the cart is empty → either
addToCartisn't in theuseChat({ tools: [...] })array, or the.client(...)callback didn't actually calladdProductToCart(...). Both are needed. - The model picks weird sizes/colors → that's actually fine for this exercise — the approval prompt is the safety net. The customer rejects, and the model tries again. If you want better defaults, you could extend the system prompt to nudge the model toward common sizes.