Skip to content
tecminds

AI Agent Approval Gates in Next.js: Building a Multi-Step Agent Loop That Knows When to Stop

How we built AI agent approval gates into a Next.js app — a real iterative agent loop that autonomously drives multi-step operations but cleanly pauses for human approval on destructive actions.

TTobias LüscherCo‑Founder · TecMinds2026-05-25 · 9 min read

AI Agent Approval Gates in Next.js: Building a Multi-Step Agent Loop That Knows When to Stop

We were replacing a simple two-phase chat interface with a real iterative AI agent inside a Next.js application. The agent needed to drive multi-step operations — creating complex records with nested sub-records, looking up related data, filling in defaults — autonomously. But the moment it reached a destructive action (archive, delete, bulk update), it had to stop, ask the user for explicit approval, and resume only after they said yes.

The concept of AI agent approval gates sounds straightforward: pause before dangerous operations. The implementation surfaced four bugs that each broke the agent in production, and each one taught us something the documentation doesn't cover. This is the writeup.

The project is ZSO-management, a civil defense organization management system built on Next.js. The patterns generalize to any application where an LLM agent needs to gate destructive operations behind human confirmation.

The Architecture: Plan, Act, Observe, Reflect, Answer

The previous implementation was a request-response chat. The new architecture is an iterative loop capped at 15 iterations per user turn. Each iteration follows the same cycle:

  1. Plan. The model receives the conversation history plus available tools and decides what to do next.
  2. Act. The agent executes the tool calls the model requested.
  3. Observe. Tool results are appended to the conversation as tool-result messages.
  4. Reflect. The updated conversation goes back to the model, which decides whether to call another tool or produce a final answer.
  5. Answer. When the model emits a text response with no tool calls, the loop terminates and the answer streams to the user.

The loop runs server-side and streams progress to the browser via Server-Sent Events (SSE). Two special tool types break the loop early:

  • ask_user_question — the model needs information it doesn't have. The agent pauses, streams the question to the client, and waits for the user to reply.
  • Destructive tools (archive, delete, bulk state changes) — the agent pauses, streams a confirmation prompt to the client, and waits for explicit approval.

When the user responds, the server resumes by injecting the user's answer as a tool result for the paused tool call. The model sees the approval (or rejection) as if the tool had returned it synchronously.

// Simplified loop structure
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 will resume via a separate endpoint
    }

    const result = await executeTool(toolCall);
    messages.push(toolResultMessage(toolCall.id, result));
  }
}

This is the happy path. The bugs lived in the edges.

Bug 1: The Orphaned Tool Call Problem

The first production failure appeared when the model emitted multiple tool calls in a single assistant message. This is normal behavior for OpenAI-compatible tool use: the model can request several operations at once, and the API expects a tool-result message for every tool_call ID in the batch.

Our agent loop iterated through the batch sequentially. When it hit a destructive tool partway through — say, the second of three calls — it returned early to pause for approval. The remaining tool call IDs never received results.

When the user approved and the loop resumed, the next API call included an assistant message with three tool call IDs but only the results for the calls that had executed before the pause. The API rejected it: the contract requires exactly one tool-result message per tool call ID in the preceding assistant message.

The fix: before returning early on a pause, push a synthetic "skipped" result for every tool call that follows the paused one.

for (let j = 0; j < toolCalls.length; j++) {
  const toolCall = toolCalls[j];

  if (requiresApproval(toolCall)) {
    // Fill in remaining tool calls with synthetic results
    for (let k = j + 1; k < toolCalls.length; k++) {
      messages.push(
        toolResultMessage(
          toolCalls[k].id,
          "Skipped: preceding action requires user approval."
        )
      );
    }
    stream.send({ type: "approval_required", toolCall });
    await persistPendingState(conversationId, toolCall);
    return;
  }

  const result = await executeTool(toolCall);
  messages.push(toolResultMessage(toolCall.id, result));
}

The synthetic results satisfy the API contract without implying that the skipped tools ran successfully. The model sees them on resume and understands that those operations were deferred. No unit test catches this unless you specifically generate multi-tool responses with a destructive call in the middle of the batch. We added exactly that test case.

Bug 2: Resume State Corruption

The second bug appeared on resume. When the user approved a paused action, the server reconstructed the conversation history from the database to continue the loop. Our persistence layer stored a placeholder assistant row — empty content with metadata flagging it as "pending approval" — so the UI could show a loading state.

On resume, the message-rebuild logic included that placeholder row in the conversation sent to the model. An assistant message with empty content and no tool calls, sitting between a tool-result message and the next user message, violated the message-sequence contract. The API rejected it, or worse, the model produced confused output.

The fix was two changes:

  1. Skip empty-content assistant rows during message reconstruction. If an assistant message has no text content and no tool calls, it carries no semantic weight and should not be sent to the model.
  2. Delete placeholder rows after the user responds, rather than clearing their content. A cleared row with empty content is indistinguishable from a malformed message. A deleted row is absent, which is correct.

The broader lesson: placeholder rows are a UI concern; the model's conversation history is an API contract. Mix them and you get silent corruption.

Bug 3: Permission Enforcement as Schema Filtering

The agent supports a permission flag — call it AUTONOMOUS_WRITES — that controls whether the model can invoke write-category tools at all. When disabled, the agent operates in read-only mode: it can look things up, summarize data, answer questions, but cannot create, update, or delete anything.

The naive implementation checks the permission at execution time and refuses the call. That works, but it wastes tokens: the model still sees the write tools in its schema, plans around them, and gets refused. Worse, some models retry with slightly different arguments, burning iterations.

The correct approach is a whitelist at the schema level. When the permission is disabled, write-category tools are filtered out of the tool definitions sent to the model. The model never sees them, never plans around them, never wastes a turn. If a tool call somehow arrives for a filtered tool (prompt injection, model confusion), the execution layer refuses it as an unknown tool — defense in depth.

This is the same principle we described in our post on AI agent security for SMEs: the policy layer is deterministic code that the model cannot negotiate with. Schema filtering is the cheapest version of that principle — you don't need a rules engine if the tool simply doesn't exist in the model's world.

Bug 4: PII Boundaries in Tool Design

One of the agent's tools searches for users by name so the model can reference them in record creation. The initial implementation returned the user's full name and role in the tool result — names round-tripped through the model's context window and could be echoed back in responses.

The fix: the search tool returns only User #<id> (Role: <role>). The model gets enough to populate a foreign key but never sees the name. When no results are found, the response is generic ("No users matched the search criteria") rather than echoing the search term back into the model's context.

The PII boundary belongs in the tool's response format, not in a post-processing filter on the model's output. If the model never receives the sensitive data, it cannot leak it. Post-processing filters are fallible; absence is not.

If you are building AI agents for Swiss SMEs or any context where data protection law applies, this is the pattern to internalize. Tool response design is your first and best privacy control.

What We Took Away

Five lessons, in the order they'll save you time if you're building a similar system.

Design for multi-tool batches from day one. The model will emit multiple tool calls in a single turn. Your pause/resume logic must handle a pause at any position in the batch, including the first call, the last call, and the middle. Synthetic "skipped" results for deferred calls are the cleanest solution we found.

Keep UI state out of LLM history. Placeholder rows, loading indicators, optimistic updates — all of these are valid UI patterns and all of them corrupt your model's conversation if they leak into the message array. Reconstruct the model's history from a clean source, not from the same table that drives your chat UI.

Filter tools at the schema level, not at execution time. If a permission says "no writes," remove write tools from the schema before the API call. The model cannot misuse a tool it cannot see. Execution-time refusal is a backup, not a primary control.

Design tool responses as a PII boundary. Decide what the model needs to see versus what it needs to reference by ID. Return IDs and roles, not names. Return counts, not lists of individuals. The tool response format is your most reliable privacy control because it operates before the model processes the data.

Test the resume path as hard as the happy path. The initial agent loop will work. The bugs live in pause, persist, resume. Specifically: message reconstruction after resume, the tool-call-to-tool-result contract across a pause boundary, and placeholder cleanup. If your test suite doesn't cover a pause at every position in a multi-tool batch followed by a resume, you will find the bug in production.


If you're building an AI agent that needs to balance autonomy with human oversight — book a free AI Potenzial-Check and bring your architecture. We'll tell you where the pause points belong and where the bugs will hide.

NEXT STEPWas this useful?