Topic 14.1
What Changes with Agents
In one line
An agent answers one request with many steps: model calls, tool calls and decisions, over seconds or minutes. The screen must show a plan, the current step and what each step did, say plainly when a step fails or the loop is cut off at its limit, and earn trust by showing real actions instead of vague words.
Think of it like this
Asking a builder to fix a leaking tap. A good one does not vanish for two hours and then hand you a bill. They say what they will do ("check the pipe, replace the washer, test the pressure"), call out each stage, show you the old washer, and tell you at once if the pipe turns out to be rusted. An agent needs the same habits on screen: say the plan, show each step, and report problems when they happen.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Agent
- A model plus tools in a loop: it calls tools, reads the results and keeps going until the job is done or a limit is reached.
- Step
- One turn of the loop: one model call and the tool calls that came from it.
- ToolLoopAgent
- The AI SDK class that runs the agent loop. It has
generate()andstream(). - stopWhen
- The condition that ends the loop, such as
stepCountIs(5). For aToolLoopAgentthe default is 20 steps. - Step limit
- The number of steps after which the loop is cut off, even if the model wants to continue.
- step-start part
- A marker in the message's parts that a new step began. Use it to group tool parts into steps.
- Data part
- A message part whose type starts with
data-. It carries your own JSON, such as a plan. With anid, a later part with the sameidreplaces it. - Plan
- The list of steps the agent intends to take, sent by the server so the screen can show progress.
- Finish reason
- Why the model stopped:
stop(it finished) ortool-calls(it wanted another tool when the loop was cut off).
Step by step
01The loop, drawn
One request starts the loop. The model is called, the tools it asked for run, and the results go back to the model. The loop ends on a normal answer, or when a stop condition fires. Everything the user sees is drawn from what each lap produces.
02What the agent sends: parts in order
The first live example runs a three-tool agent and prints what arrives. Look at the final message: step-start appears before each step, the tool parts sit between them, and the last step is the text the user reads.
Also look at the plan. The server wrote data-plan four times, and the message holds exactly one data-plan part. The same id made each write replace the last. The browser never has to merge anything.
03The server builds the plan, the model does the work
The plan list lives in your code, next to the tools. Each tool, when it starts or ends, tells the plan to update. The model does not decide the progress bar. That keeps the labels accurate and in your wording, and it means a model that skips a step leaves that step visibly "waiting".
const publish = (current) =>
writer.write({
type: "data-plan",
id: "plan", // same id: the client replaces the old plan part
data: PLAN.map((s) => ({ ...s, state: done.has(s.id) ? "done" : s.id === current ? "running" : "waiting" })),
});04Two bad endings
The second live example runs two agents. In one, the refund tool throws "Payment provider timed out". The tool part ends in output-error, the model writes an apology, and the finish reason is stop. In the other, the model keeps calling a tool and the limit is 3. The message ends on a tool part and the finish reason is tool-calls.
Your screen needs a different sentence for each. "Step 2 failed: Payment provider timed out. Step 1 is kept." and "Stopped at the step limit (3 steps)." Both are far better than a spinner that quietly stops.
05Hiding the error text by default
By default the SDK replaces error messages with "An error occurred." so a server secret never reaches the browser. The onError option of createAgentUIStreamResponse returns the text you are willing to send. Return your own safe sentence there, never the raw error from a provider or a database.
06Cost and time
Five steps are not five times one answer: each step resends the growing conversation, so the cost rises faster than the step count. Tell the user when a run is long, give them a Stop button (Topic 14.3), and set a step limit and a token budget on the server (Phase 13).
Try it yourself
- 1
Break the plan's id
In the first example, change
id: "plan"toid: "plan-" + done.size(a different id for each update makes every write a new part). Run it and read the last console line.The message now holds four
data-planparts instead of one, and the page, which shows only the latest, still looks right. That is why a wrong id is a quiet bug: the screen works, the message grows, and a saved chat carries four plans. - 2
Raise and lower the limit
In the second example, change the
loopinglimit from 3 to 5, then to 1. Read the sentence for each.The count in "after N steps" follows the limit exactly (5 gives "after 5 steps", 1 gives "after 1 step"), because the loop is cut off after that many steps. The sentence picks "step" or "steps" from the count, so 1 reads correctly.
- 3
Say less about the error
Replace
onError: (error) => error.messagewithonError: () => "Something went wrong."in the second example.The step still shows as failed, but the reason becomes your sentence. Decide per error what a user may see.
Code & diagrams
The scripted visitor presses the button. The server owns the plan and the model does the work. The plan part keeps one id, so the message ends with one plan, not four.
Preview
Console
Nothing logged yet.Expected console output
plan: lookup=running window=waiting quote=waiting
plan: lookup=done window=running quote=waiting
plan: lookup=done window=done quote=running
plan: lookup=done window=done quote=done
parts: data-plan, step-start, text, tool-lookupOrder, step-start, tool-checkReturnWindow, step-start, tool-quoteRefund, step-start, text
plan parts in the message: 1Two agents, two different endings. The first click runs the failing agent. The second click, 800 ms later, runs the one that loops until its limit of 3 steps.
Preview
Console
Nothing logged yet.Expected console output
failing: Step 2 (refund) failed: Payment provider timed out. The steps before it are kept.
looping: Stopped at the step limit after 3 steps. Nothing more was done.// app/api/agent/route.ts (Next.js route handler: runs on YOUR server, where the key lives)
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai";
import { lookupOrder, checkReturnWindow, quoteRefund } from "@/lib/mitra-tools";
const agent = new ToolLoopAgent({
model: "provider/model-name", // a gateway model id, or a provider package's model object
instructions: "You are Mitra, the help assistant of Chai & Co. Use the tools. Never invent order data.",
tools: { lookupOrder, checkReturnWindow, quoteRefund },
stopWhen: stepCountIs(8), // the default for a ToolLoopAgent is 20: choose your own
});
export async function POST(req: Request) {
const { messages } = await req.json();
return createAgentUIStreamResponse({
agent,
uiMessages: messages,
onError: () => "Something went wrong while running a step.", // never return the raw error
messageMetadata: ({ part }) => (part.type === "finish" ? { finishReason: part.finishReason } : undefined),
});
}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 out onError and lose the reason
In the second example, delete the line onError: (error) => error.message, and run it again.
Myth vs fact
Myth
An agent is a smarter model.
Fact
An agent is ordinary code: a loop that calls a model, runs the tools it asks for and calls the model again. The model may be the same one you already use. What changes is the number of calls, the time and the side effects.
Myth
The step limit protects me from cost, so any number is fine.
Fact
Each step resends the whole conversation, so a 20-step run can cost far more than 20 single answers. Pick the smallest limit that finishes real jobs and add a budget on the server (Phase 13).
Myth
I can read progress from the model's text.
Fact
Models skip steps and reword them. Send the plan and the progress from your own code as a data part, and treat the model's text as the explanation, not the progress bar.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
step-startparts are how you group a message into steps. A tool part belongs to thestep-startbefore it, soparts.slice(...)between markers gives one step. Do the grouping once, in a small function, and test it with the three shapes: no steps yet, a step in progress, a finished run. - ▸
A data part with a
transient: trueflag reachesonDatabut is not stored in the message. Use that for things that are true only now (a heartbeat, an elapsed-seconds counter). Plain data parts become part of the saved chat, so a plan you want to see again after a reload should not be transient. - ▸
Server-side hooks such as
onStepEndoncreateAgentUIStreamResponserun after each step. They are the place to write a step to your database or your log, so that a crash in the middle still leaves a record of what was done.
Remember this
- 1
The problem this solves: Until now a model call was one question and one answer, seconds long. An agent turns one request into many steps over many seconds or minutes, and some steps change things in the real world, like sending an email or moving money. A chat bubble with a spinner is the wrong screen for that. The user cannot tell whether it is working, stuck, or about to do something they did not want. This topic shows what to put on screen instead, and what the SDK gives you to draw it from.
- 2
What an agent is, in code. An agent is a loop around a model and some tools. Each turn of the loop is a step: the model is called once, and if it asks for tools, the tools run and their results are added to the conversation. Then the model is called again with those results, as a new step. The loop ends when the model answers without asking for a tool, when a tool with no
executefunction is called, when a tool call needs approval, or when a stop condition says so. The SDK packages this as theToolLoopAgentclass. - 3
Stop conditions are your safety net. A model can loop forever (look up the order, look it up again, look it up again).
stopWhentakes a condition:stepCountIs(n)stops afternsteps,hasToolCall("done")stops when the model calls a tool you named, and a function of your own can read the steps so far. The default for aToolLoopAgentisstepCountIs(20). The default for a plainstreamTextcall is just one step. Always choose a limit on purpose, because every step costs tokens and the whole conversation is sent again each time (Topic 13.1). - 4
What reaches the browser.
createAgentUIStreamResponse({ agent, uiMessages })runs the agent and streams the same kind of UI message stream you already know. One assistant message grows over the whole run. Itspartslist gets astep-startpart at the start of every step, thentextparts, then one part per tool call namedtool-<toolName>. Each tool part moves through states:input-available(the model asked),approval-requested(waiting for the user, Phase 8), thenoutput-available,output-errororoutput-denied. You can draw a whole run frompartsalone. - 5
A plan is data you send on purpose. The model's own words are a poor progress bar: it may skip a step or reword it. So the server writes a data part: a message part with a type that starts with
data-, such asdata-plan. If you give the part anid, a later part with the sameidreplaces it instead of adding a second one. The server sends the plan once with every step "waiting", then re-sends it with one step "running" and finished steps "done". The client always shows the latest version. Data parts also reachuseChat'sonDatacallback as they arrive. - 6
Three rules for trust. (1) Say what is about to happen, in your words. Build the label "Check the 7-day return window for ORD-1042" from your own data, not from text the model wrote. (2) Separate reading from changing. Looking up an order is harmless; refunding it is not. Mark the two differently and never let a changing step run without the user's approval (Phase 8). (3) Always leave a record. When the run ends, the user should see what was done, in order, including the steps that failed.
- 7
Two ways a run ends badly, and both need words. A tool can fail: the tool part ends in
output-errorwith anerrorText, and the model decides what to do next. You must show which step failed and what it means for the rest. Or the loop can hit its step limit while the model still wants more tools. Then the message ends with tool parts and no closing sentence, and the finish reason istool-callsinstead ofstop. Send the finish reason to the client withmessageMetadata, and say "Stopped at the step limit. The last step finished, and nothing more was done." Silence here looks like a crash.
Explain it without notes
What is the difference between a single tool call (Phase 8) and an agent run?
How does the browser tell where one step ends and the next begins in a UI message?
Why send the plan as a data part from the server instead of reading progress from the model's text?
A run ends with the finish reason tool-calls. What happened, and what should the user see?
Practice
Write a function groupSteps(parts) that turns a message's parts into an array of steps, where each step is { tools: [...], text: "..." }, split at step-start parts. Test it with these parts: [step-start, text "Hi", tool-a, step-start, text "Bye"].
A support agent can call searchOrders (read) and cancelOrder (change). Write the rule that decides which steps get a "This changes your order" badge and which wait for approval, and say where it should live: in the browser or on the server.
Trade-offs
- ↔
A plan shown up front builds trust and gives a progress bar, but it can be wrong: the agent may change course. Show the plan as "what Mitra intends" and let the step list record what really happened.
- ↔
Showing every step is honest but noisy for simple jobs. Show steps as a compact timeline that expands for details (Topic 14.5 covers collapsing).
Done when you can
I can explain an agent as a loop of steps and name three ways the loop ends.
I can group a UI message's parts into steps and show each tool's state.
I can send a plan as a data part with a stable
idand show progress from it.I can tell a failed step from a loop that hit its step limit, and write a sentence for each.
I can choose a step limit on purpose and explain why cost grows faster than the step count.