Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~52 min· topic 5 of 5

Topic 15.5

Capstone: Mitra, Complete

In one line

Mitra, the shop assistant, assembled from the parts of the whole course: streaming with the AI SDK, stop, history, safe Markdown, a validated order card, a lookup tool and a cancel tool behind approval, a daily limit with a 429 path, and an accessible live region. Three live examples build it up, and the last is the whole app with a scripted visitor.

Think of it like this

A dress rehearsal. The actors know their lines, the lights have been tested, the set has been built in pieces. The rehearsal puts everything on the stage at once, and then you learn whether the door that opened fine in the workshop opens on the stage. The capstone is the dress rehearsal of this course: every part Mitra got in Phases 0 to 14 runs together, including when things go wrong.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Capstone
The final project that uses everything you learned together.
Scripted visitor
A piece of code that uses the page like a person: types, clicks, and waits for the screen to change.
Happy path
The case where everything works.
Failure path
A case where something goes wrong on purpose: a limit, a stop, a refusal.
Live region
A page area that screen readers announce when its text changes. Here it changes only at the edges of an answer.
Resubmit
The request the page sends again after an approval, carrying the same conversation plus the answer.
Stand-in
A fake part used in the example (the model, the counters) that you replace in a real app.

Step by step

01The plan: pieces and where they live

Draw the whole thing before building. The server holds the rules: who the user is, how many questions they may ask, which tools exist, which tools need approval, and what the approval text says. The browser holds the screen: the message list, the card, the approval buttons, the meter and the live region. The model sits behind the server and is replaceable.

The plan: pieces and where they livediagram
Rendering diagram…

02Step 1 and 2: Markdown and the order card (example 1)

The first example has no network. It renders strings and prints the HTML they produce. A finished bold shows <strong>; a half-typed **7 da is healed into bold until the end, so the answer does not flicker; a list becomes a list; a link to our own site gets rel="noopener noreferrer"; a javascript: link loses its link and keeps its label; and raw tags such as <img> and <b> appear as visible characters.

Then the card. The zod schema says an order card has an id, an item and one of three statuses. A valid object is drawn. An unknown status, a missing field or a plain string draws "I could not show that order", because the model or an old saved message can send anything (Topic 7.2). The schema is also the contract with the server, so the same check can run on both sides.

03Step 3: the chat loop (example 2)

The second example is the chat with useChat and a streamText route: send, stream, Stop, history, a daily limit and the live region. Read the lines the visitor prints. The second question is stopped partway, and the third request carries 5 messages: the stopped answer is part of the history, so the model knows what was said. The fourth question gets 429, the box locks, and the page says when to come back.

Look at the last line: the live region said only eight things in all, and none of them is the answer text. It says "Mitra is answering." when an answer starts, "Mitra has finished answering." or "Answer stopped." at the end, and the limit message. If it read every token, a screen reader user would hear a blur (Topic 10.6).

04Step 4: tools behind approval, on a real provider

The route below is the shape of a real route. The model is a placeholder: use your provider's current model id. The rules are the same as in the live examples: the user comes from the session, a new question is counted once, the cancel tool re-checks everything inside execute, and the approval text is built from your own data. The route also sets a secret that signs each approval request, so a hand-made "approved" from the browser is refused (Topic 15.3). In AI SDK 7 that option is still named experimental_toolApprovalSecret, so check its name when you upgrade.

app/api/chat/route.ts (not run here)whole filets
import { convertToModelMessages, streamText, stepCountIs, tool } from "ai";
import { z } from "zod";
import { model } from "@/lib/model";                 // provider("provider/model-name"): your provider's current model
import { currentUserId, orders, quota } from "@/lib/server";

export async function POST(req: Request) {
  const user = await currentUserId(req);              // from the session, never from the body
  const { messages } = await req.json();

  if (messages.at(-1)?.role === "user") {            // a new question counts once; the resend after an approval does not
    const ok = await quota.tryUse(user);              // check and count in ONE atomic step
    if (!ok.allowed) return Response.json({ error: "daily_limit" }, { status: 429, headers: { "retry-after": String(ok.retryAfter) } });
  }

  const result = streamText({
    model,
    instructions: "You are Mitra, the help assistant of Chai & Co. Answer briefly. Use tools for orders.",
    messages: await convertToModelMessages(messages),
    stopWhen: stepCountIs(4),
    abortSignal: req.signal,                          // the browser pressed Stop: stop paying for tokens
    tools: {
      lookupOrder: tool({
        description: "Look up one order of the signed-in customer",
        inputSchema: z.object({ orderId: z.string() }),
        execute: async ({ orderId }) => orders.findForUser(user, orderId) ?? { 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 }) => orders.cancelForUser(user, orderId), // checks owner and status again
      }),
    },
    experimental_toolApprovalSecret: process.env.APPROVAL_SECRET!, // signs approval requests: a forged "approved" is refused
    toolApproval: {
      cancelOrder: async ({ orderId }) => {
        const o = await orders.findForUser(user, 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." };
      },
    },
  });
  return result.toUIMessageStreamResponse({ onError: () => "Mitra could not answer." });
}

05Step 5: the whole app (example 3)

The third example is Mitra in one file: server route at the top, then the browser pieces (Markdown, OrderCard, Approval, Message, MessageList, Composer, App), then the visitor. The approval is the part that touches the most code: the model asks for cancelOrder, the stream ends with the tool part in approval-requested, the page shows the card with the server's text, the person presses a button, useChat resends the conversation, and the server either runs the tool or tells the model it was denied.

Read the output as a story. Line 1 is the formatted answer and its link. Line 2 is the lookup card. Line 3 is the stopped answer. Line 4 is the approval card; the next line shows that after No the order is still processing. Line 5 shows Yes cancelled it, with 5 of 5 questions counted: the two approvals each resent the conversation, but only the five typed questions were counted. Line 6 is the limit. Lines 4 and 5 also print where the keyboard is: it moves to the approval card when the card appears and back to the box when the person answers, so a keyboard user is never left on a button that has gone.

Step 5: the whole app (example 3)diagram
Rendering diagram…

06Verify it like a reviewer

Use this as the test plan for your own version. Normal: a formatted answer, a card. Edge: an unfinished bold, a stopped answer kept in the history, a second approval for the same order. Failure: a denied approval leaves the order unchanged; a hostile link is not a link; a bad card shape is not drawn; the limit locks the box. Limits: the counter counts questions, not resends. Accessibility: the live region changes at the edges, the buttons are reachable by keyboard, and focus moves to the approval card and back to the box. Every one of these is a line in the visitor's output.

Try it yourself

  1. 1

    Press No yourself

    In the third example change LIMIT to 50 and delete the visitor (everything below the comment line that contains A scripted visitor; keep the line before it, which ends the page code). Press Run, type Cancel ORD-1043 and press No, keep it. Mitra says it did not cancel. Type the same again and press Yes, cancel it: now the order is cancelled.

  2. 2

    Add a status

    In the first example add "delayed" to the status list of the schema, and add a card test with that status. It is drawn. Remove the new word again and run: the same card is now refused. The schema is the one place that decides which cards exist.

  3. 3

    Change the limit

    In the second example change LIMIT to 2. The visitor's third question is refused: the page locks the box and shows the limit message, but the visitor is waiting for a third answer that never comes, so its lines 3 and 4 and the live-region line do not print. Look at the page instead. The page learns that the limit was reached, and how long to wait, from the 429 and its retry-after header. In this one-file demo the message also reads the same LIMIT constant; in a real app the number would come from the server as well (a header or the error body).

Code & diagrams

Steps 1 and 2: safe Markdown and a validated order card

Nothing here touches a network: these two pieces are pure. The last line reads the first problem the schema found, which is how you log a bad card without logging the whole message.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

finished : <p>Returns take <strong>7 days</strong>.</p>
streaming: <p>Returns take <strong>7 da</strong></p>
list     : <ul><li>keep the box</li><li>send the number</li></ul>
good link: <p>See the <a href="https://chaiandco.example/returns" target="_blank" rel="noopener noreferrer">returns page</a>.</p>
bad link : <p><span>Click here</span> now</p>
raw html : <p>&lt;img src=x&gt; and &lt;b&gt;bold?&lt;/b&gt;</p>
valid card    : ORD-1042 · Masala chai 250 g · shipped
unknown status: I could not show that order.
missing field : I could not show that order.
not an object : I could not show that order.
why the second one failed: status
Step 3: the chat loop with Stop, history, a daily limit and a live region

The model is a scripted stand-in (in a real app: your provider's model). The limit is 3 questions a day, a made-up number. Type your own questions after the visitor is done: the fourth is refused whatever you type.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) question 1 of 3 carries 1 messages
1 answered: Returns are accepted within 7 days.
(server) question 2 of 3 carries 3 messages
2 stopped: partial kept: true | complete: false
(server) question 3 of 3 carries 5 messages
3 answered again, the history included the stopped answer
4 limit: You have used all 3 questions for today. Try again in 1 hour. | box disabled: true
live region said: Mitra is answering. / Mitra has finished answering. / Mitra is answering. / Answer stopped. / Mitra is answering. / Mitra has finished answering. / Mitra is answering. / Daily limit reached. Try again in 1 hour.
Step 4 and 5: Mitra complete, with a scripted visitor

One file: route, pieces, visitor. The model is a scripted stand-in and the order table lives in the page for the demo. The visitor waits for the page each time, so this prints the same on any machine. After it finishes, the box is locked: press Run to start again.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1 returns: Returns are accepted within 7 days of delivery: / keep the box unopened / Details are on the returns page. | live: Mitra has finished answering.
  links in the answer: returns page -> noopener noreferrer
2 lookup: ORD-1042 · Masala chai 250 g · shipped / ORD-1042 is shipped.
3 stopped: partial kept: true | complete: false | live: Answer stopped.
4 approval card: Cancel ORD-1043: Ginger biscuits, Rs 180. This cannot be undone. | live: Mitra needs your answer before it can continue. | keyboard on the card: true
  denied: Mitra says: Okay, I have not cancelled ORD-1043. | ORD-1043 is: processing | keyboard back in the box: true
5 approved: ORD-1043 is: cancelled | questions counted: 5 of 5
6 limit: You have used all 5 questions for today. Try again in 1 hour. | box disabled: true | live: Daily limit reached. Try again in 1 hour.

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

Count the resend after an approval

In the third example, in the route, replace the line if (messages[messages.length - 1].role === "user") { with { and press Run.

terminal
$ Console
── what you'll see ──
4 approval card: Cancel ORD-1043: Ginger biscuits, Rs 180. This cannot be undone. | live: Mitra needs your answer before it can continue. | keyboard on the card: true
denied: Mitra says: Okay, I have not cancelled ORD-1043. | ORD-1043 is: processing | keyboard back in the box: true
(nothing more prints: the visitor is waiting for an approval card that never comes)

Myth vs fact

Myth

A capstone is just all the code in one place.

Fact

It is the proof that the parts work together, especially at the edges: a stop in the history, an approval that resends, a limit that locks the box.

Myth

If every part has tests, the whole needs none.

Fact

Interactions break between parts. Keep one end-to-end scenario, like the visitor, that walks the happy path and the failure paths.

Myth

The live region should read the whole answer.

Fact

Streaming text read token by token is unusable. Announce the start and the end, and let people read the answer on their own (Topic 10.6).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Keep the visitor. A scripted walk through the product, waiting for the page and never for a timer, is the cheapest end-to-end test you can write, and it runs against the fake model in CI (Topic 13.6).

  • ▸

    Print what the user sees, not internal state. The visitor reads the page's text because that is what a customer experiences. A test that checks only state can pass while the screen is wrong.

  • ▸

    When you replace the stand-ins, change one at a time: the model first, then the counters, then the order data. After each change run the visitor again.

  • ▸

    Write down the rules that live on the server in a list (user from the session, count questions once, owner and status checks inside execute, approval text from data). A review is then a comparison of the code with the list.

Remember this

  1. 1

    The problem this solves: Knowing each piece is not the same as having a working product. Pieces interact: a stopped answer is part of the history; an approval resends the conversation, which must not count as a new question; the live region must say the right thing at every edge. This topic assembles the pieces in order, shows each one working alone, and then shows the whole.

  2. 2

    Starting point. Mitra began in Phase 0 as one question box and a streamed answer. It learned reliable requests (Phase 1), streaming (2), stopping and recovery (3), conversation state (4), the AI SDK (5), safe formatting (6), structured cards (7), tools and approval (8), defence against hostile text (9), pleasant and accessible UX (10) and production limits (13). This capstone puts the core of that into one file you can read top to bottom.

  3. 3

    The build, in three live examples. (1) The pieces that draw things: safe Markdown rendered as React elements, and the order card validated with zod before it is drawn. (2) The chat loop: useChat and streamText, Stop, history that includes the stopped answer, a daily limit with a 429 path and one polite live region. (3) Everything together: the same chat plus a lookup tool and a cancel tool behind approval, driven by a scripted visitor who takes the happy path and then the failure paths.

  4. 4

    What the visitor does. It asks a question that gets a formatted answer with a link; looks up an order and gets a card; stops an answer in the middle; asks to cancel an order and presses No, so the order stays as it was; asks again and presses Yes; and finally asks a sixth question and meets the daily limit. Each step prints what the page shows, and the live region text. Nothing waits for a timer: the visitor waits for the page to reach the state it expects.

  5. 5

    What Mitra can do now that it could not before: do all of the above in one app, with every state on screen (waiting, streaming, stopped, needs your answer, limit reached, error), and with the rules that matter enforced by code on the server (who can cancel, how many questions, what counts as a question), not by the prompt.

  6. 6

    What is still missing, and where to find it. This file leaves out parts that plug into the same places: starter prompts, feedback buttons and a multi-line input (Phase 10), file, image and voice input (Phase 11), on-device features (Phase 12), the production layer with a backup model, cache and metrics (Phase 13) and the multi-step refund with a plan (Phase 14). Real model providers, a real database for counters and conversations, deployment and monitoring are yours to connect: the shape is the same, and the stand-ins are marked.

  7. 7

    Smaller than the course. This Markdown renderer is a small one on purpose: bold, code, lists and links. The code blocks with a copy button, sources and reasoning of Phase 6 and the streamed objects of Phase 7 plug into Message the same way the order card does. Conversations are not saved here (Topics 4.4 and 5.8): a refresh starts again.

  8. 8

    What the course did not cover. It teaches the frontend and the edge of your own server. How models work inside, writing good prompts and measuring the quality of answers are model-side topics: see the LLM Engineering course (/llm-engineering). Building the search behind an answer, with chunking, embeddings and citations, is the RAG course (/rag). Accounts and sign-in, a real database, billing, native mobile apps, and running the model providers themselves are outside it. Rate limiting and caching at scale are covered further in System Design (/system-design). The standing advice: the model is the part you cannot control, so keep every rule that matters in code you do control.

Explain it without notes

01

Walk me through your capstone: what does a user see from the first question to the limit, and what does the server do at each step?

02

Why does the approval resend not count as a question, and how did you make sure?

03

What does your live region announce, and why not the answer text?

04

If you could add one thing to Mitra next, what would it be and where would it plug in?

Practice

01

Add a third tool returnPolicy to the third example that returns { days: 7 } and let Mitra answer "How long do I have to return?" with a card. Which parts of the browser code change?

02

Make the live region also say "2 questions left today" after each answer. Where does the number come from, and why not compute it in the page?

Trade-offs

  • ↔

    One file is easy to read and to teach, and a real project splits it into the route, a few components and a test. The boundaries in this file (route, pieces, loop) are where you would cut.

  • ↔

    Stand-ins make every run identical and cost nothing. They also hide real behaviour: a real model is slower, wordier and sometimes wrong. Run the same visitor against a real model once, by hand, before you ship.

Done when you can

  • I can name every part of Mitra and say whether it lives in the page, the route or the model.

  • I can explain why the server, not the prompt, enforces who may cancel and how many questions are allowed.

  • I can describe what the page shows in every state: waiting, streaming, stopped, needs approval, limit, error.

  • I can explain why an approval resend is not counted and show the test.

  • I can write a scripted visitor that waits for the page and covers the failure paths.

  • I can say what I would replace first when moving from stand-ins to a real provider.