πͺ Configure the agent
The chat panel works, but the bot will happily answer anything. Ask it for the weather in Tokyo and it will tell you. Ask it about the product on the page and it has no idea which product you mean. The model also has a "reasoning" mode you can turn on so it thinks before it answers β and we are not using it yet.
In this exercise you will configure the agent across three dimensions:
- A role. A global system prompt tells the bot it is an EpicStore shopping assistant and it should only talk about shopping.
- The current product. When a customer asks about "this jacket", the server fetches the real product from the database using its id (sent over from the browser) and includes the product details in the system prompt.
- Reasoning. You turn on the model's reasoning mode with the
reasoningoption. The model thinks for a moment before it answers, and the chat panel shows that thinking in a collapsible block.
When you are done, the bot will politely refuse off-topic questions, answer product questions using the real product details, and show its reasoning above each answer.
π¨ What you will do
There are three small files to update. All the imports are already in place.
1. app/prompts/system.ts
A new file is set up with two empty placeholders and two Marty π° comment blocks that hold the prompt text for you. You will:
- π¨ Fill in
GLOBAL_SYSTEM_PROMPTwith the text Marty π° gives you in the file. - π¨ Fill in
buildProductPrompt(product)with the template Marty π° gives you. It uses theProducttype that is already inferred from the existinggetProductByIdhelper, so you do not need to write any types.
2. app/components/product-chat.tsx
The chat panel lives in the landing layout (
app/routes/_landing/_layout.tsx) so it shows up on every page β home, products listing, product detail, cart, and so on. Inside the panel, a small helper called useCurrentProduct() reads the product detail route's loader through useRouteLoaderData. It returns the product when the customer is on a /products/:id page, and undefined everywhere else. The product variable inside ProductChat is already wired up β you just need to forward it to the server.- π¨ Add
forwardedPropsto theuseChatoptions. Becauseproductcan beundefined(the panel is on a page without a specific product), the value needs to be conditional:When the customer is on a product detail page, the server gets the id. On any other page, no id is sent and the server falls back to the global prompt only.forwardedProps: product ? { productId: product.productId } : {}
3. app/routes/api.chat.tsx
The server route currently only reads
messages from chatParamsFromRequest(request). You will also pull in forwardedProps, validate the new productId field, use the prompt helpers, and turn on reasoning.- π¨ Destructure
forwardedPropsalongsidemessagesfromawait chatParamsFromRequest(request). - π¨ Validate the
forwardedPropswithPageForwardedProps.safeParse(...)(already imported from#app/schemas/chat.js). Return a400 Responseif validation fails.productIdis optional here β the chat panel also shows up on the products listing page where there is no specific product. - π¨ If
props.data.productIdis set, callgetProductById(props.data.productId)(already imported) to load the product from the database. Otherwise the product isnull. - π¨ Pass
systemPrompts: [GLOBAL_SYSTEM_PROMPT, buildProductPrompt(product)]into thechat()call. If the product isnull(no id, or the id is unknown), pass just[GLOBAL_SYSTEM_PROMPT]instead. - π¨ Add
reasoning: 'medium'to the samechat()call. This turns on the model's reasoning mode. - π£ Delete the
voidlines at the top of the file once you are using the imports.
4. Back in app/components/product-chat.tsx
Once reasoning is on, the model sends a new kind of part called
thinking along with the text parts. The parts loop already renders text; it just ignores everything else for now.- π¨ Inside the
m.parts.map(...)loop, uncomment Marty π°'sthinkingbranch. It rendersthinkingparts inside a collapsible<details>so the user can expand to see the model's reasoning, right above each answer.
π¦ About systemPrompts
chat() takes an optional systemPrompts: string[] setting. Every entry is added as a system message at the start of the conversation, before the user's first message. You can pass one big prompt or several small ones β the model concatenates them. Keeping the global rules separate from the per-product details makes the two layers easier to change one at a time.π¦ About forwardedProps
useChat lets you attach any extra JSON to every chat request through forwardedProps. On the server, those props come back as Record<string, unknown> on the result of chatParamsFromRequest(request) β you validate just that portion with a small Zod schema (PageForwardedProps here). We use it to pass the productId from the page, but you can pass anything you want (user id, current page, feature flags, etc.).π¦ About reasoning
reasoning is one option on chat() that works the same for every provider. TanStack AI turns it into the right provider field for you (for OpenRouter, that is reasoning.effort). We are using 'medium' here. The other levels are 'off', 'minimal', 'low', 'high', 'xhigh', and 'max', but a model can support only some of them. TypeScript shows which levels the current model accepts. A higher level means the model thinks for longer before it answers. You get better answers, but they are slower and use more tokens.Provider-only settings, like
temperature, topP, or seed, go in modelOptions. The fields there depend on the adapter and the model.π Docs
β How to know it is working
Open any product detail page and try the chat:
- Ask "What's the weather in Tokyo?" β the bot says it can only help with shopping.
- Ask "What is this product?" β the bot answers with the product's real name.
- Ask "How much is it?" β the bot quotes the real price.
- Open a different product detail page and ask the same question β the answer updates to the new product.
- Above each answer you see a
π Thinkingβ¦row. Click it to expand and read what the model thought about before it replied.
Then go to the products listing page (
/products). The chat bubble is there too, but the header just says "Shopping Assistant" because there is no specific product. The bot still answers general shopping questions and still refuses off-topic ones β it just has no per-product context to lean on.The bot's answers also come back as nicely formatted text. Bold, lists, and other markdown render through TanStack AI's
TextPart component instead of showing as raw **asterisks** and - dashes.π¨ If you get stuck
- The bot still answers off-topic questions β check that
systemPromptsis set on thechat()call, and thatGLOBAL_SYSTEM_PROMPThas the text from Marty π° (not an empty string). - The bot says it does not know the product β check that the chat panel passes
forwardedProps: { productId }, and that the server reads it after thePageForwardedProps.safeParse(forwardedProps)step (props.data.productId). - The server returns 400 β
chatParamsFromRequestrejected the AG-UI body, orPageForwardedProps.safeParserejectedforwardedProps. Open devtools β Network and look at the request body to see which field is off. getProductByIdreturnsnullβ theproductIdis wrong or the product was deleted. The action should still work; it just falls back to the global prompt only.- You do not see a thinking row above answers β check that
reasoning: 'medium'is passed tochat(), and that the parts loop inproduct-chat.tsxhandlespart.type === 'thinking'. Not every model supports reasoning β make sure the adapter is stillopenRouterText('nvidia/nemotron-3-super-120b-a12b:free').