AI-Agent Approval Gates in Next.js: Ein iterativer Agent-Loop, der weiss, wann er stoppen muss
Wie wir AI-Agent Approval Gates in eine Next.js-App eingebaut haben — ein iterativer Agent-Loop, der mehrstufige Operationen autonom ausführt, aber bei destruktiven Aktionen sauber pausiert.
AI-Agent Approval Gates in Next.js: Ein iterativer Agent-Loop, der weiss, wann er stoppen muss
Letzten Monat hat ein AI-Assistent in einem unserer internen Tools einen Benutzerdatensatz archiviert. Technisch korrekt — der Operator hatte genau das angefragt. Das Problem: Er hatte «die alte Anwesenheitsliste archivieren» geschrieben, und das Modell interpretierte «alt» grosszügiger als erwartet. Kein Bestätigungsschritt, kein Approval Gate, keine Pause zwischen «ich werde das jetzt löschen» und «erledigt». Die Daten waren soft-deleted und wiederherstellbar, aber der Vorfall machte eines klar: Ein AI-Agent, der in eine Datenbank schreiben kann, braucht AI-Agent Approval Gates — harte Stopps, bei denen destruktive Operationen auf menschliche Freigabe warten.
Dieser Beitrag beschreibt, wie wir diese Gates in einen iterativen Agent-Loop eingebaut haben, der in einer Next.js-Applikation läuft. Das Quellprojekt ist ZSO-Management, eine Plattform für Zivilschutzorganisationen zur Verwaltung von Personal, Anwesenheit und Ausbildung. Die Muster lassen sich auf jede Anwendung übertragen, in der ein LLM-Agent sichere Operationen autonom ausführen, destruktive aber durch menschliche Bestätigung absichern soll.
Die Architektur: Plan, Act, Observe, Reflect, Answer
Die ursprüngliche Implementierung war ein einfacher Request-Response-Chat: Benutzer sendet Nachricht, Modell antwortet, fertig. Die neue Architektur ist ein iterativer Loop mit maximal 15 Iterationen pro Benutzer-Turn. Jede Iteration folgt demselben Zyklus:
- Plan. Das Modell erhält den Gesprächsverlauf plus verfügbare Tools und entscheidet, was als nächstes zu tun ist.
- Act. Der Agent führt die angeforderten Tool Calls aus.
- Observe. Tool-Ergebnisse werden als Tool-Result-Messages an den Gesprächsverlauf angehängt.
- Reflect. Der aktualisierte Verlauf geht zurück ans Modell, das entscheidet, ob weitere Tools nötig sind oder eine finale Antwort folgt.
- Answer. Wenn das Modell eine Textantwort ohne Tool Calls liefert, terminiert der Loop und die Antwort wird an den Client gestreamt.
Der Loop läuft serverseitig und streamt Fortschritte via Server-Sent Events (SSE) an den Browser. Zwei spezielle Tool-Typen brechen den Loop vorzeitig ab:
ask_user_question— das Modell braucht Informationen, die es nicht hat. Der Agent pausiert und streamt die Frage an den Client.- Destruktive Tools (Archivieren, Löschen, Massenänderungen) — der Agent pausiert und streamt eine Bestätigungsaufforderung an den Client.
Wenn der Benutzer antwortet, setzt der Server den Loop fort, indem er die Antwort als Tool Result für den pausierten Tool Call injiziert und dort weitermacht, wo er aufgehört hat. Das Modell sieht die Genehmigung (oder Ablehnung) so, als hätte das Tool sie synchron zurückgegeben.
// Vereinfachte Loop-Struktur
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await callModel(messages, availableTools);
if (response.type === "text") {
stream.send({ type: "answer", content: response.text });
break;
}
for (const toolCall of response.toolCalls) {
if (requiresApproval(toolCall)) {
stream.send({ type: "approval_required", toolCall });
await persistPendingState(conversationId, toolCall);
return; // Pause — Client setzt über separaten Endpoint fort
}
const result = await executeTool(toolCall);
messages.push(toolResultMessage(toolCall.id, result));
}
}
Das ist der Happy Path. Die Bugs lebten an den Rändern.
Bug 1: Verwaiste Tool Calls bei Batch-Unterbrechung
Der erste Produktionsfehler trat auf, als das Modell mehrere Tool Calls in einer einzigen Assistant-Nachricht generierte. Das ist normales Verhalten bei OpenAI-kompatiblem Tool Use: Das Modell kann mehrere Operationen gleichzeitig anfordern, und die API erwartet für jede tool_call-ID im Batch eine passende Tool-Result-Message.
Unser Agent-Loop iterierte sequenziell durch den Batch. Wenn er in der Mitte auf ein destruktives Tool stiess — sagen wir, den zweiten von drei Calls — pausierte er. Die restlichen Tool-Call-IDs erhielten nie ein Ergebnis.
Beim Fortsetzen enthielt der nächste API-Call eine Assistant-Nachricht mit drei Tool-Call-IDs, aber nur Ergebnisse für die Calls vor der Pause. Die API wies das ab: Der Vertrag verlangt genau eine Tool-Result-Message pro Tool-Call-ID.
Die Lösung: Vor dem Return bei einer Pause synthetische «übersprungen»-Ergebnisse für alle folgenden Tool Calls einfügen.
for (let j = 0; j < toolCalls.length; j++) {
const toolCall = toolCalls[j];
if (requiresApproval(toolCall)) {
// Restliche Tool Calls mit synthetischen Ergebnissen füllen
for (let k = j + 1; k < toolCalls.length; k++) {
messages.push(
toolResultMessage(
toolCalls[k].id,
"Übersprungen: vorherige Aktion erfordert Benutzerfreigabe."
)
);
}
stream.send({ type: "approval_required", toolCall });
await persistPendingState(conversationId, toolCall);
return;
}
const result = await executeTool(toolCall);
messages.push(toolResultMessage(toolCall.id, result));
}
Die synthetischen Ergebnisse erfüllen den API-Vertrag, ohne zu implizieren, dass die übersprungenen Tools erfolgreich liefen. Das Modell sieht sie beim Fortsetzen und versteht, dass diese Operationen aufgeschoben wurden.
Bug 2: State-Korruption beim Fortsetzen
Der zweite Bug zeigte sich beim Resume. Wenn der Benutzer eine pausierte Aktion genehmigte, rekonstruierte der Server den Gesprächsverlauf aus der Datenbank. Unsere Persistenzschicht speicherte eine Platzhalter-Assistant-Zeile — leerer Inhalt mit Metadaten, die sie als «Genehmigung ausstehend» markierten — damit die UI einen Ladezustand anzeigen konnte.
Beim Fortsetzen inkludierte die Nachrichten-Rekonstruktion diese Platzhalterzeile im Gesprächsverlauf, der ans Modell ging. Eine Assistant-Nachricht mit leerem Inhalt und ohne Tool Calls, platziert zwischen einer Tool-Result-Message und der nächsten User-Message, verletzte den Nachrichten-Sequenz-Vertrag.
Die Lösung bestand aus zwei Änderungen:
- Leere Assistant-Zeilen bei der Rekonstruktion überspringen. Wenn eine Assistant-Nachricht keinen Textinhalt und keine Tool Calls hat, trägt sie kein semantisches Gewicht und sollte nicht ans Modell gesendet werden.
- Platzhalterzeilen nach der Benutzerantwort löschen, statt nur deren Inhalt zu leeren. Eine geleerte Zeile mit leerem Inhalt ist von einer fehlerhaften Nachricht nicht zu unterscheiden. Eine gelöschte Zeile fehlt einfach — und das ist korrekt.
Die breitere Lektion: Jeder intermediäre Agent-State, den man für die UI persistiert, muss für die LLM-Verlaufsrekonstruktion unsichtbar bleiben. Platzhalterzeilen sind ein UI-Anliegen; der Gesprächsverlauf des Modells ist ein API-Vertrag. Vermischt man beides, erhält man stille Korruption.
Berechtigungsmodell und PII-Grenzen
Approval Gates sind die Laufzeitprüfung. Die Kompilierzeit-Prüfung ist das Berechtigungsmodell. Der Agent-Loop erhält ein allowWrites-Flag, das von der Benutzerrolle abgeleitet wird. Wenn allowWrites deaktiviert ist, werden alle Tools der Kategorie «Schreiben» aus dem Schema gefiltert, das ans Modell gesendet wird. Das Modell kann Archivierungs-, Aktualisierungs- oder Erstellungs-Tools buchstäblich weder sehen noch aufrufen. Falls es trotzdem einen Write-Tool-Namen errät, verweigert der Loop die Ausführung serverseitig.
Dies ist eine Whitelist, keine Blacklist. Neue Tools sind standardmässig unsichtbar, bis sie explizit als sicher für den Agenten kategorisiert werden. Wir haben diese Philosophie in unserem Beitrag über AI-Agent-Sicherheit für KMU ausführlicher behandelt — dasselbe Vault-First-Prinzip gilt, ob man Agenten auf Organisationsebene oder innerhalb eines einzelnen Produkt-Features einsetzt.
Die PII-Grenze ist ein verwandtes, aber eigenständiges Thema. Der Agent muss Benutzerreferenzen auflösen können — wenn jemand «füge Fabian zur Montagslektion hinzu» tippt, muss das Modell wissen, welcher Benutzer «Fabian» ist. Wir haben ein Benutzer-Such-Tool gebaut, das die Eingabe tokenisiert, einen case-insensitiven Abgleich gegen die Datenbank ausführt und Ergebnisse im Format User #42 (Rolle: Gruppenführer) zurückgibt. Namen erscheinen nie im Tool-Output. Wenn nichts gefunden wird, ist die Antwort generisch — der ursprüngliche Suchbegriff wird nie zurückgespiegelt. So kann das Modell keine personenbezogenen Daten in seinem Gesprächskontext speichern oder wiederholen, selbst über lange Multi-Turn-Sessions.
Wenn man AI-Agenten für Schweizer KMU oder in einem Kontext baut, in dem Datenschutzrecht gilt, ist dies das Muster, das man verinnerlichen sollte. Das Response-Design des Tools ist die erste und beste Datenschutzkontrolle.
Fünf Erkenntnisse für den eigenen Agent-Loop
Multi-Tool-Batches von Anfang an einplanen. Das Modell wird mehrere Tool Calls in einem Turn generieren. Die Pause-/Resume-Logik muss eine Pause an jeder Position im Batch handhaben können. Synthetische «übersprungen»-Ergebnisse für aufgeschobene Calls sind die sauberste Lösung.
UI-State aus dem LLM-Verlauf fernhalten. Platzhalterzeilen, Ladeanzeigen, optimistische Updates — alles valide UI-Patterns, die den Gesprächsverlauf des Modells korrumpieren, wenn sie ins Message-Array gelangen. Den Modellverlauf aus einer sauberen Quelle rekonstruieren, nicht aus derselben Tabelle, die den Chat-UI antreibt.
Tools auf Schema-Ebene filtern, nicht zur Laufzeit. Wenn eine Berechtigung «keine Schreibzugriffe» sagt, Write-Tools vor dem API-Call aus dem Schema entfernen. Das Modell kann ein Tool nicht missbrauchen, das es nicht sehen kann. Laufzeit-Verweigerung ist ein Backup, keine primäre Kontrolle.
Tool-Responses als PII-Grenze entwerfen. Festlegen, was das Modell sehen muss versus was es per ID referenzieren soll. IDs und Rollen zurückgeben, keine Namen. Zähler zurückgeben, keine Listen von Einzelpersonen. Das Tool-Response-Format ist die zuverlässigste Datenschutzkontrolle, weil es greift, bevor das Modell die Daten verarbeitet.
Den Resume-Pfad genauso hart testen wie den Happy Path. Der initiale Agent-Loop wird funktionieren. Die Bugs leben in Pause, Persist, Resume. Konkret: Nachrichtenrekonstruktion nach Resume, der Tool-Call-zu-Tool-Result-Vertrag über eine Pausengrenze hinweg, und Platzhalter-Aufräumung.
Wenn Sie einen AI-Agenten bauen, der Autonomie mit menschlicher Aufsicht balancieren muss — ob er einen Geschäftsprozess steuert, Datensätze verwaltet oder Operationen durchführt, bei denen «Rückgängig» teuer ist — buchen Sie einen kostenlosen AI Potenzial-Check und bringen Sie Ihre Architektur mit. Wir sagen Ihnen, wo die Pausenpunkte hingehören und wo die Bugs sich verstecken werden.