Tutorial
Why Users Think Your Agent Is Frozen — Streaming to a Real UI (Part 4)
Ten seconds of silence looks identical to a crash. Part 4: wire agent.stream() into a UI — tokens, tool calls, and custom progress events — so users watch the work happen instead of staring at a spinner.
Picture a user watching your agent think.
They asked it to plan a weeklong trip to Lisbon. Behind the scenes, the agent already knows what to do — check the weather, look up flights, search for somewhere to stay, then pull it all into an itinerary. That's three tool calls and a few thousand tokens of reasoning before a single word of the actual answer exists.
From where the user sits, none of that is visible. They see a spinner. Five seconds pass. Then eight. By the time it hits ten, they're fairly sure something broke — and honestly, silence while an agent works looks identical to silence while it's stuck.
Closing that gap is what this part is about.
The first three parts of this series built an agent,
orchestrated it with workflows, and
hosted it in a harness. Every code sample along
the way used agent.generate() — call it, await it, get back a finished
answer. That's a fine shape for a script running quietly in a terminal.
It's the wrong shape for anything a person is looking at. A user doesn't want to wait for the finished answer. They want to watch it happen — tokens appearing, "searching the docs…", a tool firing, the answer assembling piece by piece.
That's streaming. In this part I swap generate() for stream() and follow
the data all the way to a UI.
The series so far
- Agents — the loop, tools, memory.
- Workflows — orchestration with guarantees.
- The Harness — the runtime that hosts it.
- Streaming (you're here) — get the agent's work to a UI as it happens.
From generate to stream
The change starts out almost too simple to be worth its own section — which is
kind of the point. Where generate() hands you back one finished result,
stream() hands you back a stream object with several different ways to read
from it. You just pick the view that matches whatever you're rendering.
Here's the smallest possible version:
import { mastra } from "./mastra";
const agent = mastra.getAgent("assistant");
const stream = await agent.stream("Help me plan a trip to Lisbon.");
// The simplest view: just the text tokens, as they arrive.
for await (const chunk of stream.textStream) {
process.stdout.write(chunk);
}Run that, and the answer types itself out — arriving character-group by character-group instead of landing all at once. Nothing about the agent changed. Same loop, same model, same tools. You only changed how you consume what comes out the other end.
That's textStream, the simplest of the views. It's not the only one.
Three views onto one stream
textStream is the friendly default. But the stream object also exposes a
couple of other readable streams, and a few promises alongside them. Here's
the map, before we walk through each one:
textStream is a ReadableStream<string> — just the assistant's text
deltas, nothing else. Reach for it when you're rendering a plain chat bubble
and don't care about anything happening underneath.
fullStream is the interesting one. It carries every event in the run — text
deltas, yes, but also tool-call, tool-result, step boundaries, and the
final finish event. This is what you want the moment your UI needs to show
tool activity, not just prose. More on that in a second.
objectStream only shows up when you've asked the agent for structured
output. Instead of text deltas, it emits the object itself, filling in field
by field — useful when what you're rendering is a form, not a paragraph.
And then there's a fourth category that isn't a stream at all: .text,
.object, .usage, .finishReason. These are promises. They resolve once,
when the run finishes, to the final value. You reach for these when a
particular code path doesn't care about anything live — it just wants the
finished answer or the token count.
The mistake I made the first time: I awaited stream.text and looped over
stream.textStream in the same code path. Don't do that — the promise
resolves to the same text the stream already produced, so you'd just be doing
the work twice. Pick one view per code path.
Now that you know these exist, the natural next question is what fullStream
actually looks like once an agent starts calling tools.
Watching the tools fire with fullStream
Text-only streaming hides the most interesting part of what an agent does:
the moment it decides to do something. fullStream is where that becomes
visible. Every chunk arrives with a type, and you switch on it:
const stream = await agent.stream("What's the weather in Oslo right now?");
for await (const chunk of stream.fullStream) {
switch (chunk.type) {
case "text-delta":
process.stdout.write(chunk.payload.text);
break;
case "tool-call":
console.log(`\n[calling ${chunk.payload.toolName}]`);
break;
case "tool-result":
console.log(`[got result from ${chunk.payload.toolName}]`);
break;
case "finish":
console.log(`\n[done — ${chunk.payload.usage?.totalTokens} tokens]`);
break;
}
}A run that uses a tool now narrates itself as it goes:
[calling get-weather]
[got result from get-weather]
It's about 4°C and clearing in Oslo right now.
[done — 312 tokens]That [calling get-weather] line is doing more work than it looks like.
Render it the instant the tool-call chunk arrives — well before any answer
text exists — and you've turned dead air into the difference between a UI
that feels responsive and one that feels hung.
But fullStream only shows you what Mastra already knows about: text deltas
and tool events. What about progress that happens somewhere the model can't
see at all?
Emitting your own events with the writer
Here's the feature I didn't expect to love this much.
Sometimes the interesting progress isn't something the model is doing — it's happening inside a tool, somewhere the model has no visibility into at all. Think of a tool that scrapes twelve pages, or compiles a project, or uploads a large file. The model just sees "call this tool, wait, get a result." Everything in between is invisible to it, and by default, invisible to your UI too.
Mastra gives your tool a writer so that doesn't have to be true. The tool can
push its own custom chunks onto the exact same stream your UI is already
reading from fullStream.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const researchTool = createTool({
id: "research",
description: "Research a topic across several sources.",
inputSchema: z.object({ topic: z.string() }),
outputSchema: z.object({ summary: z.string() }),
execute: async ({ topic }, { writer }) => {
const sources = ["docs", "changelog", "forum"];
for (const source of sources) {
// Stream a progress event the UI can render immediately.
await writer?.write({
type: "research-progress",
status: "reading",
source,
});
// ...actually fetch and read the source...
}
return { summary: `Summarized ${topic} from ${sources.length} sources.` };
},
});Those research-progress chunks show up in fullStream right alongside the
built-in ones — same stream, same loop, no separate plumbing. Your UI just
matches on the type it invented:
for await (const chunk of stream.fullStream) {
if (chunk.type === "research-progress") {
updateChecklist(chunk.source, chunk.status); // "reading docs…"
}
}And renders a live checklist as the tool works, instead of a blank screen for however long reading twelve pages takes.
Mark high-frequency progress chunks as transient — writer.custom({ type, data, transient: true }) — and Mastra streams them to the UI without persisting
them to the thread history. You get the live feedback without bloating the saved
conversation with a hundred "reading page 7 of 12" lines.
A custom event on your own stream is one thing. But most teams aren't
rendering fullStream chunks by hand in a for await loop — they're using a
React chat component, and that component expects its own shape entirely.
Handing the stream to the AI SDK UI
Most React chat UIs in this ecosystem are built on Vercel's AI SDK and its
useChat hook, and useChat expects a very specific message-stream shape —
not quite the one Mastra produces natively. Rather than have you
hand-translate every chunk type, Mastra ships an adapter:
import { toAISdkV5Stream } from "@mastra/ai-sdk";
import { mastra } from "@/mastra";
export async function POST(req: Request) {
const { messages } = await req.json();
const agent = mastra.getAgent("assistant");
const stream = await agent.stream(messages);
// Convert Mastra's stream into the shape the AI SDK UI hooks expect.
return toAISdkV5Stream(stream, { from: "agent" });
}On the client, useChat just consumes that response and re-renders as
chunks land. You write zero streaming plumbing yourself. And because the
adapter maps Mastra's chunk types onto the AI SDK's message parts, the
agent's tool calls show up in the message list right alongside its text —
not as some separate debug panel.
That covers text and tool activity. There's still one shape of output streaming handles differently: an actual typed object, filling in live.
Structured output, streamed
One more view worth knowing, and it's the one that makes structured extraction feel less like a black box. If you ask the agent for a typed object, you can stream it as it fills in — useful when what you're rendering is a card or a form, not a paragraph of prose.
import { z } from "zod";
const stream = await agent.stream("Extract the flight details from this email.", {
structuredOutput: {
schema: z.object({
airline: z.string(),
flightNumber: z.string(),
departsAt: z.string(),
}),
},
});
for await (const partial of stream.objectStream) {
// partial is a Partial<> of your schema, growing with each chunk:
// { airline: "TAP" }
// { airline: "TAP", flightNumber: "TP123" }
// { airline: "TAP", flightNumber: "TP123", departsAt: "2026-07-10T08:15" }
render(partial);
}
const final = await stream.object; // fully typed, validated objectThe card populates field by field as the model produces each one. Once it's
done, stream.object gives you back the complete, schema-validated value —
the same object you'd have gotten from .object on any other run, just with
a live preview along the way.
What changed, and what didn't
Nothing about the agent changed anywhere in this part. Same instructions, same tools, same memory from Part 1. All that changed is how the output leaves the building — and now that you've seen each view on its own, here's the full set laid out together:
textStreamfor a plain typing effect,fullStreamto render tool activity as it happens,- the writer to inject your own progress events from inside a tool,
toAISdkV5Streamto plug straight into an AI SDK UI,objectStreamto stream structured output into a form.
Streaming is what makes an agent feel alive. But feeling alive isn't the same as being useful — a fast, chatty agent that answers from nothing is still guessing. Next I give it something real to talk about: Part 5: RAG, where the agent retrieves your actual documents before it answers.