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,readyorcut 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.
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.
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.
Try it yourself
- 1
Take the healing out
Change
heal(text)totextinMessageand 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
Make the reply hostile
Add
<img src="x" onerror="window.bananas=1">to the first line ofREPLY.It appears as visible text and
payloads that ranstays 0. - 3
Add a third source
Write a third
source-urlin the server (the reply already saysPostage is extra [3].).The list gains an entry, the marker becomes a link, and the console line about
[3]turnsfalse. Change the marker inREPLYto[4]and it stays plain text again.
Code & diagrams
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
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: 0Break 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.
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,CodeBlockand 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
The problem this solves: Phase 5 ended with Mitra on
useChatandstreamText, 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
Where Mitra starts. The Phase 5 Mitra:
/api/chatonstreamTextwithtoUIMessageStreamResponse;useChatwithstatus,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
What changes. (1) The server writes two
source-urlparts before the model's stream and merges withsendStart: false(Topic 6.6). (2) AMessagecomponent draws the assistant's text throughhealandMarkdown(6.1), with aCodeBlockwhose 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
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
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
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
writingtoready; a hostile link shown as plain text; an outside link withnoopener 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 becomescut offand the server's message is shown (Topic 6.2's second example). *Stop:* theCodeBlockhere computescut offfor 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
Walk through what happens to one assistant message between the stream and the screen in Mitra.
Which parts of Phase 6 are defences, and which are comfort?
How would you extend Mitra to show images safely?
Practice
Add a test button that sends a reply containing  and make Mitra show "Image blocked (attacker.example)" instead of an image.
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.