Topic 7.7
Build: Mitra Shows Cards, Not Paragraphs
In one line
Mitra now answers with a small typed reply: text, an order card, a product card, or an honest "I can't help". The model picks the kind and an id, the cards load their own facts from the shop's data, and every failure has its own state on screen.
Think of it like this
A shop counter that used to hand you a note and now hands you the actual item with a price tag. Before, you read a sentence about your parcel. Now you are shown the parcel's label: item, status, date. The shop assistant still talks, but the things you act on come as real objects.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Reply
- Mitra's typed answer: one of
text,order,productorcannot_help. - Kind
- The field that says which shape a reply has. It is the discriminator of the union.
- ReplyView
- The component that turns a reply into text or a card.
- OrderCard / ProductCard
- Cards that receive only an id, fetch their facts from the shop's data and show loading, error and result.
- Final object
- The validated object delivered by
onFinish, after the stream ended. - Skeleton
- A grey placeholder line shown while something loads.
- Unreadable answer
- A reply that did not parse or did not match the schema. Different from a request that failed.
Step by step
01Start from Mitra's text route
Phase 6 left Mitra with a streaming text route and safe Markdown rendering. We keep the chat frame (messages list, composer, Stop, status line) and change only what comes back.
02One schema for the reply
The union is the contract between model and screen. Declining is a normal reply (cannot_help) so the model never has to invent an order to fit the format.
export const Reply = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("text"), text: z.string() }),
z.object({ kind: z.literal("order"), orderId: z.string().regex(/^ORD-\d+$/), intro: z.string().nullable() }),
z.object({ kind: z.literal("product"), productId: z.string().regex(/^[a-z-]+$/), intro: z.string().nullable() }),
z.object({ kind: z.literal("cannot_help"), reason: z.string() }),
]);03The route asks for an object
streamText with output: Output.object({ schema: Reply }), returned with createTextStreamResponse and toTextStream. The instructions option lists the kinds. The input is checked first, as in Phase 0: a bad body is a 400, not a model call.
04The client reads a partial object
useObject({ api: "/api/chat", schema: Reply, onFinish }). While streaming, object is the partial reply: kind arrives first, then the text. onFinish gives the final validated object (or an error), and Mitra stores that as the turn. An error becomes "could not read its own answer"; onError becomes "could not be reached".
05Cards wait, text streams
ReplyView shows text with a cursor while final is false. For order and product it shows "Looking that up…" until final is true, and only then mounts the card. The card loads its facts and owns its own states.
06Run six questions
The last example is Mitra. A scripted visitor asks eight questions, each one after the previous answer is complete: an order, a product, a return policy answered as text, an off-topic question, an order that does not exist, a broken reply, a server that crashes, and a text answer that the visitor stops after its first words (Stop saves the turn as "Stopped.", because stop() calls neither onFinish nor onError). The console prints what each answer looked like.
Try it yourself
- 1
Teach Mitra a new question
Add a row to
SCRIPT:[/ORD-1043/, …]already exists. Add a question aboutORD-1044that returns{ kind: "order", orderId: "ORD-1044", intro: null }, and add"ORD-1044": { item: "Masala chai 250 g", status: "cancelled", days: null }toORDERS.The cancelled card shows no arrival date, because
daysisnull. The screen code did not change. - 2
Change the kind of an answer
Change the
gingerrow so the model replies{ kind: "product", productId: "tea-bags", intro: "Yes." }.The model's reply is valid, but the shop has no such product, so the card says "We could not find that product." The model chose the id; the data decided whether it is real.
- 3
Add a new kind
Add
z.object({ kind: z.literal("returns"), days: z.number() })toReply, a row inSCRIPTthat produces it, and a branch inReplyViewthat shows "Returns accepted for N days."Three places change: the schema (the contract), the model's choices, and the renderer. Forget the third and the new reply falls through to the last line of
ReplyView, which mounts aProductCardwith no id and shows "We could not find that product." (a wrong card, not an error). A renderer needs an explicit last branch for unknown kinds.
Code & diagrams
A half-written id can be a complete, valid-looking id for a different order. Look-ups wait for the end.
Preview
Console
Nothing logged yet.Expected console output
partial: {"kind":"order","orderId":"ORD-10"}
passes the id pattern while half-written: true
the id the card would look up: ORD-10 (the model was writing ORD-1042)
final: {"kind":"order","orderId":"ORD-1042"} successful-parseThe model is a scripted stand-in. Read the eight results: a card with facts from the shop, a product card, plain text, an honest refusal, a card that says the order was not found, a reply Mitra could not read, a server that could not be reached, and a stopped answer. After it finishes, type your own questions.
Preview
Console
Nothing logged yet.Expected console output
Where is ORD-1042? => Here is your order. ORD-1042 · Masala chai 250 g On the way, arrives in 2 days
Do you sell ginger biscuits? => Yes, we do. Ginger biscuits · ₹120 In stock
Can I return opened tea? => You can return any unopened item within 7 days of delivery.
What is the weather today? => I can only help with orders, products and returns.
Where is ORD-9999? => Let me check. We could not find order ORD-9999.
broken please => | ERROR: Mitra could not read its own answer. Please try again.
(server) Error: provider down
Please crash => | ERROR: Mitra could not be reached. Please try again.
Can I return opened tea? => You Stopped.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
Mount the card before the object is final
Delete the line if (!final) return <p className="assistant skeleton">Looking that up…</p>; in ReplyView. To see what the card does, add console.log("lookup:", url); as the first line inside the if (!started.current) { block of useFacts, and keep only the first ask("Where is ORD-1042?").
Myth vs fact
Myth
Now that the model returns objects, the paragraph answers are gone.
Fact
Text stays a kind of reply. A policy question is best answered in words; a status is best shown as a card.
Myth
The card should show whatever the model returned.
Fact
The model returns an id. The card shows facts loaded from the shop's own data, validated again.
Myth
A refusal is a failure.
Fact
cannot_help is a normal reply. A failure is a request that did not complete or a reply that could not be read.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Count outcomes: replies by kind, unreadable replies,
cannot_helpreplies, cards whose id was not found. A rising not-found rate means the model invents ids, and a rising unreadable rate means the schema or the model changed. - ▸
The
introfield lets the model add a friendly sentence without being able to change the facts. Keep free text short and put limits on it in the schema. - ▸
Saved turns hold the final reply object, not the rendered card, so a saved chat shows today's order status when it is reopened and not an old one.
Remember this
- 1
The problem this solves: Mitra (Phase 0 to 6) answered every question with text. For "Where is ORD-1042?" a paragraph is the wrong tool: the user wants item, status and date at a glance, and a developer wants the status as a value. Topics 7.1 to 7.6 gave the parts. This topic puts them together in Mitra.
- 2
Where Mitra starts. Mitra's route
/api/chatstreams text withstreamTextand the client shows the stream withuseChat(Phase 5), formatted safely (Phase 6). In this topic we replace the answer's shape. The route asks for an object withOutput.object({ schema: Reply }).Replyis a discriminated union with four kinds:text,order,productandcannot_help. The client reads it withuseObjectand renders it withReplyView. - 3
What the model decides, and what it does not. The model chooses the kind, writes the short sentence, and gives an id (
ORD-1042,ginger-biscuits). It never supplies the item, status, price or stock.OrderCardandProductCardfetch those from the shop's own routes (/api/order,/api/product) and validate them with their own schemas. So a card is true even when the model is wrong, and an id that does not exist shows "We could not find order ORD-9999.", not an invented card. - 4
Streaming rules. Text streams with a cursor, because partial text is readable. Cards do not stream:
ORD-10is a well-formed id for another order, so the card waits for the final object (the first live example below proves it, and the break-it shows what happens otherwise). While waiting, Mitra shows "Looking that up…". Thecannot_helpreason streams like text. - 5
Every state on screen. Idle: a hint. Waiting: "Mitra is thinking…". Streaming text: text with a cursor. A card loading its facts: a skeleton line. A card whose facts are missing: its own error line. A reply the SDK could not turn into an object: "Mitra could not read its own answer. Please try again." A failed request: "Mitra could not be reached." Stop works as before, and a stopped reply is saved as "Stopped." (with any text that had arrived), never as a finished answer. The example shows this at the end.
- 6
What Mitra can do now that it could not before: show an order or product as a card from validated data, decline honestly, and tell an unreadable answer from an unreachable service. What is still missing: tools the model can call to look things up and act (Phase 8: today the model only names an id and the page does the lookup), a cancel-order action that asks the user first (Phase 8), hostile content and prompt injection (Phase 9), budgets and cost (Phase 13), and tests (Topic 13.6).
Explain it without notes
Walk through what happens from the user's question to an order card on screen in Mitra v7.
Why does the card load its own facts instead of the model sending them?
Why do cards wait for the final object while text streams?
Which failures does Mitra v7 tell apart, and what does the user see for each?
Practice
Add a ProductCard button "Ask about returns" that sends the question "What is the return policy?" through the same path. Which function should it call so the busy rule still applies?
Make onFinish log kind and whether the reply was readable, without logging the question text. Why leave the question out?
Trade-offs
- ↔
Typed replies make the screen clearer and safer, and add a schema to maintain in step with the prompt and the renderer.
- ↔
Waiting for the final object before showing a card is safe, but it hides progress for a moment. The skeleton line keeps the user informed.
Done when you can
I can describe Mitra's
Replyunion and what each kind renders.I can explain what the model decides and what comes from the shop's data.
I can say why text streams but cards wait for the final object.
I can list the failure states Mitra shows and what causes each.
I can name what Mitra still lacks and which phase adds it.