Human-in-the-loop patterns

Last updated Oct 12, 2026 Human-in-the-loop (HITL) patterns add approval or input at different layers of an Agent. You can respond to an MCP server request, hold application work in a durable Workflow, or approve a connector call before model-generated code invokes a tool.

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

Common uses include financial approvals, content moderation, bulk data operations, approval before side-effecting tool calls, and access-control changes.

Choosing a pattern

Choose the pattern based on who introduces the pause and where it occurs:

Pattern Approval layer Initiated by Typical wait Key API
MCP elicitation MCP request handled by your Agent client MCP server developer Minutes configureElicitationHandlers()
Workflow approval Durable application task or tool operation Agent application developer Months or years waitForApproval()
Code Mode approval Connector call in model-generated code Code Mode Agent developer Until configured expiry requiresApproval, approve(), reject()

Workflow-based approval

Use Cloudflare Workflows when your application needs to hold a task or tool operation for as long as approval requires. waitForApproval() creates a durable gate backed by Cloudflare Workflows, so the wait can continue for months or longer without keeping an Agent running.

Basic pattern

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;
    }
}
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:

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(),
                    },
                ],
            });
        }
    }
}
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:

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");
}
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:

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");
        }
    }
}
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:

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 },
        });
    }
}
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

{
    "name": "expense-approval",
    "main": "src/index.ts",
    "upload_source_maps": true,
    // Set this to today's date
    "compatibility_date": "2026-10-27",
    "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"] }],
}
name = "expense-approval"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
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

MCP elicitation lets an MCP server developer decide that a tool call needs more information or an out-of-band interaction. Your Agent acts as the MCP client: it presents the request to the user and returns their response. These interactions typically complete within minutes.

Form mode collects structured, non-sensitive input. URL mode asks the user to open an out-of-band flow, such as third-party authorization or payment.