I spent last Tuesday reading my own agent code and feeling mildly embarrassed. About four hundred lines existed to do exactly one thing: call the model, check whether it asked for a tool, run the tool, feed the result back, loop until it stopped asking. I had written that loop three separate times across two projects. Each copy had its own subtly different bug around what happens when a tool throws halfway through a multi-step run.
Then I upgraded to AI SDK 7 and deleted most of it.
Short version for the impatient: if you’re still hand-rolling the tool-call loop, the ToolLoopAgent abstraction replaces your code, and the codemod does most of the work. But the loop isn’t the interesting part. Approvals and durability are, because those are the two things that kept biting me in production and that almost no tutorial covers.
The release cadence is a lot, and I’m allowed to say so
Let me get the grumbling out of the way. AI SDK 5 landed on 31 July 2025. Version 6 shipped 7 May 2026 with the Agent abstraction. Version 7 arrived on 25 June 2026, seven weeks later. That’s three major versions in under a year, each with real breaking changes to how messages and tools are shaped.
I don’t think this is unreasonable for a library tracking a moving target, and Vercel ships codemods with every major, which is more than most maintainers bother with. But I’ve watched people burn a sprint on a v5 to v6 migration and then discover v7 was out before their PR merged. If your team has a slow review cycle, pin your version and batch the upgrades. Chasing every major here is a genuine waste of your week.
The counter-argument, which I think is right: the v7 changes are mostly additive on top of v6’s Agent abstraction. If you already migrated to v6, v7 is a small step. If you’re on v5, you have a real project ahead of you.
The loop I finally deleted
Here’s the shape of what I used to write. This is condensed, but the structure is honest:
// The loop I wrote three times and never got quite right
let messages = [{ role: 'user', content: prompt }];
while (true) {
const result = await generateText({ model, messages, tools });
messages.push(...result.response.messages);
if (!result.toolCalls?.length) return result.text;
for (const call of result.toolCalls) {
try {
const output = await runTool(call);
messages.push(toolResultMessage(call, output));
} catch (err) {
// every bug I shipped lived in this branch
messages.push(toolErrorMessage(call, err));
}
}
}
The bugs were never in the happy path. They were in step budgets, in what happens when the model calls two tools and one fails, and in my own inconsistent decisions about whether a tool error should end the run or get handed back to the model.
The replacement:
import { ToolLoopAgent } from 'ai';
const agent = new ToolLoopAgent({
model,
tools: { weather: weatherTool },
});
const result = await agent.generate({
prompt: 'What should I wear in Dubai today?',
});
That’s the whole thing. The agent is a value you define once and reuse, so the same definition backs your API route, your test suite, and your CLI. I moved three endpoints onto this and my agent test file got shorter by about half.
One thing that surprised me: tools can now declare their own context schema, so an API key goes to the tool that needs it instead of living in a closure that every tool can see.
const agent = new ToolLoopAgent({
model,
tools: {
weather: tool({
description,
inputSchema,
contextSchema: z.object({ apiKey: z.string() }),
execute: async (input, { context: { apiKey } }) => {
// ...
},
}),
},
toolsContext: {
weather: { apiKey: process.env.WEATHER_API_KEY! },
},
});
That sounds like a small ergonomic win until you start pulling in tools written by other people. I have two from a vendor SDK, and I’d rather they never see my Stripe key. Before this I was passing a config object around and trusting everyone to behave.
Tool approvals are the feature I actually needed
This is the one that made me upgrade rather than wait.
I have an internal agent that can send emails. For months, my “safety” mechanism was a boolean flag I set in the environment and a code comment promising myself I’d build a real approval flow later. Version 7 supports approvals at the agent level:
const agent = new ToolLoopAgent({
model,
tools: { weather: weatherTool, sendEmail: sendEmailTool },
toolApproval: {
sendEmail: 'user-approval',
},
});
You get a simple user-approval for specific tools, a per-tool function that can auto-approve, auto-deny, or push the decision to a human, and a catch-all function for anything you didn’t name. For riskier work there’s opt-in HMAC-signed approval, which matters more than it sounds. Without signing, an approval is just a message in your stream, and a message in your stream can be forged by anything that can reach your endpoint. The SDK also revalidates tool inputs before resuming, so a replayed approval can’t quietly swap in different arguments. The tool approvals documentation covers the escalation shapes properly.
If you’ve read my post on the Claude Code hooks I actually ship, this is the same instinct applied one layer down. Guardrails belong where the dangerous call happens, not in the prompt asking the model to please be careful.
Durability is the part nobody demos
Here’s the failure I hit in March and could not fix cleanly. An agent run waits on a human approval. The human takes eleven minutes. In minute four, I deploy. The process dies, the run dies, the human clicks approve on a request that no longer exists.
Version 7 introduces @ai-sdk/workflow and WorkflowAgent for runs that survive restarts, deploys, and delayed approvals. It supports streaming, tools, approvals, callbacks, and typed runtime context across step boundaries, which means the agent’s state isn’t sitting in a variable in a process you’re about to kill.
I’ve had this in production for two weeks on one workflow, so treat my endorsement as provisional. It has survived four deploys mid-run, which is four more than my previous setup managed. The WorkflowAgent guide is worth reading before you wire it up, because the durability story only works if your tools are idempotent, and mine were not.
Timeouts, because agents stall in specific ways
A plain HTTP request either returns or times out. An agent has more ways to hang: the provider opens a stream and stops sending chunks, one tool blocks on a slow API, or a ten-step run quietly eats your entire request budget. Version 7 lets you set limits at each of those levels:
const result = await generateText({
model,
tools: { weather: weatherTool, slowApi: slowApiTool },
timeout: {
totalMs: 60000, // whole run
stepMs: 10000, // any single step
chunkMs: 2000, // abort if the stream goes quiet
toolMs: 5000, // default per tool
tools: {
slowApiMs: 10000,
},
},
prompt: 'What is the weather in San Francisco?',
});
chunkMs is the one I’d been faking with a hand-rolled Promise.race, badly. Timeout aborts surface as TimeoutError and the abort reason propagates through the stream and UI protocols, so your frontend can tell “the model stopped talking” apart from “the tool died”.
Telemetry stopped being per-call busywork
Minor, but it removed a chore I’d been avoiding. Previously I wired lifecycle callbacks into every generateText and streamText call, which meant my instrumentation was only as good as my memory. Now you register once at startup:
import { registerTelemetry, generateText } from 'ai';
import { OpenTelemetry } from '@ai-sdk/otel';
registerTelemetry(new OpenTelemetry());
Traces cover the root generation, each model call, individual steps, tool executions, and token usage. There’s also a node:diagnostics_channel route if you’d rather subscribe to raw events than adopt OpenTelemetry. I use Langfuse and the step-level tool timings were the first thing that showed me one tool was eating 60% of my run duration.
What I’d actually do this week
Pick one agent endpoint. Run npx @ai-sdk/codemod v7 on it, or if you’re coming from v5, budget real time and go through v6 first. Then add toolApproval to whichever tool in that endpoint would ruin your day if the model called it with bad arguments. Twenty minutes, and it’s the most useful thing in the release.
Leave the experimental surface alone for now. Realtime voice, video generation, and the harness integrations all ship behind experimental_ prefixes, and that prefix is doing honest work. Fun to read about. Not something I’d put in front of users this quarter.
And skip the whole thing if your app is a chat endpoint with no tools. You’d be migrating for features you don’t use. I do a fair amount of this backend plumbing for clients, and you can see some of that work here. The thing I keep running into is teams reaching for an agent framework before they have a single tool worth guarding.