Topic 6.7
Showing Reasoning and Progress
In one line
Some models send their reasoning before the answer, and your server can send progress lines such as "Looking up your order…". Show them as a calm, collapsible aside, keep progress lines out of the saved message, clear them when work ends or fails, and hide reasoning when it should not be seen.
Think of it like this
At a restaurant counter you hear the cook say "I'm frying it now" while you wait. You don't need the recipe. You only need to know something is happening. A progress line is that voice from the kitchen. Reasoning text is the cook thinking out loud, which you may or may not want customers to hear.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Reasoning part
- A message part (
type: "reasoning") holding the model's thinking text, sent before the answer by models that produce it. - sendReasoning
- A server option. When false, the reasoning is not sent to the browser at all.
- <details>
- An HTML element that shows a summary and can be opened and closed with no JavaScript.
- Data part
- A custom part of a message, named
data-<name>, that carries any JSON your UI needs. - Transient part
- A data part sent with
transient: true. The client sees it once throughonDataand it is not kept in the message. - onData
- A
useChatcallback that runs for each data part as it arrives. - Live region
- A part of the page that screen readers announce when it changes (
role="status"is polite). - Output tokens
- The pieces of text a model produces. Reasoning counts as output tokens.
Step by step
01What arrives, in order
The first example logs the parts of the assistant message each time they change. For a reasoning model: reasoning:streaming, then reasoning:done text:streaming, then reasoning:done text:done. The reasoning part is complete before the text begins.
02Fold it away
Thinking draws a <details>. While its part is streaming and no answer text exists yet, it is open and says "Thinking…". Afterwards it closes and says "Thought for a moment". The user's own click is stored separately, so the code never fights the user.
After the first answer the page has one details element and none is open.
03When there is nothing to show
Answers 2 and 3 have no reasoning part: one because the server set sendReasoning: false, one because that model did not reason. No new details element appears, and the answer is complete. Every branch of the UI must be fine with the part being absent.
04Progress lines as transient data
The second example's server is a createUIMessageStream. It writes two transient data-status parts ("Looking up your order…", "Checking the return policy…") before merging the model's stream. The client's onData shows each in a role="status" paragraph, and the line disappears when the first answer text arrives.
Look at the last number of each "finished" line: zero data parts in the message. Nothing to save, nothing to restore after a refresh.
05A step fails
The scripted visitor then asks again with a server step that throws. The stream errors, useChat goes to error, and the page shows "Mitra could not finish. Please try again.", the message the server chose (Topic 5.6). The status line is cleared because it is only shown while the chat is busy.
Try it yourself
- 1
Show the reasoning forever
In the first example, change the
openattribute toopen={true}and run it.Every answer's thinking now sits open above it, pushing the answer down. Compare with the folded version.
- 2
Make the status line sticky
In the second example, change
busy && !hasText ? line : ""to!hasText ? line : ""and run it.Read the second
finished:line: the failed answer leaves the old line on screen. - 3
Slow a step down
In the second example, change both
wait(20)calls towait(400)and watch the page.Each line stays long enough to read. Add a third
say("Still searching…")after a longer wait to see how a slow step reads.
Code & diagrams
Answers 2 and 3 add no details element, so the count stays at 1 (from answer 1). The reasoning here is scripted text from the stand-in model; a real model writes its own.
Preview
Console
Nothing logged yet.Expected console output
parts: reasoning:streaming
parts: reasoning:done text:streaming
parts: reasoning:done text:done
answer 1 | details on the page: 1 | open: 0
parts: text:streaming
parts: text:done
answer 2 | details on the page: 1 | open: 0
parts: text:streaming
parts: text:done
answer 3 | details on the page: 1 | open: 0The line is derived from state (busy and no answer text yet), not stored and cleared by hand, so a failure cannot leave it behind.
Preview
Console
Nothing logged yet.Expected console output
status line: Looking up your order…
status line: Checking the return policy…
finished: ready | line on screen: "" | data parts kept in the message: 0
status line: Looking up your order…
status line: Checking the return policy…
finished: error | line on screen: "" | data parts kept in the message: 0
error: Mitra could not finish. Please try again.sendReasoning and sendSources are options of toUIMessageStreamResponse in the installed ai 7.0.138 types. Whether a model produces reasoning, and how to ask for it, depends on the provider and model: check its documentation.
// Do not send the model's thinking to the browser (it is also never saved with the message).
return result.toUIMessageStreamResponse({ sendReasoning: false });
// Progress line: transient, so it is not stored in message.parts
writer.write({ type: "data-status", data: { text: "Looking up your order…" }, transient: true });
// Client
useChat({ onData: (part) => { if (part.type === "data-status") setLine(part.data.text); } });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
Leave the status line on after an error
In the second example, change const shownLine = busy && !hasText ? line : ""; to const shownLine = !hasText ? line : ""; and run it.
Myth vs fact
Myth
Showing the model's reasoning shows how it really decided.
Fact
It is more text the model wrote, and it may not reflect what produced the answer. Treat it as a helpful aside, not as evidence.
Myth
Hiding reasoning with CSS keeps it private.
Fact
It was still sent to the browser. If it must not be seen, do not send it (sendReasoning: false).
Myth
Status lines belong in the message history.
Fact
They describe work in progress. Saving them clutters history and brings back stale lines after a refresh. Use transient parts.
Myth
A spinner is enough.
Fact
A spinner says something may be happening. A short, true line tells the user what and lets them judge whether to wait.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
With most providers reasoning is billed as output tokens even if you hide it (Topic 13.1 on cost). Some providers let you limit or summarise it. Check your provider's current options before relying on it.
- ▸
Do not read reasoning aloud by default. Collapse it, label the summary clearly, and let screen reader users open it if they wish.
- ▸
Reasoning can contain text from tool results or retrieved documents, including hostile text. Render it with the same safe renderer as the answer.
- ▸
Test the three shapes of a message: reasoning plus text, text only, and reasoning only (a stream that was stopped or failed before the answer began). The last one should still show something sensible.
Remember this
- 1
The problem this solves: Between the question and the first word of the answer there can be many seconds: the model thinks, your server searches, a tool runs. A blank screen with a spinner feels broken. Showing what is happening makes the wait bearable. But reasoning text and status lines are not the answer: they must not be confused with it, saved as if they were, or left on the screen when the work ends or fails.
- 2
Reasoning parts. Some models produce "thinking" text before the answer. In the AI SDK it arrives as a part of type
reasoningwithtextandstate("streaming"then"done"), before thetextpart. Your server decides whether it is sent:toUIMessageStreamResponse({ sendReasoning: false })leaves it out. In the first example, answer 2 sets that, and no reasoning part reaches the browser. A model that does not reason sends none, so your UI must work with zero reasoning parts too (answer 3). - 3
Show it quietly. A good pattern: while the model is thinking, show a short label ("Thinking…") and the text if you like. When the answer starts, fold it into a closed
<details>("Thought for a moment") that the user can open. Do not put reasoning in the same box or style as the answer, and never speak it aloud by default to screen reader users: it is long and repeated work for them. Draw its text as plain text or through the same safe Markdown renderer. It is model output, so Topic 6.3 applies to it fully. - 4
Should you show reasoning at all? Arguments for: trust, debugging, learning. Against: it can be long, wrong, or leak things you would rather not publish, such as hidden instructions, internal tool names or customer data from other steps. It also counts as output tokens you pay for whether or not you show it (Topic 13.1). Many products show a short summary or nothing. Decide per product, and send nothing you do not want users to read: hiding it only in CSS still sends it over the network.
- 5
Progress lines are not parts of the message. "Looking up your order…" is information about the work in progress, not about the conversation. In the SDK you send it as a transient data part:
writer.write({ type: "data-status", data: { text }, transient: true }). Transient parts reachonDataon the client and are *not* added tomessage.parts, so they are not saved and do not come back after a refresh. The second example prints the last message's data parts: zero. - 6
Rules for status lines. Keep each short and in plain words (not tool names such as
search_orders_v2). Show only the latest one, replacing the previous. Hide it as soon as the answer's first word arrives, otherwise two things talk at once. Announce it politely withrole="status", neverrole="alert"(alerts interrupt the reader). And clear it when the stream ends *or fails*. The break-it shows a line frozen on the screen after an error. - 7
When progress lines lie. Fixed lines sent at fixed times ("Almost done…") are decoration, and users notice. Send a line when a real step starts. If a step takes unusually long, say so ("Still searching…") rather than repeating the same line. Edge cases: a step that fails (send nothing, show the error), two steps at once (show the most recent), no steps at all (the plain "thinking" state from Phase 0).
Explain it without notes
How does a reasoning part arrive and in what order relative to the answer?
Why send progress lines as transient data parts rather than normal parts?
Why is it not enough to hide reasoning with CSS?
What state does a good progress line depend on, and why?
Practice
Add a "Hide thinking" checkbox to the first example that, when ticked, does not draw Thinking parts even if they arrive.
Add a third status line, "Still searching…", that the server sends only if a step takes longer than 300 ms.
Trade-offs
- ↔
Showing reasoning builds trust and gives you debugging material. It adds long text, can embarrass you if it is wrong or reveals internals, and costs tokens whether or not it is shown.
- ↔
Many status lines feel informative; too many feel noisy. One line per real step, replaced as the work moves on, is a good default.
Done when you can
I can describe the order of reasoning and text parts and handle the absence of reasoning.
I can show reasoning as a closed
<details>that never fights the user's click.I can send a progress line as a transient data part and read it with
onData.I can derive the status line from state so a failure cannot leave it on screen.
I can decide whether reasoning should be sent at all, and say what that choice costs.