Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~39 min· topic 7 of 8

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 through onData and it is not kept in the message.
onData
A useChat callback 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.

What arrives, in orderdiagram
Rendering diagram…

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

    Show the reasoning forever

    In the first example, change the open attribute to open={true} and run it.

    Every answer's thinking now sits open above it, pushing the answer down. Compare with the folded version.

  2. 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. 3

    Slow a step down

    In the second example, change both wait(20) calls to wait(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

Reasoning parts: streamed, hidden by the server, or not there at all

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

Press Run to see the page.

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: 0
Progress lines: shown while working, never saved, cleared on failure

The 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

Press Run to see the page.

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.
Server options (not live)ts

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.

terminal
$ The console
── what you'll see ──
finished: error | line on screen: "Checking the return policy…" | data parts kept in the message: 0

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

    Reasoning parts. Some models produce "thinking" text before the answer. In the AI SDK it arrives as a part of type reasoning with text and state ("streaming" then "done"), before the text part. 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. 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. 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. 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 reach onData on the client and are *not* added to message.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. 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 with role="status", never role="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. 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

01

How does a reasoning part arrive and in what order relative to the answer?

02

Why send progress lines as transient data parts rather than normal parts?

03

Why is it not enough to hide reasoning with CSS?

04

What state does a good progress line depend on, and why?

Practice

01

Add a "Hide thinking" checkbox to the first example that, when ticked, does not draw Thinking parts even if they arrive.

02

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.