Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~32 min· topic 7 of 7

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, product or cannot_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.

lib/schemas.tswhole filets
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.

Cards wait, text streamsdiagram
Rendering diagram…

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. 1

    Teach Mitra a new question

    Add a row to SCRIPT: [/ORD-1043/, …] already exists. Add a question about ORD-1044 that returns { kind: "order", orderId: "ORD-1044", intro: null }, and add "ORD-1044": { item: "Masala chai 250 g", status: "cancelled", days: null } to ORDERS.

    The cancelled card shows no arrival date, because days is null. The screen code did not change.

  2. 2

    Change the kind of an answer

    Change the ginger row 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. 3

    Add a new kind

    Add z.object({ kind: z.literal("returns"), days: z.number() }) to Reply, a row in SCRIPT that produces it, and a branch in ReplyView that 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 a ProductCard with 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

Why cards wait for the final object

A half-written id can be a complete, valid-looking id for a different order. Look-ups wait for the end.

Preview

Press Run to see the page.

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-parse
Mitra v7: cards, text, honest refusals

The 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

Press Run to see the page.

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?").

terminal
$ Console
── what you'll see ──
lookup: /api/order?id=undefined
lookup: /api/order?id=ORD-1042
Where is ORD-1042? => Here is your order. ORD-1042 · Masala chai 250 g On the way, arrives in 2 days

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_help replies, 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 intro field 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. 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. 2

    Where Mitra starts. Mitra's route /api/chat streams text with streamText and the client shows the stream with useChat (Phase 5), formatted safely (Phase 6). In this topic we replace the answer's shape. The route asks for an object with Output.object({ schema: Reply }). Reply is a discriminated union with four kinds: text, order, product and cannot_help. The client reads it with useObject and renders it with ReplyView.

  3. 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. OrderCard and ProductCard fetch 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. 4

    Streaming rules. Text streams with a cursor, because partial text is readable. Cards do not stream: ORD-10 is 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…". The cannot_help reason streams like text.

  5. 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. 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

01

Walk through what happens from the user's question to an order card on screen in Mitra v7.

02

Why does the card load its own facts instead of the model sending them?

03

Why do cards wait for the final object while text streams?

04

Which failures does Mitra v7 tell apart, and what does the user see for each?

Practice

01

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?

02

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 Reply union 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.