An agent is not a text stream
A chatbot streams tokens; an agent streams tool calls, tool results, step boundaries and mid-flight errors — and a UI that models the stream as a growing string cannot render any of them.
Everything so far has treated the response as one model call. It is not. Ask the chatbot the Tokyo question and what actually happens is a loop: the model decides to look something up, a tool runs, the result goes back, the model decides again, and only at the end does prose come out. Somewhere in there a tool returns nothing useful and the model changes approach.
HouseWarm never had this shape. Extraction is one pass over one document — the confidence scores and source crops are rich, but they describe a single completed operation. An agent turn is a sequence of operations whose length is not known when it starts, any one of which can fail. That is the structural difference, and a UI whose state is { text: string, loading: boolean } cannot represent it no matter how the text is rendered.
What a turn actually carries
The most complete published inventory belongs to Vercel — a vendor documenting its own product, and one this course treats as a catalogue rather than a prescription. Its data stream protocol, which is SSE on the wire, lists these chunk types:
start
text-start · text-delta · text-end
reasoning-start · reasoning-delta · reasoning-end · reasoning-file
source-url · source-document · file · custom · data-*
tool-input-start · tool-input-delta · tool-input-available
tool-approval-request · tool-approval-response
tool-output-available · tool-output-denied
start-step · finish-step · reset-step
error · abort · finishText is four of roughly twenty-five. That ratio is the argument of this lesson in one line.
The wire shapes are mundane, which is the point — there is nothing exotic here, only information your current model has no slot for:
data: {"type":"tool-input-delta","toolCallId":"call_fJdQ","inputTextDelta":"San Francisco"}
data: {"type":"tool-input-available","toolCallId":"call_fJdQ","toolName":"getWeatherInformation","input":{"city":"San Francisco"}}
data: {"type":"tool-output-available","toolCallId":"call_fJdQ","output":{"weather":"sunny"}}
data: {"type":"abort","reason":"user cancelled"}
data: [DONE]Where people get burned
Two staleness warnings, both of which you should treat as permanent properties of this ecosystem rather than as facts about today.
The library moves fast. As of September 2026 the current major of the ai package is v7, with v5 and v6 both still maintained on separate npm tags and separate documentation hosts. v7 alone renamed system to instructions, onFinish to onEnd, result.fullStream to result.stream, moved the response helpers to standalone functions, and dropped CommonJS. Any tutorial you find is probably about a different major. The wire format in the previous lesson has not changed comparably in the same period, which is the durable reason to learn it.
The documentation is incomplete against the code. The package’s own chunk schema accepts tool-input-error, tool-output-error and message-metadata, none of which appear on the docs page above. If you write a backend that speaks this protocol, the schema file is the authority. That is a general habit worth forming, not a complaint about one vendor.
Step boundaries are the thing you are missing
start-step and finish-step are the events with no equivalent in a text stream, and they are what make a multi-step turn renderable. Each model call in the loop is a step; a step contains tool calls and possibly text. Without boundaries you cannot group a tool call with the reasoning that produced it, cannot show that the agent is on its third attempt, and cannot discard the work of one failed step while keeping the rest.
That last one is worth pausing on, because the protocol has a chunk for it. reset-step is documented as removing all message parts received since the most recent start-step. Retry is a first-class operation in the protocol, not something bolted on by the client — which tells you the design assumes a step can fail and be redone while the user is watching the previous attempt. Your current UI has no representation for that at all.
On the client side the same information lands as message parts. A UIMessage holds a parts array rather than a string, with variants including text, reasoning, step-start, file, source-url, and tool parts typed as tool-${NAME}. And a tool part is a six-state machine, not the two states a spinner implies: input-streaming, input-available, approval-requested, approval-responded, output-available, output-error.
Look at that list next to HouseWarm. An approval state, sitting inside a tool call, mid-turn, while the rest of the response is still streaming. That is your review gate — the design you already know how to do well — relocated from “after the response” to “during it.” The interaction pattern transfers; the state model does not.
Design your own union anyway
Do not adopt a vendor’s event vocabulary as your application’s internal one. The version churn above is reason enough, but the better reason is that a provider’s events describe an API response and your UI needs to describe your product. Write the union you would want if no library existed, then adapt at the edge:
type TurnEvent =
| { kind: 'turn-start'; turnId: string }
| { kind: 'step-start'; stepId: string }
| { kind: 'text'; stepId: string; delta: string }
| { kind: 'tool-start'; callId: string; tool: ToolName }
| { kind: 'tool-input'; callId: string; partial: string }
| { kind: 'tool-input-done'; callId: string; input: unknown }
| { kind: 'tool-output'; callId: string; output: unknown }
| { kind: 'tool-failed'; callId: string; message: string }
| { kind: 'step-end'; stepId: string }
| { kind: 'step-reset'; stepId: string }
| { kind: 'turn-aborted'; reason: string }
| { kind: 'turn-failed'; message: string }
| { kind: 'turn-end'; turnId: string }Three properties make this worth the hour it takes to write. Every arm has a rendering — if you cannot say what the screen does for one, either the arm is wrong or you have found a design decision you were about to skip. Failure is representable in three distinct places (a tool failed, a turn failed, a turn was aborted), which is the minimum, because those three deserve different UI. And the union is a type, so an unhandled arm is a compile error rather than a blank region of the screen in production.
The adapter that maps provider events onto this is boring code, and that is the whole return on the investment: when the SDK ships a major, or the chatbot moves provider, the diff is one file and your render tree does not move.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Your reducer handles text deltas and tool outputs and nothing else. Name the specific screen failure that produces.
Check your answer
The visible symptom is a long dead pause followed by a sudden jump. The agent opens a tool call, the arguments stream in, the tool runs — none of which your reducer has an arm for — and then output appears all at once. From the user’s side that is indistinguishable from a hang followed by a burst, which is the exact experience streaming was supposed to eliminate.
The subtler failure is worse. With no tool-failed and no turn-failed arm, a tool that errors produces the same screen as a tool that is still running: nothing new. The turn ends, the text stops, and the user reads a truncated answer as a complete one. Nothing errored anywhere you can see, because the status code was 200 and your reducer had no slot for the event that said otherwise.
Hands on
Give the chatbot an event model, and render tool calls individually
Done when: Tool calls in the flight chatbot render as separate, individually-stated elements — name visible before arguments, arguments before output, a distinct rendering for a failed one — driven by a typed event union with no unhandled arms.
- Write the
TurnEventunion for your chatbot in a single file. Use your own tool names, not the placeholder ones. Do not import a provider’s types into it. - Write the adapter that maps your provider’s raw events — the ones you captured and annotated in the previous lesson’s log — onto that union. Keep it in its own module. If an arm has no source event, that is a finding: either your provider does not emit it or you have not looked hard enough.
- Write the reducer as an exhaustive switch, with a
nevercheck in the default branch so an unhandled arm fails the type check rather than the screen. - Render each tool call as its own element with its own visible state. The minimum bar: the tool’s name appears before its arguments are complete, the arguments appear before the output, and a failed tool call looks different from a running one. A single spinner covering the turn does not pass.
- Force a tool to fail — break its endpoint, or return an error from it deliberately — and confirm the screen says so while the rest of the turn continues. This is the step people skip and it is the one the pass condition is really about.
- Record the union and the arm-by-arm rendering decisions in
ARTIFACT.md, and bring the union into the chat. I will look for arms that have no rendering and for failure modes that collapsed into a single generic error state.
What this does not cover
You now have an event model and tool calls that render individually. What you do not have is any principle for what to show when a piece of the answer is half-arrived — a recommendation at forty percent, a code block with no closing fence, a table missing its last two rows. That is a product judgement with real consequences on an advisory surface, and it opens the module on rendering a partial answer, starting with the lesson on how much of a thought is safe to show.
The turn-aborted arm is a promise this lesson explicitly did not keep: it records that the user stopped watching, not that the work stopped. Closing that gap — reaching the server, deciding what happens to a half-finished turn, and stopping the bill — is the stop-button lesson. And the whole question of what survives when the connection dies rather than being closed on purpose belongs to the refresh-survival lesson after it.
Read this next — primary source
Stream ProtocolVercel — vendor documenting its own product (AI SDK). Free. Verified against v7, the current major as of September 2026; v5 and v6 are still separately maintained on npm and their docs live at different hosts
This lesson borrows the vocabulary and argues you should design your own. Reading the whole page is worth it as the most complete published inventory of what an agent stream has to carry — every chunk type with its exact wire shape, including the ones this lesson had no room for: reasoning parts, source parts, custom provider content, and the full tool-approval round trip. Read it as a well-considered catalogue of the problem space, not as an API to adopt on sight, and then compare it against the package’s own schema file, because the two disagree.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.