playbook/outfitter-agents/plugins/outfitter/skills/ai-sdk/references/tool-approval.md

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