---
title: Human-in-the-loop patterns
description: Implement human-in-the-loop functionality using Cloudflare Agents for workflow approvals and MCP elicitation
image: https://developers.cloudflare.com/dev-products-preview.png
---

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/agents/llms.txt  
> Use this file to discover all available pages before exploring further.

[Skip to content](#%5Ftop) 

# Human-in-the-loop patterns

Human-in-the-loop (HITL) patterns allow agents to pause execution and wait for human approval, confirmation, or input before proceeding. This is essential for compliance, safety, and oversight in agentic systems.

## Why human-in-the-loop?

* **Compliance**: Regulatory requirements may mandate human approval for certain actions
* **Safety**: High-stakes operations (payments, deletions, external communications) need oversight
* **Quality**: Human review catches errors AI might miss
* **Trust**: Users feel more confident when they can approve critical actions

### Common use cases

| Use Case            | Example                              |
| ------------------- | ------------------------------------ |
| Financial approvals | Expense reports, payment processing  |
| Content moderation  | Publishing, email sending            |
| Data operations     | Bulk deletions, exports              |
| AI tool execution   | Confirming tool calls before running |
| Access control      | Granting permissions, role changes   |

## Choosing a pattern

Cloudflare provides two main patterns for human-in-the-loop:

| Pattern               | Best for                                     | Key API           |
| --------------------- | -------------------------------------------- | ----------------- |
| **Workflow approval** | Multi-step processes, durable approval gates | waitForApproval() |
| **MCP elicitation**   | MCP servers requesting structured user input | elicitInput()     |

Decision guide:

* Use **Workflow approval** when you need durable, multi-step processes with approval gates that can wait hours, days, or weeks
* Use **MCP elicitation** when building MCP servers that need to request additional structured input from users during tool execution

## Workflow-based approval

For durable, multi-step processes, use [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) with the `waitForApproval()` method. The workflow pauses until a human approves or rejects.

### Basic pattern

* [  JavaScript ](#tab-panel-4236)
* [  TypeScript ](#tab-panel-4237)

JavaScript

```

import { Agent } from "agents";

import { AgentWorkflow } from "agents/workflows";


export class ExpenseWorkflow extends AgentWorkflow {

  async run(event, step) {

    const expense = event.payload;


    // Step 1: Validate the expense

    const validated = await step.do("validate", async () => {

      if (expense.amount <= 0) {

        throw new Error("Invalid expense amount");

      }

      return { ...expense, validatedAt: Date.now() };

    });


    // Step 2: Report that we are waiting for approval

    await this.reportProgress({

      step: "approval",

      status: "pending",

      message: `Awaiting approval for $${expense.amount}`,

    });


    // Step 3: Wait for human approval (pauses the workflow)

    const approval = await this.waitForApproval(step, {

      timeout: "7 days",

    });


    console.log(`Approved by: ${approval?.approvedBy}`);


    // Step 4: Process the approved expense

    const result = await step.do("process", async () => {

      return { expenseId: crypto.randomUUID(), ...validated };

    });


    await step.reportComplete(result);

    return result;

  }

}


```

TypeScript

```

import { Agent } from "agents";

import { AgentWorkflow } from "agents/workflows";

import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";


type ExpenseParams = {

  amount: number;

  description: string;

  requestedBy: string;

};


export class ExpenseWorkflow extends AgentWorkflow<

  ExpenseAgent,

  ExpenseParams

> {

  async run(event: AgentWorkflowEvent<ExpenseParams>, step: AgentWorkflowStep) {

    const expense = event.payload;


    // Step 1: Validate the expense

    const validated = await step.do("validate", async () => {

      if (expense.amount <= 0) {

        throw new Error("Invalid expense amount");

      }

      return { ...expense, validatedAt: Date.now() };

    });


    // Step 2: Report that we are waiting for approval

    await this.reportProgress({

      step: "approval",

      status: "pending",

      message: `Awaiting approval for $${expense.amount}`,

    });


    // Step 3: Wait for human approval (pauses the workflow)

    const approval = await this.waitForApproval<{ approvedBy: string }>(step, {

      timeout: "7 days",

    });


    console.log(`Approved by: ${approval?.approvedBy}`);


    // Step 4: Process the approved expense

    const result = await step.do("process", async () => {

      return { expenseId: crypto.randomUUID(), ...validated };

    });


    await step.reportComplete(result);

    return result;

  }

}


```

### Agent methods for approval

The agent provides methods to approve or reject waiting workflows:

* [  JavaScript ](#tab-panel-4240)
* [  TypeScript ](#tab-panel-4241)

JavaScript

```

import { Agent, callable } from "agents";


export class ExpenseAgent extends Agent {

  initialState = {

    pendingApprovals: [],

  };


  // Approve a waiting workflow

  @callable()

  async approve(workflowId, approvedBy) {

    await this.approveWorkflow(workflowId, {

      reason: "Expense approved",

      metadata: { approvedBy, approvedAt: Date.now() },

    });


    // Update state to reflect approval

    this.setState({

      ...this.state,

      pendingApprovals: this.state.pendingApprovals.filter(

        (p) => p.workflowId !== workflowId,

      ),

    });

  }


  // Reject a waiting workflow

  @callable()

  async reject(workflowId, reason) {

    await this.rejectWorkflow(workflowId, { reason });


    this.setState({

      ...this.state,

      pendingApprovals: this.state.pendingApprovals.filter(

        (p) => p.workflowId !== workflowId,

      ),

    });

  }


  // Track workflow progress to update pending approvals

  async onWorkflowProgress(workflowName, workflowId, progress) {

    const p = progress;


    if (p.step === "approval" && p.status === "pending") {

      // Add to pending approvals list for UI display

      this.setState({

        ...this.state,

        pendingApprovals: [

          ...this.state.pendingApprovals,

          {

            workflowId,

            amount: 0, // Would come from workflow params

            description: p.message || "",

            requestedBy: "user",

            requestedAt: Date.now(),

          },

        ],

      });

    }

  }

}


```

TypeScript

```

import { Agent, callable } from "agents";


type PendingApproval = {

  workflowId: string;

  amount: number;

  description: string;

  requestedBy: string;

  requestedAt: number;

};


type ExpenseState = {

  pendingApprovals: PendingApproval[];

};


export class ExpenseAgent extends Agent<Env, ExpenseState> {

  initialState: ExpenseState = {

    pendingApprovals: [],

  };


  // Approve a waiting workflow

  @callable()

  async approve(workflowId: string, approvedBy: string): Promise<void> {

    await this.approveWorkflow(workflowId, {

      reason: "Expense approved",

      metadata: { approvedBy, approvedAt: Date.now() },

    });


    // Update state to reflect approval

    this.setState({

      ...this.state,

      pendingApprovals: this.state.pendingApprovals.filter(

        (p) => p.workflowId !== workflowId,

      ),

    });

  }


  // Reject a waiting workflow

  @callable()

  async reject(workflowId: string, reason: string): Promise<void> {

    await this.rejectWorkflow(workflowId, { reason });


    this.setState({

      ...this.state,

      pendingApprovals: this.state.pendingApprovals.filter(

        (p) => p.workflowId !== workflowId,

      ),

    });

  }


  // Track workflow progress to update pending approvals

  async onWorkflowProgress(

    workflowName: string,

    workflowId: string,

    progress: unknown,

  ): Promise<void> {

    const p = progress as { step: string; status: string; message?: string };


    if (p.step === "approval" && p.status === "pending") {

      // Add to pending approvals list for UI display

      this.setState({

        ...this.state,

        pendingApprovals: [

          ...this.state.pendingApprovals,

          {

            workflowId,

            amount: 0, // Would come from workflow params

            description: p.message || "",

            requestedBy: "user",

            requestedAt: Date.now(),

          },

        ],

      });

    }

  }

}


```

### Timeout handling

Set timeouts to prevent workflows from waiting indefinitely:

* [  JavaScript ](#tab-panel-4230)
* [  TypeScript ](#tab-panel-4231)

JavaScript

```

const approval = await this.waitForApproval(step, {

  timeout: "7 days", // Also supports: "1 hour", "30 minutes", etc.

});


if (!approval) {

  // Timeout expired - escalate or auto-reject

  await step.reportError("Approval timeout - escalating to manager");

  throw new Error("Approval timeout");

}


```

TypeScript

```

const approval = await this.waitForApproval<{ approvedBy: string }>(step, {

  timeout: "7 days", // Also supports: "1 hour", "30 minutes", etc.

});


if (!approval) {

  // Timeout expired - escalate or auto-reject

  await step.reportError("Approval timeout - escalating to manager");

  throw new Error("Approval timeout");

}


```

### Escalation with scheduling

Use `schedule()` to set up escalation reminders:

* [  JavaScript ](#tab-panel-4232)
* [  TypeScript ](#tab-panel-4233)

JavaScript

```

import { Agent, callable } from "agents";


class ExpenseAgent extends Agent {

  @callable()

  async submitForApproval(expense) {

    // Start the approval workflow

    const workflowId = await this.runWorkflow("EXPENSE_WORKFLOW", expense);


    // Schedule reminder after 4 hours

    await this.schedule(Date.now() + 4 * 60 * 60 * 1000, "sendReminder", {

      workflowId,

    });


    // Schedule escalation after 24 hours

    await this.schedule(Date.now() + 24 * 60 * 60 * 1000, "escalateApproval", {

      workflowId,

    });


    return workflowId;

  }


  async sendReminder(payload) {

    const workflow = this.getWorkflow(payload.workflowId);

    if (workflow?.status === "waiting") {

      // Send reminder notification

      console.log("Reminder: approval still pending");

    }

  }


  async escalateApproval(payload) {

    const workflow = this.getWorkflow(payload.workflowId);

    if (workflow?.status === "waiting") {

      // Escalate to manager

      console.log("Escalating to manager");

    }

  }

}


```

TypeScript

```

import { Agent, callable } from "agents";


class ExpenseAgent extends Agent<Env, ExpenseState> {

  @callable()

  async submitForApproval(expense: ExpenseParams): Promise<string> {

    // Start the approval workflow

    const workflowId = await this.runWorkflow("EXPENSE_WORKFLOW", expense);


    // Schedule reminder after 4 hours

    await this.schedule(Date.now() + 4 * 60 * 60 * 1000, "sendReminder", {

      workflowId,

    });


    // Schedule escalation after 24 hours

    await this.schedule(Date.now() + 24 * 60 * 60 * 1000, "escalateApproval", {

      workflowId,

    });


    return workflowId;

  }


  async sendReminder(payload: { workflowId: string }) {

    const workflow = this.getWorkflow(payload.workflowId);

    if (workflow?.status === "waiting") {

      // Send reminder notification

      console.log("Reminder: approval still pending");

    }

  }


  async escalateApproval(payload: { workflowId: string }) {

    const workflow = this.getWorkflow(payload.workflowId);

    if (workflow?.status === "waiting") {

      // Escalate to manager

      console.log("Escalating to manager");

    }

  }

}


```

### Audit trail with SQL

Use `this.sql` to maintain an immutable audit trail:

* [  JavaScript ](#tab-panel-4234)
* [  TypeScript ](#tab-panel-4235)

JavaScript

```

import { Agent, callable } from "agents";


class ExpenseAgent extends Agent {

  async onStart() {

    // Create audit table

    this.sql`

      CREATE TABLE IF NOT EXISTS approval_audit (

        id INTEGER PRIMARY KEY AUTOINCREMENT,

        workflow_id TEXT NOT NULL,

        decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')),

        decided_by TEXT NOT NULL,

        decided_at INTEGER NOT NULL,

        reason TEXT

      )

    `;

  }


  @callable()

  async approve(workflowId, userId, reason) {

    // Record the decision in SQL (immutable audit log)

    this.sql`

      INSERT INTO approval_audit (workflow_id, decision, decided_by, decided_at, reason)

      VALUES (${workflowId}, 'approved', ${userId}, ${Date.now()}, ${reason || null})

    `;


    // Process the approval

    await this.approveWorkflow(workflowId, {

      reason: reason || "Approved",

      metadata: { approvedBy: userId },

    });

  }

}


```

TypeScript

```

import { Agent, callable } from "agents";


class ExpenseAgent extends Agent<Env, ExpenseState> {

  async onStart() {

    // Create audit table

    this.sql`

      CREATE TABLE IF NOT EXISTS approval_audit (

        id INTEGER PRIMARY KEY AUTOINCREMENT,

        workflow_id TEXT NOT NULL,

        decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')),

        decided_by TEXT NOT NULL,

        decided_at INTEGER NOT NULL,

        reason TEXT

      )

    `;

  }


  @callable()

  async approve(

    workflowId: string,

    userId: string,

    reason?: string,

  ): Promise<void> {

    // Record the decision in SQL (immutable audit log)

    this.sql`

      INSERT INTO approval_audit (workflow_id, decision, decided_by, decided_at, reason)

      VALUES (${workflowId}, 'approved', ${userId}, ${Date.now()}, ${reason || null})

    `;


    // Process the approval

    await this.approveWorkflow(workflowId, {

      reason: reason || "Approved",

      metadata: { approvedBy: userId },

    });

  }

}


```

### Configuration

* [  wrangler.jsonc ](#tab-panel-4228)
* [  wrangler.toml ](#tab-panel-4229)

JSONC

```

{

  "name": "expense-approval",

  "main": "src/index.ts",

  // Set this to today's date

  "compatibility_date": "2026-05-08",

  "compatibility_flags": ["nodejs_compat"],

  "durable_objects": {

    "bindings": [{ "name": "EXPENSE_AGENT", "class_name": "ExpenseAgent" }],

  },

  "workflows": [

    {

      "name": "expense-workflow",

      "binding": "EXPENSE_WORKFLOW",

      "class_name": "ExpenseWorkflow",

    },

  ],

  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["ExpenseAgent"] }],

}


```

TOML

```

name = "expense-approval"

main = "src/index.ts"

# Set this to today's date

compatibility_date = "2026-05-08"

compatibility_flags = [ "nodejs_compat" ]


[[durable_objects.bindings]]

name = "EXPENSE_AGENT"

class_name = "ExpenseAgent"


[[workflows]]

name = "expense-workflow"

binding = "EXPENSE_WORKFLOW"

class_name = "ExpenseWorkflow"


[[migrations]]

tag = "v1"

new_sqlite_classes = [ "ExpenseAgent" ]


```

## MCP elicitation

When building MCP servers with `McpAgent`, you can request additional user input during tool execution using **elicitation**. The MCP client renders a form based on your JSON Schema and returns the user's response.

### Basic pattern

* [  JavaScript ](#tab-panel-4242)
* [  TypeScript ](#tab-panel-4243)

JavaScript

```

import { McpAgent } from "agents/mcp";

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { z } from "zod";


export class CounterMCP extends McpAgent {

  server = new McpServer({

    name: "counter-server",

    version: "1.0.0",

  });


  initialState = { counter: 0 };


  async init() {

    this.server.tool(

      "increase-counter",

      "Increase the counter by a user-specified amount",

      { confirm: z.boolean().describe("Do you want to increase the counter?") },

      async ({ confirm }, extra) => {

        if (!confirm) {

          return { content: [{ type: "text", text: "Cancelled." }] };

        }


        // Request additional input from the user

        const userInput = await this.server.server.elicitInput(

          {

            message: "By how much do you want to increase the counter?",

            requestedSchema: {

              type: "object",

              properties: {

                amount: {

                  type: "number",

                  title: "Amount",

                  description: "The amount to increase the counter by",

                },

              },

              required: ["amount"],

            },

          },

          { relatedRequestId: extra.requestId },

        );


        // Check if user accepted or cancelled

        if (userInput.action !== "accept" || !userInput.content) {

          return { content: [{ type: "text", text: "Cancelled." }] };

        }


        // Use the input

        const amount = Number(userInput.content.amount);

        this.setState({

          ...this.state,

          counter: this.state.counter + amount,

        });


        return {

          content: [

            {

              type: "text",

              text: `Counter increased by ${amount}, now at ${this.state.counter}`,

            },

          ],

        };

      },

    );

  }

}


```

TypeScript

```

import { McpAgent } from "agents/mcp";

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

import { z } from "zod";


type State = { counter: number };


export class CounterMCP extends McpAgent<Env, State, {}> {

  server = new McpServer({

    name: "counter-server",

    version: "1.0.0",

  });


  initialState: State = { counter: 0 };


  async init() {

    this.server.tool(

      "increase-counter",

      "Increase the counter by a user-specified amount",

      { confirm: z.boolean().describe("Do you want to increase the counter?") },

      async ({ confirm }, extra) => {

        if (!confirm) {

          return { content: [{ type: "text", text: "Cancelled." }] };

        }


        // Request additional input from the user

        const userInput = await this.server.server.elicitInput(

          {

            message: "By how much do you want to increase the counter?",

            requestedSchema: {

              type: "object",

              properties: {

                amount: {

                  type: "number",

                  title: "Amount",

                  description: "The amount to increase the counter by",

                },

              },

              required: ["amount"],

            },

          },

          { relatedRequestId: extra.requestId },

        );


        // Check if user accepted or cancelled

        if (userInput.action !== "accept" || !userInput.content) {

          return { content: [{ type: "text", text: "Cancelled." }] };

        }


        // Use the input

        const amount = Number(userInput.content.amount);

        this.setState({

          ...this.state,

          counter: this.state.counter + amount,

        });


        return {

          content: [

            {

              type: "text",

              text: `Counter increased by ${amount}, now at ${this.state.counter}`,

            },

          ],

        };

      },

    );

  }

}


```

## Elicitation vs workflow approval

| Aspect       | MCP Elicitation               | Workflow Approval             |
| ------------ | ----------------------------- | ----------------------------- |
| **Context**  | MCP server tool execution     | Multi-step workflow processes |
| **Duration** | Immediate (within tool call)  | Can wait hours/days/weeks     |
| **UI**       | JSON Schema-based form        | Custom UI via agent state     |
| **State**    | MCP session state             | Durable workflow state        |
| **Use case** | Interactive input during tool | Approval gates in pipelines   |

## Building approval UIs

### Pending approvals list

Use the agent's state to display pending approvals in your UI:

```

import { useAgent } from "agents/react";


function PendingApprovals() {

  const { state, agent } = useAgent({

    agent: "expense-agent",

    name: "main",

  });


  if (!state?.pendingApprovals?.length) {

    return <p>No pending approvals</p>;

  }


  return (

    <div className="approval-list">

      {state.pendingApprovals.map((item) => (

        <div key={item.workflowId} className="approval-card">

          <h3>${item.amount}</h3>

          <p>{item.description}</p>

          <p>Requested by {item.requestedBy}</p>


          <div className="actions">

            <button

              onClick={() => agent.stub.approve(item.workflowId, "admin")}

            >

              Approve

            </button>

            <button

              onClick={() => agent.stub.reject(item.workflowId, "Declined")}

            >

              Reject

            </button>

          </div>

        </div>

      ))}

    </div>

  );

}


```

## Multi-approver patterns

For sensitive operations requiring multiple approvers:

* [  JavaScript ](#tab-panel-4238)
* [  TypeScript ](#tab-panel-4239)

JavaScript

```

import { Agent, callable } from "agents";


class MultiApprovalAgent extends Agent {

  @callable()

  async approveMulti(workflowId, userId) {

    const approval = this.state.pendingMultiApprovals.find(

      (p) => p.workflowId === workflowId,

    );

    if (!approval) throw new Error("Approval not found");


    // Check if user already approved

    if (approval.currentApprovals.some((a) => a.userId === userId)) {

      throw new Error("Already approved by this user");

    }


    // Add this user's approval

    approval.currentApprovals.push({ userId, approvedAt: Date.now() });


    // Check if we have enough approvals

    if (approval.currentApprovals.length >= approval.requiredApprovals) {

      // Execute the approved action

      await this.approveWorkflow(workflowId, {

        metadata: { approvers: approval.currentApprovals },

      });

      return true;

    }


    this.setState({ ...this.state });

    return false; // Still waiting for more approvals

  }

}


```

TypeScript

```

import { Agent, callable } from "agents";


type MultiApproval = {

  workflowId: string;

  requiredApprovals: number;

  currentApprovals: Array<{ userId: string; approvedAt: number }>;

  rejections: Array<{ userId: string; rejectedAt: number; reason: string }>;

};


type State = {

  pendingMultiApprovals: MultiApproval[];

};


class MultiApprovalAgent extends Agent<Env, State> {

  @callable()

  async approveMulti(workflowId: string, userId: string): Promise<boolean> {

    const approval = this.state.pendingMultiApprovals.find(

      (p) => p.workflowId === workflowId,

    );

    if (!approval) throw new Error("Approval not found");


    // Check if user already approved

    if (approval.currentApprovals.some((a) => a.userId === userId)) {

      throw new Error("Already approved by this user");

    }


    // Add this user's approval

    approval.currentApprovals.push({ userId, approvedAt: Date.now() });


    // Check if we have enough approvals

    if (approval.currentApprovals.length >= approval.requiredApprovals) {

      // Execute the approved action

      await this.approveWorkflow(workflowId, {

        metadata: { approvers: approval.currentApprovals },

      });

      return true;

    }


    this.setState({ ...this.state });

    return false; // Still waiting for more approvals

  }

}


```

## Best practices

1. **Define clear approval criteria** — Only require confirmation for actions with meaningful consequences (payments, emails, data changes)
2. **Provide detailed context** — Show users exactly what the action will do, including all arguments
3. **Implement timeouts** — Use `schedule()` to escalate or auto-reject after reasonable periods
4. **Maintain audit trails** — Use `this.sql` to record all approval decisions for compliance
5. **Handle connection drops** — Store pending approvals in agent state so they survive disconnections
6. **Graceful degradation** — Provide fallback behavior if approvals are rejected

## Next steps

[ Run Workflows ](https://developers.cloudflare.com/agents/api-reference/run-workflows/) Complete waitForApproval() API reference. 

[ MCP servers ](https://developers.cloudflare.com/agents/api-reference/mcp-agent-api/) Build MCP agents with elicitation. 

[ Email notifications ](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/) Send notifications for pending approvals. 

[ Schedule tasks ](https://developers.cloudflare.com/agents/api-reference/schedule-tasks/) Implement approval timeouts with schedules. 

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/agents/","name":"Agents"}},{"@type":"ListItem","position":3,"item":{"@id":"/agents/guides/","name":"Guides"}},{"@type":"ListItem","position":4,"item":{"@id":"/agents/guides/human-in-the-loop/","name":"Human-in-the-loop patterns"}}]}
```
