Zum Inhalt springen
EN DE

Challenge 3.5: Tool Approval

Wuerdest Du einem Agenten erlauben, ohne Rueckfrage Dateien zu loeschen, E-Mails zu versenden oder Daten in einer Datenbank zu ändern?

LLM will Tool ausführen, Approval Guard fragt User, bei Genehmigung wird Tool ausgefuehrt, bei Ablehnung abgelehnt

Zwischen Tool Call und Tool Execution sitzt ein Approval Guard. Wenn ein Tool als kritisch markiert ist, fragt der Guard den User um Erlaubnis, bevor das Tool ausgefuehrt wird. Das ist Human-in-the-Loop — der Mensch behaelt die Kontrolle über gefaehrliche Aktionen.

Ohne Approval: Der Agent fuehrt alles aus, was das LLM entscheidet. Dateien loeschen, E-Mails versenden, Daten ueberschreiben — ohne Rueckfrage. Ein Fehler im Prompt oder eine Halluzination kann irreversiblen Schaden anrichten.

Mit Approval: Kritische Operationen werden erst nach menschlicher Freigabe ausgefuehrt. Du definierst, welche Tools sicher sind (automatisch ausführen) und welche gefaehrlich sind (erst fragen). Der Agent bleibt nuetzlich, aber kontrollierbar.

Schicht 1: Statisches Approval mit needsApproval: true

Abschnitt betitelt „Schicht 1: Statisches Approval mit needsApproval: true“

Die einfachste Form: Jeder Aufruf dieses Tools braucht eine Freigabe:

import { tool } from 'ai';
import { z } from 'zod';
const deleteFileTool = tool({
description: 'Delete a file from the filesystem',
inputSchema: z.object({
path: z.string().describe('The file path to delete'),
}),
needsApproval: true, // ← Immer Freigabe noetig
execute: async ({ path }) => {
// In Production: fs.unlink(path)
return { deleted: path, status: 'success' };
},
});

Wenn needsApproval: true gesetzt ist, wird execute NICHT automatisch aufgerufen. Stattdessen muss der Aufrufer (Dein Code) die Freigabe erteilen, bevor die Ausfuehrung stattfindet.

Manchmal soll die Approval-Logik vom Kontext abhaengen — z.B. nur bei bestimmten Pfaden oder ab einem bestimmten Betrag:

const deleteFileTool = tool({
description: 'Delete a file from the filesystem',
inputSchema: z.object({
path: z.string().describe('The file path to delete'),
}),
needsApproval: async ({ path }) => { // ← Async Funktion
// Nur Freigabe noetig für kritische Verzeichnisse
return path.startsWith('/important/') || path.startsWith('/config/');
},
execute: async ({ path }) => {
return { deleted: path, status: 'success' };
},
});

Die needsApproval-Funktion bekommt die gleichen Parameter wie execute. Sie gibt true zurück, wenn eine Freigabe noetig ist, und false, wenn das Tool direkt ausgefuehrt werden darf.

Schicht 3: Approval Flow mit generateText — das 2-Call Pattern

Abschnitt betitelt „Schicht 3: Approval Flow mit generateText — das 2-Call Pattern“

Das AI SDK v6 hat keinen toolCallApproval Callback. Stattdessen nutzt es ein message-basiertes 2-Call Pattern: Du rufst generateText auf, pruefst das Ergebnis auf Approval Requests, fragst den User, und rufst generateText erneut mit der Antwort auf.

import {
generateText, tool, stepCountIs,
type ModelMessage, type ToolApprovalResponse,
} from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
import * as readline from 'readline';
const readFileTool = tool({
description: 'Read a file from the filesystem',
inputSchema: z.object({
path: z.string().describe('The file path to read'),
}),
execute: async ({ path }) => ({
content: `Inhalt von ${path}: Lorem ipsum...`,
}),
});
const deleteFileTool = tool({
description: 'Delete a file from the filesystem',
inputSchema: z.object({
path: z.string().describe('The file path to delete'),
}),
needsApproval: true,
execute: async ({ path }) => ({
deleted: path, status: 'success',
}),
});
// Hilfsfunktion: User im Terminal fragen
function askUser(question: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'ja');
});
});
}
const tools = { readFile: readFileTool, deleteFile: deleteFileTool };
const messages: ModelMessage[] = [
{ role: 'user', content: 'Lies die Datei /tmp/test.txt und loesche sie danach.' },
];
// Loop: generateText aufrufen, Approvals verarbeiten, erneut aufrufen
let result = await generateText({
model: anthropic('claude-sonnet-4-5-20250514'),
tools,
stopWhen: stepCountIs(5),
messages,
});
// Antwort-Messages in den Verlauf übernehmen
messages.push(...result.response.messages);
// Pruefen ob Approval Requests vorhanden sind
while (result.content.some(part => part.type === 'tool-approval-request')) {
const approvals: ToolApprovalResponse[] = [];
for (const part of result.content) {
if (part.type === 'tool-approval-request') {
const approved = await askUser(
`Agent will ${part.toolCall.toolName}(${JSON.stringify(part.toolCall.input)}) ausführen. Erlauben? (y/n): `
);
approvals.push({
type: 'tool-approval-response',
approvalId: part.approvalId, // ← Verknuepft Request und Response
approved,
reason: approved ? 'User hat genehmigt' : 'User hat abgelehnt',
});
}
}
// Approval-Antworten als Tool-Message anhaengen
messages.push({ role: 'tool', content: approvals });
// Erneut aufrufen — bei Genehmigung wird das Tool jetzt ausgefuehrt
result = await generateText({
model: anthropic('claude-sonnet-4-5-20250514'),
tools,
stopWhen: stepCountIs(5),
messages,
});
messages.push(...result.response.messages);
}
console.log(result.text);

So funktioniert das Pattern:

  1. Erster Aufruf: generateText erkennt, dass deleteFile eine Freigabe braucht. Statt das Tool auszufuehren, kommt ein tool-approval-request in result.content zurück.
  2. Approval Request prüfen: Jeder Request hat eine approvalId und den toolCall (mit toolName und input).
  3. User fragen: Du fragst den User und baust ein ToolApprovalResponse-Objekt mit der gleichen approvalId.
  4. Zweiter Aufruf: Du haengst die Approvals als { role: 'tool', content: approvals } an die Messages und rufst generateText erneut auf.
  5. Loop: Bei Genehmigung wird das Tool ausgefuehrt. Bei Ablehnung reagiert das LLM darauf. Die while-Schleife faengt auch Faelle ab, in denen der Agent nach der ersten Runde weitere Approval-pflichtige Tools aufrufen will.

Ein bewaehrtes Pattern: Trenne Tools in sichere und gefaehrliche Kategorien:

import { tool } from 'ai';
import { z } from 'zod';
// Sichere Tools — automatisch ausführen
const searchTool = tool({
description: 'Search for information (read-only)',
inputSchema: z.object({
query: z.string().describe('Search query'),
}),
execute: async ({ query }) => ({
results: [`Ergebnis für "${query}"`],
}),
});
const readFileTool = tool({
description: 'Read a file (read-only)',
inputSchema: z.object({
path: z.string().describe('File path'),
}),
execute: async ({ path }) => ({
content: `Inhalt von ${path}`,
}),
});
// Gefaehrliche Tools — immer Freigabe
const writeFileTool = tool({
description: 'Write content to a file (destructive)',
inputSchema: z.object({
path: z.string().describe('File path'),
content: z.string().describe('Content to write'),
}),
needsApproval: true,
execute: async ({ path, content }) => ({
written: path,
bytes: content.length,
}),
});
const deleteFileTool = tool({
description: 'Delete a file (destructive, irreversible)',
inputSchema: z.object({
path: z.string().describe('File path'),
}),
needsApproval: true,
execute: async ({ path }) => ({
deleted: path,
}),
});
// Alle Tools zusammen nutzen:
const tools = {
search: searchTool, // ← auto
readFile: readFileTool, // ← auto
writeFile: writeFileTool, // ← needs approval
deleteFile: deleteFileTool, // ← needs approval
};

Faustregel: Read-only Operationen sind sicher. Alles was schreibt, loescht oder versendet braucht Approval. Im Zweifelsfall: Approval einbauen.

Datei: challenge-3-5.ts

Aufgabe: Baue ein File-Management-Tool-Set mit einem readFile (sicher) und einem deleteFile (mit Approval). Implementiere den message-basierten Approval Flow mit dem 2-Call Pattern.

import {
generateText, tool, stepCountIs,
type ModelMessage, type ToolApprovalResponse,
} from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
import * as readline from 'readline';
// TODO 1: Definiere readFileTool (kein Approval noetig)
// - description: Datei lesen
// - inputSchema: path (string)
// - execute: Gibt simulierten Inhalt zurück
// TODO 2: Definiere deleteFileTool (mit needsApproval: true)
// - description: Datei loeschen
// - inputSchema: path (string)
// - needsApproval: true
// - execute: Gibt Loesch-Bestaetigung zurück
// TODO 3: Erstelle eine askUser() Hilfsfunktion
// - Fragt im Terminal nach Freigabe
// - Gibt true/false zurück
// TODO 4: Erstelle messages Array (ModelMessage[]) mit dem User-Prompt
// - 'Lies /tmp/notes.txt und loesche die Datei danach.'
// TODO 5: Erster generateText-Aufruf mit tools, stopWhen, messages
// - messages.push(...result.response.messages)
// TODO 6: While-Loop: result.content auf 'tool-approval-request' prüfen
// - Für jeden Request: User fragen, ToolApprovalResponse bauen
// - Approvals als { role: 'tool', content: approvals } an messages anhaengen
// - generateText erneut aufrufen mit aktualisierten messages
// TODO 7: Logge result.text und alle Steps

Checkliste:

  • readFileTool ohne Approval definiert
  • deleteFileTool mit needsApproval: true definiert
  • askUser() Hilfsfunktion implementiert
  • Erster generateText-Aufruf mit messages Array
  • result.content auf tool-approval-request geprueft
  • ToolApprovalResponse mit approvalId gebaut
  • Zweiter generateText-Aufruf mit Approval-Antworten in Messages
  • Bei “Nein”: Tool wird nicht ausgefuehrt, Agent reagiert darauf
Lösung anzeigen
import {
generateText, tool, stepCountIs,
type ModelMessage, type ToolApprovalResponse,
} from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';
import * as readline from 'readline';
const readFileTool = tool({
description: 'Read a file from the filesystem (safe, read-only)',
inputSchema: z.object({
path: z.string().describe('The file path to read'),
}),
execute: async ({ path }) => ({
path,
content: `Inhalt von ${path}: Dies sind wichtige Notizen.`,
size: 42,
}),
});
const deleteFileTool = tool({
description: 'Delete a file from the filesystem (destructive, irreversible)',
inputSchema: z.object({
path: z.string().describe('The file path to delete'),
}),
needsApproval: true,
execute: async ({ path }) => ({
deleted: path,
status: 'success',
}),
});
function askUser(question: string): Promise<boolean> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'ja');
});
});
}
const tools = { readFile: readFileTool, deleteFile: deleteFileTool };
const messages: ModelMessage[] = [
{ role: 'user', content: 'Lies die Datei /tmp/notes.txt und loesche sie danach.' },
];
// Erster Aufruf — readFile wird direkt ausgefuehrt,
// deleteFile erzeugt einen Approval Request
let result = await generateText({
model: anthropic('claude-sonnet-4-5-20250514'),
tools,
stopWhen: stepCountIs(5),
messages,
});
messages.push(...result.response.messages);
// Loop: Solange Approval Requests kommen, User fragen und erneut aufrufen
while (result.content.some(part => part.type === 'tool-approval-request')) {
const approvals: ToolApprovalResponse[] = [];
for (const part of result.content) {
if (part.type === 'tool-approval-request') {
console.log(`\n--- Approval Request ---`);
console.log(`Tool: ${part.toolCall.toolName}`);
console.log(`Parameter: ${JSON.stringify(part.toolCall.input)}`);
const approved = await askUser('Ausfuehren? (y/n): ');
console.log(approved ? 'Genehmigt.' : 'Abgelehnt.');
approvals.push({
type: 'tool-approval-response',
approvalId: part.approvalId,
approved,
reason: approved ? 'User hat genehmigt' : 'User hat abgelehnt',
});
}
}
// Approval-Antworten an Messages anhaengen
messages.push({ role: 'tool', content: approvals });
// Erneut aufrufen — Tool wird jetzt ausgefuehrt (oder abgelehnt)
result = await generateText({
model: anthropic('claude-sonnet-4-5-20250514'),
tools,
stopWhen: stepCountIs(5),
messages,
});
messages.push(...result.response.messages);
}
// Ergebnis ausgeben
console.log('\n=== Ergebnis ===');
console.log(`Schritte: ${result.steps.length}`);
const allToolCalls = result.steps.flatMap(s => s.toolCalls);
console.log(`Tool Calls: ${allToolCalls.length}`);
for (const call of allToolCalls) {
console.log(` - ${call.toolName}(${JSON.stringify(call.args)})`);
}
console.log(`\nAntwort: ${result.text}`);

Erklärung: Der Agent liest zuerst /tmp/notes.txt (automatisch, kein Approval noetig) und versucht dann die Datei zu loeschen. Beim deleteFile Call greift needsApproval: true: Statt das Tool auszufuehren, gibt generateText einen tool-approval-request in result.content zurück. Dein Code fragt den User im Terminal. Bei “y” wird eine genehmigende ToolApprovalResponse gebaut und als Tool-Message angehaengt. Der zweite generateText-Aufruf fuehrt das Tool dann aus. Bei “n” bekommt das LLM die Ablehnung und reagiert darauf — z.B. mit “Die Datei wurde nicht geloescht, da die Aktion abgelehnt wurde.”

Ausfuehren: npx tsx challenge-3-5.ts

Erwarteter Output (ungefaehr):

--- Approval Request ---
Tool: deleteFile
Parameter: {"path":"/tmp/notes.txt"}
Ausfuehren? (y/n): y
Genehmigt.
=== Ergebnis ===
Schritte: 1
Tool Calls: 1
- deleteFile({"path":"/tmp/notes.txt"})
Antwort: Die Datei /tmp/notes.txt wurde gelesen und anschliessend geloescht.
Prompt startet Agent, LLM waehlt sichere oder gefaehrliche Tools, gefaehrliche brauchen Approval, bei Ablehnung zurück zum LLM

Uebung: Erweitere den Research Agent aus Challenge 3.3 um einen Approval-Schritt für das Speichern von Ergebnissen.

  1. Nimm den Agent aus Challenge 3.3 mit searchTool und summarizeTool
  2. Fuege ein saveResultsTool hinzu mit needsApproval: true
  3. Prompt: “Recherchiere TypeScript, fasse zusammen und speichere die Ergebnisse in /tmp/research.txt.”
  4. Implementiere den 2-Call Loop: Erster generateText-Aufruf, dann result.content auf tool-approval-request prüfen, User fragen, ToolApprovalResponse bauen, zweiter Aufruf
  5. Logge ob der User die Speicherung genehmigt oder abgelehnt hat

Optional Stretch Goal: Implementiere dynamisches Approval: needsApproval als Funktion, die nur bei Pfaden ausserhalb von /tmp/ eine Freigabe verlangt. Teste mit verschiedenen Pfaden.

Part of AI Learning — free courses from prompt to production. Jan on LinkedIn