6.2 KiB
6.2 KiB
Tool Approval (Human-in-the-Loop)
v6 patterns for requiring user approval before tool execution.
When to Use
| Scenario | Approval Type |
|---|---|
| Always dangerous (delete, payment) | Static: needsApproval: true |
| Conditionally risky (large amounts) | Dynamic: needsApproval: async (args) => boolean |
| User preference | Dynamic based on user settings |
Static Approval
Tool always requires approval:
import { tool } from 'ai';
import { z } from 'zod';
const deleteUserTool = tool({
description: 'Permanently delete a user account',
inputSchema: z.object({
userId: z.string(),
reason: z.string(),
}),
needsApproval: true, // Always require
execute: async ({ userId, reason }) => {
await deleteUser(userId, reason);
return { success: true };
},
});
Dynamic Approval
Approval based on input:
const paymentTool = tool({
description: 'Process a payment',
inputSchema: z.object({
amount: z.number(),
recipient: z.string(),
currency: z.string().default('USD'),
}),
needsApproval: async ({ amount }) => amount > 1000,
execute: async ({ amount, recipient, currency }) => {
return await processPayment(amount, recipient, currency);
},
});
Complex Approval Logic
const externalApiTool = tool({
description: 'Call external API',
inputSchema: z.object({
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'DELETE']),
body: z.any().optional(),
}),
needsApproval: async ({ method, endpoint }) => {
// Approve all non-GET requests
if (method !== 'GET') return true;
// Approve requests to sensitive endpoints
if (endpoint.includes('/admin')) return true;
// No approval needed for safe reads
return false;
},
execute: async ({ endpoint, method, body }) => {
return await fetch(endpoint, { method, body: JSON.stringify(body) });
},
});
Client-Side Handling
useChat with Approval
'use client';
import { useChat } from '@ai-sdk/react';
function Chat() {
const { messages, sendMessage, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
return (
<div>
{messages.map((message) => (
<Message
key={message.id}
message={message}
onApprove={(id) => addToolApprovalResponse({ id, approved: true })}
onDeny={(id) => addToolApprovalResponse({ id, approved: false })}
/>
))}
</div>
);
}
Approval UI Component
function ToolInvocation({ invocation, onApprove, onDeny }) {
switch (invocation.state) {
case 'approval-requested':
return (
<div className="border rounded p-4 bg-yellow-50">
<h4 className="font-bold">Approval Required</h4>
<p>Tool: {invocation.toolName}</p>
<pre className="text-sm bg-gray-100 p-2 rounded">
{JSON.stringify(invocation.input, null, 2)}
</pre>
<div className="flex gap-2 mt-2">
<button
onClick={() => onApprove(invocation.approval.id)}
className="bg-green-500 text-white px-4 py-2 rounded"
>
Approve
</button>
<button
onClick={() => onDeny(invocation.approval.id)}
className="bg-red-500 text-white px-4 py-2 rounded"
>
Deny
</button>
</div>
</div>
);
case 'pending':
return <div className="text-gray-500">Waiting for tool execution...</div>;
case 'output-available':
return (
<div className="border rounded p-4 bg-green-50">
<h4 className="font-bold">Tool Result</h4>
<pre className="text-sm">{JSON.stringify(invocation.output, null, 2)}</pre>
</div>
);
default:
return null;
}
}
Rendering Tool Parts in Messages
function Message({ message, onApprove, onDeny }) {
return (
<div>
{message.parts.map((part, i) => {
if (part.type === 'text') {
return <p key={i}>{part.text}</p>;
}
if (part.type === 'tool-invocation') {
return (
<ToolInvocation
key={i}
invocation={part}
onApprove={onApprove}
onDeny={onDeny}
/>
);
}
return null;
})}
</div>
);
}
Server-Side Processing
For complex approval workflows with server-side tool execution:
import { processToolCalls } from './tool-processor';
export async function POST(req: Request) {
const { messages } = await req.json();
// Check for pending approvals
const lastMessage = messages.at(-1);
const hasPendingApprovals = lastMessage?.parts?.some(
(p) => p.type === 'tool-invocation' && p.state === 'output-available'
);
if (hasPendingApprovals) {
// Process approved tools
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const processed = await processToolCalls({
writer,
messages,
tools: myTools,
}, toolExecuteFunctions);
// Continue conversation with tool results
const result = streamText({
model: openai('gpt-4o'),
messages: convertToModelMessages(processed),
});
writer.merge(result.toUIMessageStream());
},
originalMessages: messages,
});
return createUIMessageStreamResponse({ stream });
}
// Normal flow
const result = streamText({
model: openai('gpt-4o'),
messages: convertToModelMessages(messages),
tools: myTools,
});
return result.toUIMessageStreamResponse({ originalMessages: messages });
}
Best Practices
Security:
- Always require approval for destructive operations
- Use dynamic approval for operations with varying risk levels
- Log all approval decisions for audit trails
UX:
- Show clear context for what the tool will do
- Display input parameters so users can make informed decisions
- Provide cancel/timeout options for pending approvals
Error Handling:
- Handle denied approvals gracefully
- Provide alternative actions when tools are denied
- Don't retry denied tools automatically