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

> Learn how to add approval gates in agent tool execution using a Thread state checker. Pause agent loops, await human confirmation, and resume processing securely. Improve agent control and reliability.

- Repository: [HumanLayer/12-factor-agents](https://github.com/humanlayer/12-factor-agents)
- Tags: how-to-guide
- Published: 2026-05-19

---

**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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/packages/create-12-factor-agent/template/src/agent.ts) to recognize it:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```bash
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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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`](https://github.com/humanlayer/12-factor-agents/blob/main/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.