Vercel AI SDK 6: An Agent Is Just a while Loop Vercel AI SDK 6 introduces an agent framework where an agent is essentially a while loop that repeatedly calls a model and runs tools until a stopping condition is met. A developer demonstrated that without the `stopWhen` option, `generateText` only makes one model call and returns an empty string, but with `stopWhen`, it loops until the model produces a final answer. The SDK abstracts vendor-specific LLM APIs behind a unified interface, and the agent behavior is controlled by a simple loop rather than complex logic. I wired the same model and the same single tool into two functions in the exact same way. One of them ran the tool once and then gave back no answer at all ; the other called that same model twenty times over. The only thing that changed in the code was one line — whether or not I flipped on an option called stopWhen . That was the scene I ran into the first time I actually installed Vercel AI SDK 6 and ran something on top of ai@6.0.212 . These days the library leads with the banner of an "agent framework." Yet what that word "agent" points to at the level of real code turns out to be surprisingly fuzzy. So this post strips away the marketing copy and takes apart, by hand, what the AI SDK 6 agent actually is as a control flow — with no model API key, no GPU, and not a single external call. NOTE Every number and behavior in this post was verified by running it myself against amock modelon Windows 11 · Node v24.15.0 · ai@6.0.212 · zod@4.4.3 . Because no real LLM is ever called, the results reproduce identically every time. Why the "mock model" is the crux is something I explain separately halfway through. Let me lay the groundwork before the main event, so that even a first-time reader can follow all the way through. In web development, when we send an HTTP request to a server, we don't hand-write the low-level, browser-specific code ourselves. An abstraction layer like fetch or axios papers over the differences. The AI SDK is fetch for calling LLMs large language models . It wraps the vendor-by-vendor APIs — OpenAI, Anthropic, Google — behind a single function like generateText , so you can swap models like changing a plug in a socket while your app code stays put. Think of it as an adapter that turns every vendor's differently shaped plug into one power strip.As of 2026, AI SDK 6 has moved up to a general availability GA release https://vercel.com/blog/ai-sdk-6 not a beta , and the ai@6.0.x package is still being updated as of late June. Four things stand out as the headline changes from 5 to 6. ToolLoopAgent Let me unpack just two terms first. A tool call is the model asking, "instead of answering directly, please call the getWeather 'Seoul' function for me." The model can't run functions itself, so our code does the running and hands the result back to the model. An agent is exactly this round trip — model calls a tool → we run it and return the result → the model looks at that and decides what's next — repeated until the job is done. It sounds grand, but as we'll see shortly, the reality of it is one ordinary loop. The first thing I wanted to confirm was this: if you hand tools to generateText , does that by itself make it an agent? I scripted the mock model to move along like this. On step 1 it returns a tool call saying "call the weather tool with {city:'Seoul'} ," and on step 2 it returns the final text "Seoul is 21C." Then I called that same model two different ways. First, plainly, with no options — generateText { model, tools, prompt } : | Observation | Value | |---|---| | Step count | 1 | Model calls doGenerate | 1 | | Final text | "" empty string | | Did the tool run? | Yes — {city:'Seoul', tempC:21} | The tool clearly ran. And yet the final answer is empty. Why? Because the model called the tool once and stopped right there. The tool's result never went back to the model, and the model never got a second turn. By default, generateText is a one-shot function that ends the moment a tool is called once. The automatic round trip we casually call "an agent" simply isn't here. Now I added a single line — stopWhen: stepCountIs 5 : | Observation | Value | |---|---| | Step count | 2 | | Model calls | 2 | | Final text | "Seoul is 21C." | Same model, same tool — but this time the model was called twice. When the first call requested the tool, the SDK ran it, appended the result to the conversation, and called the model again ; on that second call the model finally produced its closing sentence. That is exactly the contradictory scene from the opening. stopWhen , true to its name, is the condition that decides when to stop. stepCountIs 5 means "run for at most 5 steps," hasToolCall 'weather' means "stop the moment that tool is called," and you can supply your own function for a custom condition too. Sure enough, when I set hasToolCall 'weather' , it stopped at exactly step 1, right after the tool was called. So in AI SDK 6, what an "agent" really is is a while loop that keeps calling the model until stopWhen says stop. Not magic — a loop with a termination condition, and the switch that turns that termination condition on is stopWhen . Diagram:this section is illustrated with a flowchart — view it in the original article on var.gg . This is where a class called ToolLoopAgent https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent comes in. Its official name is ToolLoopAgent the older beta's Experimental Agent remains as an alias pointing at the same class — in my install, ToolLoopAgent === Experimental Agent was true , and what it does is bundle the model, tools, and stopWhen into one object: a generateText .There is one decisive difference, though. Even if you don't give ToolLoopAgent a stopWhen, it ships with stepCountIs 20 as the default. I fed a mock model that endlessly re-requests the tool into both approaches. | Call style | Step count with no stopWhen | |---|---| generateText | 1 no loop | ToolLoopAgent | 20 stops at the default cap | Same model, but one runs once and the other twenty times. That number 20 is the whole identity of "the agent." generateText starts out with its loop switched off , and ToolLoopAgent starts out with its loop switched on at 20 steps. That is the entirety of the essential difference between the two APIs. WARNING So here's the first real-world trap. If the model, for whatever reason, never stops calling tools, ToolLoopAgent willquietly call the model 20 times — meaning you're billed 20 times.It saves you from an infinite loop, but that ceiling is also your cost ceiling. stopWhen is both a safety catch and a budget dial. Tools don't always succeed cleanly. So I broke one on purpose. I built a tool whose execute immediately does throw new Error 'TOOL BOOM' and dropped it into the loop. My expectation was "the loop blows up on the exception and dies." Wrong. The loop did not throw. Instead it went like this: "tool-call", "tool-error" , tool-error was The same held when the model called a tool that doesn't even exist. Instead of a crash, it surfaced as a tool-error and the loop carried on. In other words, AI SDK 6 is designed to catch tool-execution failures by default and, rather than throwing an exception, tell the model "this tool failed in this way." It's a self-heal structure that leaves the model room to route around the failure or pick a different tool on its own. This isn't all upside. Because the error is quietly absorbed into the conversation, those of us outside the code may only notice later — "the tool failed, so why did I just pay more?" This automatic feedback is the default behavior , and using it while knowing you can change it via tool options or an error handler is different from using it unaware. It rhymes with cooperative cancellation in general: when you abort a request midway, what actually stops and what keeps spinning on as "orphan work" always comes down to someone's cooperation — control is never something you hold unilaterally. The next thing I looked at was structured output. A model, left to itself, spits out free text. But an app often needs an object of a fixed shape like { city: string, tempC: number } . For this, the AI SDK provides — separate from the free-text generateText — a path that forces the result to conform to a schema. The dedicated function generateObject shows the boundary most sharply, so I experimented with it, but one thing is worth flagging up front: in v6, generateObject is already marked @deprecated. The installed type definitions themselves steer you to "use the output setting on generateText instead." For new code, generateText { output: Output.object { schema } } is the recommended path though this result still comes out under the name experimental output — i.e., the surface isn't fully set yet . Either way, generateObject , where that boundary is crispest.I gave it the zod a runtime schema-validation library schema z.object { city: z.string , tempC: z.number } and ran three cases. | What the model emitted | generateObject result | |---|---| {"city":"Seoul","tempC":21} | Valid — typed object. At runtime object.tempC 2 = 42 works | {"city":"Seoul","tempC":"hot"} type violation | AI NoObjectGeneratedError thrown | totally not json | AI NoObjectGeneratedError thrown | Let me clear up a common misconception here. "Give it a TypeScript type and the model will honor that type" is a half-truth. The type is a compile-time promise , and whether the model actually kept that promise is checked by zod at runtime. They're different layers. The compiler guarantees the type, but the model can break the promise at runtime however it likes, and when it does the SDK throws an exception rather than quietly forcing a fit. The type layer itself is solid. Checking with a separate type pass tsc --strict , generateText 's result .text is string no structure at all , while generateObject { schema } 's .object.tempC was a number inferred from the schema. Trying to assign a string where a number belongs produced a compile error. What the compiler catches is "does my code use the object correctly," and what zod catches is "did the model produce a correct object." Mistake the two for the same thing and you'll get the experience where the types pass but production blows up with a NoObjectGeneratedError . When you stream a response with streamText , characters trickle onto the screen — but what's happening underneath? With a mock model I built a stream mixing text fragments and tool calls, and transcribed the order of the pieces that land on fullStream exactly as they came. start → start-step → text-start → text-delta → text-end → tool-input-start → tool-input-delta → tool-input-end → tool-call → tool-result → finish-step → finish Two things matter here. First, a stream is not a blob of characters but a sequence of typed events — text-start, text-delta, text-end; tool-input start, delta, end — each stage split into its own part, so the UI can render exactly whether "the model is calling a tool right now" or "is writing text." Second, tool-result shows up in the middle of the stream , right after tool-call . That means the SDK doesn't wait for streaming to finish before running the tool; it runs the tool mid-flow and splices the result back into the stream. One note for anyone considering a migration. In AI SDK 5, the option that turned on the multi-step loop was maxSteps . In v6 this option was removed. The problem is that it disappears without making any noise. I passed generateText { ..., maxSteps: 5 } through just like v5 code. Type checking did flag that maxSteps is no longer a valid option confirmed with @ts-expect-error , but at runtime it was silently ignored — step count 1, one model call, final text an empty string. In other words, an agent loop that ran fine on maxSteps in v5 will, in any environment that skips type checking pure JS, a loose build , degrade into a one-shot function without a single line of error the moment you bump to v6, and the answer goes empty. In v6 you have to turn the loop on with stopWhen . NOTE To sum up, there are two ways to turn on multi-step in v6 — the functional way gives stopWhen directly to generateText / streamText , and the object way uses ToolLoopAgent and decides whether to keep or lower the default stepCountIs 20 . The v5 maxSteps and system parameters were renamed to stopWhen and instructions , respectively. After running it myself, the most honest thing I can say is that AI SDK 6's boundary between what it gives you and what it doesn't is sharper than you'd expect. What it gives mock model I'll cover separately below, you can reproduce the entire control flow deterministically, no model required. What it doesn't give stopWhen .This last item matters. Every conclusion in this post is the "contract and control flow" seen on top of a mock model — it does not reproduce a real LLM's caprice nonsensical tool arguments, an unquenchable appetite for calling tools . The mock model clearly shows one half of the truth — how the SDK promised to behave — and the other half how the model breaks that promise only surfaces with a real provider. A mock model is a model implementation whose script we write in ourselves, in place of a real LLM. AI SDK 6 officially ships MockLanguageModelV3 in ai/test . Because you can nail down in code "return this tool call as the response, then return this text next," you can reproduce the agent loop, the stop condition, error handling, and streaming order in full — with no API key, no cost, and no network. This is exactly why this post could fill in every table above while calling the model zero times. Plenty of "build a chatbot with the AI SDK" posts are out there, but ones that tackle "how to test AI features deterministically, at zero cost" head-on are rare. From a product-code standpoint the latter is actually the more urgent need, because you can't validate the loop and failure paths of an agent bound for production against a real model that burns money and wobbles on every run. Set this next to how the Python world pins an agent's inputs and outputs down with types to solve the same problem, and the contrast gets sharp. The AI SDK isn't the only road to building an agent. Here's how it shakes out, based on hands-on experience and each tool's official positioning. | Option | Abstraction level | Type safety | Testability | Lock-in risk | |---|---|---|---|---| AI SDK 6 | Medium — provider abstraction + loop | High TS + zod | High official mock model | Low multi-provider | LangChain.js | High — up to chains/graphs | Medium | Medium | Medium heavy ecosystem dependence | Mastra | High — workflow-centric | High TS-first | Medium | Medium | OpenAI Agents SDK JS | Medium — agent-specialized | Medium | Medium | High OpenAI-flavored | Raw fetch | None | By hand | By hand | None | The point isn't "which is best." AI SDK 6's place is the spot that keeps the abstraction thin only as far as the loop and the provider while keeping types and testing solid. If you need higher-level orchestration complex branching, long-running workflows , LangChain or Mastra may fit better; if you're fine binding deeply to one provider, that vendor's SDK might. The AI SDK's strength isn't a flashy graph — it's that, just as we did a moment ago, you can inspect every corner of the loop without a model. This tool shines brightest when you want to add "an AI feature with tool calls, in a way that's less bound to a provider and solid on types and testing." Conversely, if complex long-running workflow orchestration is the essence of what you're building, look to a heavier framework. After taking it apart myself, the sentences that stick are just a few. while loop that stopWhen turns on. generateText starts with the loop off, ToolLoopAgent with it on at a default of 20 steps. stopWhen is both a safety catch and a budget dial. generateObject answers a promise-breaking model with an exception. maxSteps is gone in v6. stopWhen .In an era where "agents write the code," using an agent without knowing what loop it actually is on the inside is precarious. I reached the same conclusion writing about git tooling in the agent era https://var.gg/en/blog/git-tools-in-the-agent-era — the smarter an abstraction gets, the more you owe it to yourself to run the thing underneath at least once and see what actually turns. The AI SDK 6 "agent," fortunately, was open enough to inspect all the way through without calling a model even once. Originally published at var.gg.