Skip to content
June 2, 2026Article

AI SDK Advanced Concepts: Production Controls

The advanced AI SDK layer that keeps a chat feature alive in production. Agents, stop conditions, call options, caching, rate limits, cancellation, backpressure, and DevTools.

Back to Blog

When I first started building with the AI SDK, I lived entirely in the first layer. The part everyone sees. You generate text, you stream text, you render chat messages, you call a tool, you return structured output. It feels like you have shipped something real.

And then real users show up. They send the same expensive prompt forty times. They hammer the endpoint. They stop a generation halfway through and walk away while your server keeps paying for tokens. That is the point where it clicked for me that there is a second layer underneath, and it is the one that actually keeps the feature usable.

I think about it as two questions the system has to answer on every request.

  • Should this call run at all, and what path should it take?
  • Once it starts streaming, how do we keep it observable, cancellable, and stable?

The first question is a decision. The second one is control. So that is how I will frame the whole post: a decision layer and a control layer, sitting under the part the user sees.

TipAI SDK 7 readiness

This walkthrough follows AI SDK 7 and the production shape I use day to day. Before you paste any of this into a new app, compare the agent, stop condition, call options, middleware, stream abort, and DevTools APIs against the AI SDK 7 introduction (opens in new tab) and the current reference docs. Keep the architecture. Adjust names or signatures if the latest docs have moved on.

1. The difference the user actually feels

Start with the thing your user can see, because that is where everything else hangs off.

generateText waits until the full answer is ready, then hands it over in one piece.

streamText starts updating the interface while the answer is still being produced.

That is the whole product difference in two lines. Generate waits. Stream updates the app as the work happens. Everything in this post is about keeping that same experience under control: deciding when a call should run at all, what path it should take, and what happens while the stream is still moving.

2. When is an agent actually worth it?

This is a question I had to answer for myself early on, because the word agent gets thrown around a lot.

streamText is a primitive. You can absolutely build a tool loop with it. But if you do, you own that loop. You write the part that reads the tool call, runs the tool, feeds the result back, and decides whether to go again.

ToolLoopAgent is for the cases where that loop is the product. It can call tools, observe the results, take another step, and keep going until a stop condition is met. So here is the line I keep in my head: streamText is a primitive, ToolLoopAgent is a managed loop.

That does not make it magic. It just moves the iteration into a reusable agent boundary so you are not rewriting the same loop in every route.

3. Give the agent narrow tools

Here is something I had to learn the slightly harder way: an agent is only as safe as the tools you hand it. The loop is only as good as the choices it can make.

So I keep each tool small and obvious. The search tool gathers source material. The analyze tool extracts the signal. The report tool writes the final output. Each one has a description, an input schema, and a single focused execute function. Nothing clever.

My rule of thumb: if I cannot explain a tool in one sentence, it is probably too broad for the first version. Narrow it.

4. Define the agent once, use it everywhere

Now we put it together in one place. The agent holds the model, the instructions, and the tool set as a single unit.

I like this boundary a lot. Once it exists, the rest of the app just calls researchAgent.generate() or researchAgent.stream(). Nobody downstream has to rebuild the tool loop, or remember which tools the agent is allowed to touch. It is defined once.

One small thing I always do: keep the model configurable. That way you can benchmark it, or swap providers, without rewriting the agent itself.

5. Picture the agent as a loop

It helps to actually picture what the agent is doing here. Receive the task. Choose a tool. Use the result. Choose the next action. And eventually produce the final answer.

The word I want you to sit on is eventually. An agent needs the freedom to take multiple steps, because that is the entire point of it. But it also needs a reason to stop. Hold that thought, because the next step is all about giving it one.

6. Run it with generate or stream

The nice part is that the same agent answers both of our product needs.

Use generate() when the user can wait for the finished result. Use stream() when they need feedback while the work is happening. Output can still arrive as text chunks, and structured output can still arrive as partial objects. So you are not choosing the agent based on the UI. You are choosing the call style.

The API shape stays familiar, which is the point. The only real difference now is that the agent owns the loop between model calls, instead of you.

7. Give the loop a reason to stop

This is the reason to stop I promised you. AI SDK agents do ship with a default step cap, but I would not lean on it. In production I think you should choose an explicit stop policy for the workflow, on purpose.

There are a few shapes this takes:

  • A hard step cap, such as isStepCount(8).
  • A completion tool, such as hasToolCall("generateReport").
  • A custom predicate, such as “stop once confidence is high enough.”

And when you pass an array of them, the rule is simple: whichever stop condition fires first wins. You are not ranking them. You are saying any of these is a good enough reason to be done.

8. Stop conditions are also a UI

Here is the bit people miss. Stop conditions are not only backend safety. They are also some of the most honest things you can put in front of a user.

Because once you know the step count and which condition fired, the UI has real things to say. How many steps have run. Which tool finished the job. Whether the agent stopped because it was actually done, or because it hit a safety cap and you cut it off.

And honestly, that is the whole gap between “the agent is thinking” and “the agent is on step three, using the report tool.” One is a spinner. The other is trust.

AI SDK 7 also introduces two related controls for tools: scoped context and agent-level approval. Per-tool contextSchema lets you declare exactly which fields a tool needs from toolsContext, while shared state lives in runtimeContext and is visible across the loop. For dangerous operations, move approval out of the tool definition and into the call's toolApproval setting. That keeps the tool itself small and testable, and puts the gating decision where the request is actually configured.

9. Structure lives in the same flow

When I first hit structured output I assumed it was a separate world with its own rules. It is not. It lives in the same flow as everything else.

The agent can stream text when the user wants readable progress. And it can use Output.object when the product needs a typed result it can actually rely on. Same agent, same loop.

The part I really like is partial structured output. For a dashboard, a report, or a workflow summary, the interface can fill itself in field by field as the model works, instead of sitting empty until the whole object lands.

10. Let the request shape the agent

In a real product, not every user should get the same agent. That sounds obvious, but it is easy to forget when you are testing with one account.

Free users might only get documentation search. Enterprise users might be allowed to create a ticket. An urgent request might deserve a stronger model, or different instructions. Same agent, different behavior per request.

Two pieces make this clean. callOptionsSchema validates the runtime options coming in, so you are not trusting arbitrary input. prepareCall then turns those validated options into the actual agent settings for that run.

11. One path: schema, options, prepareCall

It helps to see those three pieces as a single path rather than three separate features.

The schema defines what is allowed. The options carry the context for this particular request. And prepareCall converts that context into something concrete: instructions, which tools are active, the model choice, provider options, or metadata.

What I like about this is where the personalization ends up living. It sits right next to the agent, in one place, instead of being smeared across every route handler that happens to call it.

12. Reuse the expensive work

Now we move from the decision layer into making things cheaper and faster. Caching asks one blunt question: can we reuse this expensive result instead of paying for it again?

For non-streaming calls, middleware can check a key-value store before it ever calls doGenerate(). On a hit, you return the stored result. On a miss, the call goes to the model and the result is saved with a TTL. The way I say it to myself: a cache means you run the expensive path once.

One honest gotcha. Dates and provider metadata sometimes need a little normalization after they have been serialized and read back. So keep the cache boundary boring and explicit. This is not the place to be clever.

13. See where the cache pays off

It is worth watching what this actually buys you, because the win is real.

The first call pays the full latency and the full token cost. The second identical call comes back almost immediately, with no new model tokens spent. Lower latency, lower cost, more predictable performance. You ran the expensive path once and everyone after that rides on it.

The tradeoff is freshness, and there is no way around that. So I choose TTLs per use case rather than treating every prompt the same. A pricing summary and a live status answer should not share the same expiry.

14. Caching a stream takes more care

Caching a streamed response is trickier, and this is where people get it subtly wrong. The UI still expects chunks, so you cannot just cache one big blob.

The shape that works: pass the live chunks straight through to the user while you also quietly collect them. When the stream finishes, store the array of parts. On the next hit, you replay those parts with simulateReadableStream.

The trap to avoid is collecting the whole stream first and only then sending it on. Do that and you have turned your stream back into a wait. That defeats the entire point.

15. Replay it so it still feels live

The goal here is that a cached stream still feels alive to the user, even though you already know every word of it.

You do know the full content. But replaying it as chunks means it travels the exact same path as a real response: the same rendering, the same progress behavior, the same message-part contract.

And that is what I am really after. The cache stays completely invisible to the React tree. The UI has no idea whether it just hit the model or hit Redis, and it should not have to care.

16. Attach it all at the model boundary

So where does the caching middleware actually go? wrapLanguageModel is the clean place for it, and for anything else cross-cutting.

Caching, tracing, guardrails, logging. They all get easier the moment the route just calls one wrapped model, instead of the route having to remember every middleware step by hand on every call.

The test I use is simple. The route should read like product logic, not like infrastructure plumbing. If it reads like plumbing, the behavior probably belongs at the model boundary instead.

17. Stop the request before it costs you

Rate limiting is the cheapest protection you have, because it acts before the model call ever starts. You are spending nothing yet, and that is exactly when you want to make the call.

Use the authenticated user id as the key when you can. IP limits have their place, but shared networks, VPNs, and mobile carriers make them noisy, and you end up punishing people who share an exit node with a heavy user.

When someone does hit the limit, return a 429 with a retry time attached. That gives the frontend something concrete to turn into a human message instead of a generic error.

18. The limiter decides who gets through

It is worth being clear about what that gate is really doing, because it is more than blocking bad actors.

The limiter decides who gets through. That caps spend per user. It keeps access fair. And it stops a single noisy client, automated or just overenthusiastic, from quietly eating your entire AI budget while everyone else waits.

Which leads to the line I keep coming back to in this whole layer: the cheapest request is the one that never reaches the model.

19. Let people stop a bad answer

Sometimes the model just starts heading in the wrong direction. You can see it in the first line. The user can too.

If all the UI does is wait, that user pays in time and in tokens for output they already know they are going to throw away. So give them an out. useChat hands you stop(), and I keep the button active only while a response is submitted or streaming, so it is never sitting there pretending it can do something.

That handles the visible stream. But to be honest, it is only half the story, and the missing half is where people get caught out.

20. The server has to hear the abort

Here is the half people miss. Stopping the client does not stop the provider. The button went quiet, but the model call is still running on your server, still billing.

So you forward req.signal into streamText, and now the server can actually cancel the model call when the user bails. Then use onAbort to do the cleanup that matters: persist the partial progress, log that it happened, release whatever you were holding. Use onEnd if you need to save the final message history once the stream completes. Stopping the client is not enough unless the server hears it.

One honest tradeoff to flag. Abort behavior and resumable streams do not sit together cleanly. You will likely have to decide which product behavior matters more for your case, rather than getting both for free.

21. Treat the stop as one honest state

So the stop button is not really a button. It is a small flow, and I think it is worth treating it as one product state from end to end, not just a frontend animation that makes the user feel better.

A cancellation that actually holds together has four moments you can see: the user stops the stream, the client stops rendering new chunks, the server receives the abort signal, and the app then saves or discards the partial run on purpose.

Do that and the user gets real feedback, without the app quietly pretending a cancelled response finished normally. Those are two very different things, and users can tell.

22. Backpressure, and why it matters

Backpressure sounds like a scary word, but the idea is calm: it is what stops a fast producer from overwhelming a slower consumer.

Picture chunks arriving faster than the UI can actually deal with them. The buffer fills, memory climbs, and eventually data gets dropped. Pull mode flips that relationship around. Instead of the producer firehosing chunks, the consumer asks for the next one when it is genuinely ready for it.

The good news is that AI SDK streams handle this for you. The bad news is you can quietly undo it. If you eagerly buffer everything in the middle, you have stepped between the producer and the consumer and broken the very thing that was protecting you.

23. Wrap it in DevTools while you build

AI failures are genuinely hard to debug from the final text alone. You get a wrong answer and almost no clue about which step produced it. I have stared at plenty of those.

DevTools wraps your model and records local traces for generateText, streamText, and agent runs. To be clear, this is a development tool, not a production one. Those traces can contain prompts, outputs, and raw provider data, so it is not something you leave switched on in front of real users.

In v7, telemetry graduates to @ai-sdk/otel: once you call registerTelemetry(new OpenTelemetry()), every AI SDK call is traced by default and you opt out per call with telemetry: { isEnabled: false }.

But while you are building? Reach for it the moment you need to see what actually happened, instead of guessing.

24. Read the run, not just the answer

With agents especially, I have come to value the trace more than the final response. The answer tells you what happened. The trace tells you why.

A trace turns agent behavior into something you can actually inspect: the prompt, each step, the tool parameters, the tool outputs, token usage, timing, the raw request and response payloads, and the stop reason that ended the run.

So when an agent goes sideways, and at some point one will, this is where you go. Not to the output, but to the exact step where it quietly drifted off course.

Zooming out

When I lay all of this out, the thing I want you to take away is that it is not a pile of unrelated features you bolt on as you hit problems.

It is one production pipeline, and each piece has a job:

  • Agents decide the next action.
  • Stop conditions bound the loop.
  • Call options personalize the run.
  • Caching reuses work.
  • Rate limits protect the budget.
  • Stop and abort handling prevent wasted output.
  • Backpressure keeps streams stable.
  • DevTools makes the full run inspectable.

If the first crash course was about getting a chat feature working at all, this layer is about keeping that feature calm and controlled once real users start leaning on it. The model is still the part everyone sees. But the system around it is what decides whether the thing survives contact with production. That is the part I find genuinely fun to build, and it is what the next posts will keep digging into.

Further reading

Enjoyed this post?

Get notified when I publish something new. No spam, just fresh content.