How to Add Approval Gates in Agent Tool Execution: A 12-Factor Agents Guide

To add approval gates in agent tool execution, implement a Thread state checker that detects gated intents, pause the agent loop to await human confirmation, and resume processing via a server endpoint that handles approval or denial payloads.

Agents in the 12-Factor Agents framework execute tools inside an LLM-driven loop, but risky operations like financial transactions or data deletion require human oversight. Adding approval gates creates a human-in-the-loop pause that prevents autonomous execution of sensitive tools until explicit confirmation is received. This pattern, as implemented in the humanlayer/12-factor-agents repository, relies on three coordinated components: thread state detection, a pausable agent loop, and a resumable server endpoint.

The Three-Component Architecture

The approval gate pattern consists of three integrated parts that work together to safely interrupt and resume execution.

Thread State – The Thread class provides an awaitingHumanApproval() method that inspects the last event to determine if the agent is paused for human review. According to the source in packages/create-12-factor-agent/template/src/agent.ts (lines 39-42), this method checks whether the latest intent matches any gated tool identifiers.

Server Endpoint – A POST handler at /thread/:id/response receives approval payloads, decides whether to execute the tool or record a denial, and resumes the agent loop. The implementation in workshops/2025-05/sections/11-humanlayer-approval/src/server.ts (lines 46-84) handles the generic approval logic without requiring tool-specific modifications.

Agent Loop – The LLM emits a special intent (such as request_approval_from_manager or a custom tool name) that triggers the loop to return the thread to the client rather than executing immediately. In packages/create-12-factor-agent/template/src/agent.ts (lines 99-106), the switch case detects these intents and pauses execution by returning the current thread state.

Step-by-Step Execution Flow

Understanding the exact sequence helps implement reliable approval gates that maintain conversation context.

  1. LLM Emits Gated Intent – When the model decides to use a protected tool, it returns an intent such as divide or delete_resource. The agentLoop detects this in its switch statement and returns the thread immediately, pausing execution.

  2. Thread Marks Awaiting Approval – The Thread.awaitingHumanApproval() method returns true because the last event's intent matches the gated list. This signal tells the server that human input is required before proceeding.

  3. Server Returns Response URL – The server attaches a response_url to the last event so the client knows where to POST the approval decision later (see server.ts lines 24-27).

  4. Human Submits Approval Payload – The client sends a JSON object with the structure {type: "approval", approved: boolean, comment?: string} to the response endpoint.

  5. Server Processes Decision – If approved is true, the server calls handleNextStep to execute the original tool and push the result. If false, it appends a feedback event recording the denial without executing the tool (see server.ts lines 70-84).

  6. Loop Resumes – After handling the payload, the server calls agentLoop(thread) again, allowing the LLM to continue with the tool result or denial message in context (see server.ts lines 94-98).

Adding a New Approval Gate

Extending the pattern to protect additional tools requires minimal changes to two files.

First, define the gated intent name (e.g., "delete_resource") and update the awaitingHumanApproval() method in packages/create-12-factor-agent/template/src/agent.ts to recognize it:

awaitingHumanApproval(): boolean {
  const last = this.events[this.events.length - 1];
  return ['divide', 'delete_resource'].includes(last.data.intent);
}

Next, add a corresponding branch in the agentLoop switch statement that returns the thread when encountering the new intent:

case "delete_resource":
  // pause for human approval
  return thread;

No server changes are required. The generic approval handler in /thread/:id/response automatically processes any payload matching awaitingHumanApproval() by checking the approved boolean and either executing the tool or recording the rejection.

Complete Code Examples

Detecting Approval State in the Thread Class

The Thread class maintains the conversation history and exposes the approval check:

// packages/create-12-factor-agent/template/src/agent.ts
class Thread {
  events: Event[];

  awaitingHumanApproval(): boolean {
    const last = this.events[this.events - 1];
    return ['divide', 'delete_resource'].includes(last.data.intent);
  }
}

Pausable Agent Loop Implementation

The loop returns control to the caller when encountering gated intents:

// packages/create-12-factor-agent/template/src/agent.ts
async function agentLoop(thread: Thread): Promise<Thread> {
  while (true) {
    const nextStep = await b.DetermineNextStep(thread.serializeForLLM());
    thread.events.push({ type: "tool_call", data: nextStep });

    switch (nextStep.intent) {
      case "request_approval_from_manager":
      case "delete_resource":          // ← custom gated tool
        // pause, let the client approve
        return thread;
      // ... regular tool cases ...
      default:
        thread = await handleNextStep(nextStep, thread);
    }
  }
}

Server Endpoint for Approval Processing

The Express handler processes approval payloads and resumes the loop:

// workshops/2025-05/sections/11-humanlayer-approval/src/server.ts
app.post('/thread/:id/response', async (req, res) => {
  const thread = store.get(req.params.id);
  const body: Payload = req.body;
  const lastEvent = thread.events.at(-1);

  if (thread.awaitingHumanApproval() && body.type === 'approval') {
    if (body.approved) {
      // Run the original tool and push its result
      await handleNextStep(lastEvent.data, thread);
    } else {
      // Record denial with optional comment
      thread.events.push({
        type: "tool_response",
        data: `user denied the operation: "${body.comment}"`
      });
    }
  }
  
  // Resume the agent loop
  const newThread = await agentLoop(thread);
  res.json(newThread);
});

Client Approval Request

Send the approval decision via HTTP POST:

curl -X POST http://localhost:3000/thread/42/response \
  -H "Content-Type: application/json" \
  -d '{
        "type": "approval",
        "approved": true,
        "comment": "Looks good, proceed."
      }'

Summary

  • Approval gates in 12-Factor Agents use a pause-and-resume pattern rather than blocking synchronous calls.
  • The Thread.awaitingHumanApproval() method in packages/create-12-factor-agent/template/src/agent.ts determines when human confirmation is required by checking the latest intent.
  • The agent loop returns the thread immediately when encountering gated intents, allowing the server to persist state and wait for external input.
  • The server endpoint at /thread/:id/response handles generic approval payloads with approved boolean and optional comment fields.
  • Adding new gates requires only updating the intent list in awaitingHumanApproval() and adding a return case in agentLoop, with no changes needed to the server handler.

Frequently Asked Questions

What happens when a user denies an approval request?

When the payload contains approved: false, the server skips tool execution and appends a denial event to the thread history. According to workshops/2025-05/sections/11-humanlayer-approval/src/server.ts (lines 76-80), the system records the user's comment (if provided) and resumes the loop, allowing the LLM to see the rejection and potentially suggest alternatives.

Can I add approval gates to any tool in my agent?

Yes. You can gate any tool by adding its intent name to the array checked by awaitingHumanApproval() and including a corresponding case in the agentLoop switch statement that returns the thread. The generic approval handler processes all gates uniformly, making the pattern reusable across diverse tool types.

Do I need to modify the server for each new approval gate?

No. The server endpoint at /thread/:id/response handles all approval gates generically. As long as your Thread class correctly reports awaitingHumanApproval() for the new intent, the existing logic in server.ts will process the approval or denial without additional configuration.

How does the thread maintain context while waiting for approval?

The thread serializes its complete event history, including the pending tool call that triggered the gate. When stored via the store mechanism and retrieved in the response handler, the full conversation state persists. This allows the agentLoop to resume exactly where it paused, with the LLM receiving the tool result or denial message as the next event in the sequence.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →