Your agent is one bad tool call away from an incident. It can query the database all day, but the moment it can refund, deleteUser, or sendEmail, "mostly autonomous" needs a human in the loop for exactly those actions — and nowhere else. The Vercel AI SDK (v7, GA June 25, 2026) builds that gate in. This is the whole pattern, in code you can paste.
The one idea to hold onto: approval doesn't block your server. The agent runs until it hits a gated tool, then returns — it hands you a request and stops. You resume with a second call once a human has decided. That stateless design is what lets an approval survive a page reload or an overnight wait.
1. Put the gate on the tool#
The gate lives on the tool definition, not in your prompt. That matters: a prompt-based "ask before you refund" is a suggestion the model can rationalize past — the decision of when an agent should stop and ask a human is too important to leave to the model's judgment in the moment. needsApproval is a hard stop the loop enforces.
// package.json must be { "type": "module" } — AI SDK 7 is ESM-only
// deps: ai@7, @ai-sdk/anthropic@4, zod@4
import { tool } from 'ai';
import { z } from 'zod';
const refund = tool({
description: 'Refund a customer for an order',
inputSchema: z.object({ // note: inputSchema, not "parameters"
orderId: z.string(),
amountCents: z.number().int(),
}),
// boolean, OR an async predicate for conditional gating:
needsApproval: async ({ amountCents }) => amountCents > 5000, // approve only over $50
execute: async ({ orderId, amountCents }) => {
// ...call your payments API...
return { refunded: true, orderId, amountCents };
},
});
Small refunds run untouched; anything over $50 pauses for a person. The rule is in one place, testable, and impossible for the model to argue with.
2. Build the agent, make the first call#
import { ToolLoopAgent, isStepCount } from 'ai';
import type { ModelMessage, ToolApprovalResponse } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
const agent = new ToolLoopAgent({
model: anthropic('claude-sonnet-4.5'), // use your provider's current model id
instructions: 'You are a support agent. If a refund is denied, explain why and stop.',
tools: { refund },
stopWhen: isStepCount(20), // default is already isStepCount(20)
});
const messages: ModelMessage[] = [
{ role: 'user', content: 'Refund order A-1007 for $80, it arrived broken.' },
];
let result = await agent.generate({ messages });
messages.push(...result.response.messages);
Because $80 > $50, the loop reaches the refund tool, sees it needs approval, and stops without executing. result comes back now — not in ten minutes when someone clicks — carrying the pending request.
3. Read the approval request, get a human decision#
const approvals: ToolApprovalResponse[] = [];
for (const part of result.content) {
if (part.type === 'tool-approval-request') {
// part.approvalId + part.toolCall describe the pending action
const approved = await askHuman(part.toolCall); // Slack, email, a dashboard — your call
approvals.push({
type: 'tool-approval-response',
approvalId: part.approvalId,
approved,
reason: approved ? 'agent verified damage photo' : 'no proof of damage',
});
}
}
askHuman can take a second or a day. Persist messages and the pending approvalIds, surface them wherever your humans live, and come back when they answer.
4. Resume with a second call#
if (approvals.length) {
messages.push({ role: 'tool', content: approvals }); // one tool message carries all decisions
result = await agent.generate({ messages }); // approved tools now execute
}
console.log(result.text);
On this pass, approved tools run and the model continues to a final answer. Denied tools aren't silently dropped — the model is told they were refused, so it can apologize, ask for more evidence, or route to a human, instead of blindly retrying.
The gate belongs on the tool, not in the prompt. A instruction to "ask first" is advice the model can talk itself out of. needsApproval is a wall the runtime enforces — the difference between a policy and a hope.
The same thing in a chat UI#
If you're rendering with useChat from @ai-sdk/react, you don't hand-roll the two calls. A tool part enters the approval-requested state; you render buttons and answer it:
const { messages, addToolApprovalResponse } = useChat({
// auto-resume once every pending approval is answered
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
// in your render, for a part in the 'approval-requested' state:
<button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>
Approve
</button>
Same mechanism, no manual re-submit.
Where to draw the line#
Gate the tools that touch money, delete data, message a human, or spend a budget — and only those. Over-gate and you've rebuilt a form with extra steps; under-gate and you've shipped an agent that can wire funds on a hallucination. Make needsApproval a function so the threshold is explicit and reviewable, log every approvalId with who decided it, and treat a denial as signal the model should learn from, not an error to swallow.
The reason this pattern is worth adopting now: it's the smallest change that moves an agent from "impressive demo I don't trust in production" to "runs unattended, stops at the exact three actions where a human should decide." That's the line between a toy and a coworker — and in AI SDK 7 it's a boolean on a tool.



