Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~30 min· topic 4 of 9

Topic 5.4

UIMessage Parts

In one line

A message in the AI SDK is not one string. It is a list of parts, each with a type: text, reasoning, a tool call, a source, a file, your own data, and a marker between steps. You render a message by looping over its parts and choosing a component for each type, and you ignore the types you do not know.

Think of it like this

A tiffin, the stacked lunch box. One box, several compartments: rice, curry, bread, a sweet. You do not eat 'the box'; you open each compartment and treat what is in it as what it is. A message is the tiffin, and its parts are the compartments. You open each one by its label.

Words you'll meet

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

Part
One labelled piece of a message: text, reasoning, a tool call, a source, a file or data.
Part type
The type string that says what a part is, such as text or tool-lookupOrder.
State (of a part)
Where a part is in its life: for text streaming or done; for a tool input-available, output-available and more.
Reasoning
Working-out text some models produce before the answer.
Tool part
A part for one tool call. It holds the tool's input, and its output once it has run.
Source part
A part that names a web page (source-url) or document (source-document) the answer used.
Data part
A part whose type starts with data-. It carries any JSON your own server wrote into the stream.
Transient
A data part that is not saved in the message and is delivered only to onData.
step-start
An invisible marker that begins each step of a multi-step answer. Render nothing for it.

Step by step

01How chunks become parts

Topic 5.2's chunks and this topic's parts are two views of one thing. The hook reads each chunk and updates the last assistant message. A text-start chunk adds a text part in the streaming state, text-delta chunks append to it, and text-end marks it done. The same pattern applies to reasoning. A tool call adds a tool part that later gets its output.

How chunks become partsdiagram
Rendering diagram…

02Render by type

The first live example renders a real message that has four kinds of parts: a step marker, reasoning, a tool call with its result, and text. The renderer is a function with one branch per type and a quiet return null at the end.

After the answer finishes, the example prints what ended up on screen, part by part. The step-start markers print nothing, because they render nothing.

03Watch the parts change

The second example records every distinct shape the last message passes through. You can see the reasoning part appear in the streaming state, finish while the tool part appears as input-available, change in place to output-available, and the text part stream and complete.

04Data and source parts from your server

The third example writes a progress part twice with the same id, a one-off toast marked transient, and a source, before merging the model's own stream. The message ends with one progress part (holding the second value), the source, and the text, and the toast went only to onData.

05Edge cases

The fourth example shows the empty reply (no assistant message, status ready), and a rendering function fed messages in older and unknown shapes. A safe renderer returns nothing for what it does not know, and the screen can tell 'nothing visible' from 'nothing came back'.

Try it yourself

  1. 1

    Show the reasoning open

    In the first example change <details className="reasoning"> to <details className="reasoning" open> and run it.

    The model's scratch work is now in the user's face. Many products keep it collapsed, or drop it with sendReasoning: false on the server so it never even leaves your route.

  2. 2

    Add a part type to the renderer

    In the third example, render the progress part: replace the useEffect loop with a PartView like the first example's, and add if (part.type === "data-progress") return <p>{part.data.step}</p>; to it.

    The user sees 'Looking up your order' first, and the same paragraph changes in place to 'Found it' when the server writes the same id again, above the answer.

  3. 3

    Break the switch on purpose

    In the first example replace the last line of PartView (return null;) with return <p>{part.text}</p>; and run it.

    The step-start parts have no text, so two empty paragraphs appear. For a type with an object in it, such as a data part, React would throw 'Objects are not valid as a React child'. This is why unknown types must return nothing.

Code & diagrams

Rendering a message part by part

The assistant message has five parts but only three are visible: reasoning (collapsed), the tool line and the text. The two step-start markers rendered nothing. The first on-screen line is the user's own message. This is a full tool round trip with no tool UI work, because the SDK ran the tool on the server and put its result in the same part.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

part types in the message: step-start, reasoning, tool-lookupOrder, step-start, text
on screen: text -> Where is ORD-1042?
on screen: reasoning -> ThinkingThe customer asks about an order, so I should look it up.
on screen: tool-lookupOrder -> Looked up lookupOrder: shipped
on screen: text -> Order ORD-1042 has shipped.
The parts change shape while the answer streams

Each line is a new shape of the same message. A part is never replaced by a new one: the tool part keeps its place and its state moves forward, so a list keyed by index and by part position stays stable.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

step-start  reasoning(streaming)
step-start  reasoning(done)  tool-lookupOrder(input-available)
step-start  reasoning(done)  tool-lookupOrder(output-available)  step-start  text(streaming)
step-start  reasoning(done)  tool-lookupOrder(output-available)  step-start  text(done)
Data, source and transient parts written by your own server

onData sees every data part, including the transient toast and both progress writes. The stored message keeps one progress part with the latest value, the source, a step marker and the text, and no toast.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

onData: data-progress {"step":"Looking up your order"}
onData: data-progress {"step":"Found it"}
onData: data-toast {"text":"Saved to your history"} (transient)
assistant messages: 1
part data-progress: {"step":"Found it"}
part source-url: Returns policy https://example.com/returns
part step-start
part text: Returns are accepted within 7 days.
Edge cases: an empty reply, and parts you have never seen

A message with nothing to read still exists in the list. Decide in one place what 'visible' means, and skip empty bubbles.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

message 1 shows: "Hello!"
message 2 shows: ""  -> render nothing, not an empty bubble
message 3 shows: ""  -> render nothing, not an empty bubble
message 4 shows: ""  -> render nothing, not an empty bubble
empty reply -> status: ready | messages: 1 | last role: user
screen says: Mitra had no answer. Try again.

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

Render text for every part

In the first example replace return null; // step-start, data, file, and anything new with return <p>{String(part.text)}</p>; and run it.

terminal
$ The console, lines for the step markers
── what you'll see ──
on screen: step-start -> undefined
on screen: step-start -> undefined

Break #2

Write data before the start chunk

In the third example delete writer.write({ type: "start" }); and change result.toUIMessageStream({ sendStart: false }) to result.toUIMessageStream().

terminal
$ The console, line after the answer
── what you'll see ──
assistant messages: 2

Myth vs fact

Myth

A message is just its text, so I can show message.parts[0].text.

Fact

The first part may be a step marker or reasoning. Filter by type; never index into parts.

Myth

A tool call needs a custom client-side handler to show up.

Fact

Tool calls arrive as ordinary parts with their own state, even when the tool runs on the server. Showing them is rendering, not wiring.

Myth

Data parts are saved forever with the message.

Fact

Regular data parts are in the message, so they are saved when you save messages; transient ones are not stored at all.

Pro corner

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

  • ▸

    Type your messages. UIMessage<Metadata, DataParts, Tools> takes your own shapes, so part.data and part.output are typed and the renderer's switch is checked by TypeScript. This is a strong reason to define one MitraMessage type shared by the route and the client (/typescript course).

  • ▸

    Use the part's position as the React key only because parts never reorder. When a part has its own id (data-* with id, tool parts with toolCallId) prefer that.

  • ▸

    Keep rendering of each part type in its own small component. Phase 6 replaces the text branch with a Markdown renderer, Phase 7 adds cards for tool results, and Phase 8 adds approval buttons. A switch that only picks components stays readable through all of it.

Remember this

  1. 1

    The problem this solves: In Phase 4 a message had one content string. Real AI answers are more than text. A model can 'think' first, call a tool and wait for it, cite a web page, return a file, and you may want to send your own progress updates. Squeezing all that into one string forces you to parse markers out of text. Parts keep each piece separate, labelled and in order.

  2. 2

    The shape. A UI message is { id, role, metadata?, parts }. parts is an array in the order things happened. Every part has a type. The common ones: { type: "text", text, state }; { type: "reasoning", text, state }; { type: "tool-<name>", toolCallId, state, input, output } (for a tool named lookupOrder the type is tool-lookupOrder); { type: "source-url", sourceId, url, title }; { type: "source-document", ... }; { type: "file", mediaType, url }; { type: "data-<name>", id?, data }; and { type: "step-start" }, an invisible marker between the steps of a multi-step answer.

  3. 3

    Rendering is a loop and a switch. message.parts.map((part, i) => ...) and a function that picks a component by part.type. The rule that keeps you safe: a part type you do not know renders nothing. New SDK versions and your own data parts add types; if the switch crashes on an unknown type, one new feature breaks every old saved chat. Never render part.text for a type you did not check.

  4. 4

    **state on text and reasoning.** A text part is "streaming" while words arrive and "done" when its block ends. You can use it for a cursor on the last streaming part. Tool parts have a longer life: input-streaming, input-available, output-available, output-error and, with approval (Phase 8), approval-requested, approval-responded and output-denied. Phase 8 renders them in depth. Here you only need to know that they are parts, and that a tool part changes in place as its state moves on.

  5. 5

    Reasoning parts. Some models send their working-out before the answer. It arrives as a reasoning part. By default the SDK forwards it (sendReasoning defaults to true); you can turn it off on the server with toUIMessageStreamResponse({ sendReasoning: false }). Show it collapsed, or not at all. It is the model's scratch work, it can be wrong or odd, and many products hide it (Topic 6.7).

  6. 6

    Data parts: your own progress and results. On the server you can write { type: "data-progress", id: "p1", data: { step: "Looking up" } } into the stream (with createUIMessageStream). The browser gets a part of that type. Writing again with the same id updates the same part instead of adding a new one. A part marked transient: true is not stored in the message at all and only reaches the onData callback, which suits one-off toasts. Source parts (sendSources: true on the response) carry the pages an answer drew on (Topic 6.6).

  7. 7

    Edge cases that surprise people. An empty model reply creates no assistant message at all: the status goes back to ready and the last message is still the user's, so you must handle 'nothing came back'. A message can hold only a step-start, or only a data part, and have no text to show. And two things written to the stream before the start chunk end up in a message of their own, as the second breakIt shows.

  8. 8

    File parts, briefly. A file part is { type: "file", mediaType, url, filename? }. The url is either a hosted address or a data URL (the file's bytes written into the address itself). The model can produce one (a generated image) and a user message can carry one (an attachment, Topic 11.1). Render it by checking mediaType first: show image/* in an <img> with an alt, anything else as a download link, and render nothing for a mediaType you do not handle. Treat url as untrusted, like a source URL (Topic 6.4).

Explain it without notes

01

Why did the AI SDK move from a single content string to a list of parts?

02

How should a renderer treat a part type it does not recognise, and why?

03

What is the difference between a normal data part, a data part with the same id, and a transient data part?

04

What happens in the UI if the model returns an empty reply?

Practice

01

Write a function describe(message) that returns a one-line text for a message list preview: the first non-empty text part shortened to 40 characters, or 'Reply with no text' if there is none.

02

Add source-url parts to the first example's renderer so they appear as a 'Source: title' link with rel="noopener noreferrer" and target="_blank". Why is it safe to show only http and https URLs?

Trade-offs

  • ↔

    Parts make rendering precise and extensible, but every consumer (screen, saved chats, analytics, tests) must now understand the list. A plainText(message) helper in one place keeps the rest of the code simple.

  • ↔

    Streaming your own data parts gives a rich progress UI, but each one is a contract between your server and every client version in the wild. Keep their shapes small and versioned, and ignore unknown ones.

Done when you can

  • I can list the common part types and say what each carries.

  • I can render a message with a loop and a switch that ignores unknown types.

  • I can say how a tool part changes while its tool runs.

  • I can write a data part, update it by id, and send a transient one.

  • I can handle a reply with no assistant message and a message with nothing visible.