---
title: Human-in-the-Loop
description: Wait for human input or external events before proceeding in your AI agent workflows.
type: guide
summary: Pause agent workflows for human approval using hooks and webhooks, then resume on input.
prerequisites:
  - /docs/ai
  - /docs/foundations/hooks
related:
  - /docs/ai/chat-session-modeling
  - /docs/api-reference/workflow/create-webhook
  - /docs/api-reference/workflow/define-hook
  - /docs/foundations/workflows-and-steps
---

# Human-in-the-Loop



A common pre-requisite for running AI agents in production is the ability to wait for human input or external events before proceeding.

Workflow SDK's [webhook](/docs/api-reference/workflow/create-webhook) and [hook](/docs/api-reference/workflow/define-hook) primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action, allowing smooth resumption of workflows even after days of inactivity, and provides stability across code deployments.

If you need to react to external events programmatically, see the [hooks](/docs/foundations/hooks) documentation for more information. This part of the guide will focus on the human-in-the-loop pattern, which is a subset of the more general hook pattern.

## How It Works

<Steps>
  <Step>
    `defineHook()` creates a typed hook that can be awaited in a workflow. When the tool is called, it creates a hook instance using the tool call ID as the token.
  </Step>

  <Step>
    The workflow pauses at `await hook` - no compute resources are consumed while waiting for the human to take action.
  </Step>

  <Step>
    The UI displays the pending tool call with its input data (flight details, price, etc.) and renders approval controls.
  </Step>

  <Step>
    The user submits their decision through an API endpoint, which resumes the hook with the approval data.
  </Step>

  <Step>
    The workflow receives the approval data and resumes execution.
  </Step>
</Steps>

While this demo will use a client side button for human approval, you could just as easily create a webhook and send the approval link over email or slack to resume the agent.

## Creating a Booking Approval Tool

Add a tool that allows the agent to deliberately pause execution until a human approves or rejects a flight booking:

<Steps>
  <Step>
    ### Define the Hook

    Create a typed hook with a Zod schema for validation:

    ```typescript title="workflow/hooks/booking-approval.ts" lineNumbers
    import { defineHook } from "workflow";
    import { z } from "zod";
    // ... existing imports ...

    export const bookingApprovalHook = defineHook({
      schema: z.object({
        approved: z.boolean(),
        comment: z.string().optional(),
      }),
    });

    // ... tool definitions ...
    ```
  </Step>

  <Step>
    ### Implement the Tool

    Create a tool that creates a hook instance using the tool call ID as the token. The UI will use this ID to submit the approval.

    {/*@skip-typecheck: incomplete code sample*/}

    ```typescript title="workflows/chat/steps/tools.ts" lineNumbers
    import { bookingApprovalHook } from "@/workflows/hooks/booking-approval"; // [!code highlight]

    // ...

    async function executeBookingApproval( // [!code highlight]
      { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number }, // [!code highlight]
      { toolCallId }: { toolCallId: string } // [!code highlight]
    ) { // [!code highlight]
      // Note: No "use step" here - hooks are workflow-level primitives // [!code highlight]

      // Use the toolCallId as the hook token so the UI can reference it // [!code highlight]
      const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight]

      // Workflow pauses here until the hook is resolved // [!code highlight]
      const { approved, comment } = await hook; // [!code highlight]

      if (!approved) {
        return `Booking rejected: ${comment || "No reason provided"}`;
      }

      return `Booking approved for ${passengerName} on flight ${flightNumber}${comment ? ` - Note: ${comment}` : ""}`;
    }

    // ...

    // Adding the tool to the existing tool definitions
    export const flightBookingTools = {
      // ... existing tool definitions ...
      bookingApproval: {
        description: "Request human approval before booking a flight",
        inputSchema: z.object({
          flightNumber: z.string().describe("Flight number to book"),
          passengerName: z.string().describe("Name of the passenger"),
          price: z.number().describe("Total price of the booking"),
        }),
        execute: executeBookingApproval,
      },
    };
    ```

    <Callout type="info">
      Note that the `defineHook().create()` function must be called from within a workflow context, not from within a step. This is why `executeBookingApproval` does not have `"use step"` - it runs in the workflow context where hooks are available.
    </Callout>
  </Step>

  <Step>
    ### Create the API Route

    Create a new API endpoint that the UI will call to submit the approval decision:

    ```typescript title="app/api/hooks/approval/route.ts" lineNumbers
    import { bookingApprovalHook } from "@/workflows/hooks/booking-approval"; // [!code highlight]

    export async function POST(request: Request) {
      const { toolCallId, approved, comment } = await request.json();

      // Schema validation happens automatically // [!code highlight]
      // Can throw a zod schema validation error, or a
      await bookingApprovalHook.resume(toolCallId, { // [!code highlight]
        approved,
        comment,
      });

      return Response.json({ success: true });
    }
    ```
  </Step>

  <Step>
    ### Create the Approval Component

    Build a new component that reacts to the tool call data, and allows the user to approve or reject the booking:

    ```typescript title="components/booking-approval.tsx" lineNumbers
    "use client";

    import { useState } from "react";

    interface BookingApprovalProps {
      toolCallId: string;
      input?: {
        flightNumber: string;
        passengerName: string;
        price: number;
      };
      output?: string;
    }

    export function BookingApproval({ toolCallId, input, output }: BookingApprovalProps) {
      const [comment, setComment] = useState("");
      const [isSubmitting, setIsSubmitting] = useState(false);

      // If we have output, the approval has been processed
      if (output) {
        return (
          <div className="border rounded-lg p-4">
            <p className="text-sm text-muted-foreground">{output}</p>
          </div>
        );
      }

      const handleSubmit = async (approved: boolean) => {
        setIsSubmitting(true);
        try {
          await fetch("/api/hooks/approval", { // [!code highlight]
            method: "POST", // [!code highlight]
            headers: { "Content-Type": "application/json" }, // [!code highlight]
            body: JSON.stringify({ toolCallId, approved, comment }), // [!code highlight]
          }); // [!code highlight]
        } finally {
          setIsSubmitting(false);
        }
      };

      return (
        <div className="border rounded-lg p-4 space-y-4">
          <div className="space-y-2">
            <p className="font-medium">Approve this booking?</p>
            <div className="text-sm text-muted-foreground">
              {input && (
                <div className="space-y-2">
                  <div>Flight: {input.flightNumber}</div>
                  <div>Passenger: {input.passengerName}</div>
                  <div>Price: ${input.price}</div>
                </div>
              )}
            </div>
          </div>

          <textarea
            value={comment}
            onChange={(e) => setComment(e.target.value)}
            placeholder="Add a comment (optional)..."
            className="w-full border rounded p-2 text-sm"
            rows={2}
          />

          <div className="flex gap-2">
            <button
              type="button"
              onClick={() => handleSubmit(true)}
              disabled={isSubmitting}
              className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
            >
              {isSubmitting ? "Submitting..." : "Approve"}
            </button>
            <button
              type="button"
              onClick={() => handleSubmit(false)}
              disabled={isSubmitting}
              className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
            >
              {isSubmitting ? "Submitting..." : "Reject"}
            </button>
          </div>
        </div>
      );
    }
    ```
  </Step>

  <Step>
    ### Show the Tool Status in the UI

    Use the component we just created to render the tool call and approval controls in your chat interface:

    {/*@skip-typecheck: incomplete code sample*/}

    ```typescript title="app/page.tsx" lineNumbers
    // ... existing imports ...
    import { BookingApproval } from "@/components/booking-approval";

    export default function ChatPage() {

      // ...

      const { stop, messages, sendMessage, status, setMessages } =
        useChat<MyUIMessage>({
          // ... options
        });

      // ...

      return (
        <div className="flex flex-col w-full max-w-2xl pt-12 pb-24 mx-auto stretch">
          // ...

          <Conversation className="mb-10">
            <ConversationContent>
              {messages.map((message, index) => {
                const hasText = message.parts.some((part) => part.type === "text");

                return (
                  <div key={message.id}>
                    // ...
                    <Message from={message.role}>
                      <MessageContent>
                        {message.parts.map((part, partIndex) => {

                          // ...

                          if (
                            part.type === "tool-searchFlights" ||
                            part.type === "tool-checkFlightStatus" ||
                            part.type === "tool-getAirportInfo" ||
                            part.type === "tool-bookFlight" ||
                            part.type === "tool-checkBaggageAllowance"
                          ) {
                            // ... render other tools
                          }
                          if (part.type === "tool-bookingApproval") { // [!code highlight]
                            return ( // [!code highlight]
                              <BookingApproval // [!code highlight]
                                key={partIndex} // [!code highlight]
                                toolCallId={part.toolCallId} // [!code highlight]
                                input={part.input as any} // [!code highlight]
                                output={part.output as any} // [!code highlight]
                              /> // [!code highlight]
                            ); // [!code highlight]
                          } // [!code highlight]
                          return null;
                        })}
                      </MessageContent>
                    </Message>
                  </div>
                );
              })}
            </ConversationContent>
            <ConversationScrollButton />
          </Conversation>

          // ...
        </div>
      );
    }
    ```
  </Step>
</Steps>

## Using Webhooks Directly

For simpler cases where you don't need type-safe validation or programmatic resumption, you can use [`createWebhook()`](/docs/api-reference/workflow/create-webhook) directly. This generates a unique URL that can be called to resume the workflow:

```typescript title="workflows/chat/steps/tools.ts" lineNumbers
import { createWebhook } from "workflow";
import { z } from "zod";

async function executeBookingApproval(
  { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  const webhook = createWebhook(); // [!code highlight]

  // The webhook URL could be logged, sent via email, or stored for later use
  console.log("Approval URL:", webhook.url);

  // Workflow pauses here until the webhook is called // [!code highlight]
  const request = await webhook; // [!code highlight]
  const { approved, comment } = await request.json(); // [!code highlight]

  if (!approved) {
    return `Booking rejected: ${comment || "No reason provided"}`;
  }

  return `Booking approved for ${passengerName} on flight ${flightNumber}`;
}
```

The webhook URL can be called directly with a POST request containing the approval data. This is useful for:

* External systems that need to call back into your workflow
* Payment provider callbacks
* Email-based approval links

## Related Documentation

* [Hooks & Webhooks](/docs/foundations/hooks) - Complete guide to hooks and webhooks
* [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook) - Webhook configuration options
* [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Type-safe hook definitions


## Sitemap
[Overview of all docs pages](/sitemap.md)
