Skip to main content
A common requirement for production AI agents is the ability to wait for human input or external events before proceeding. Workflow DevKit’s webhook and 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, with stability across code deployments.

How It Works

1
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.
2
The workflow pauses at await hook - no compute resources are consumed while waiting.
3
The UI displays the pending tool call with its input data and renders approval controls.
4
The user submits their decision through an API endpoint, which resumes the hook with the approval data.
5
The workflow receives the approval data and resumes execution.

Creating an Approval Tool

Add a tool that allows the agent to deliberately pause execution until a human approves or rejects an action:
1

Define the Hook

Create a typed hook with a Zod schema for validation:
workflows/hooks/booking-approval.ts
import { defineHook } from "workflow";
import { z } from "zod";

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

Implement the Tool

Create a tool that creates a hook instance using the tool call ID as the token:
workflows/chat/steps/tools.ts
import { bookingApprovalHook } from "@/workflows/hooks/booking-approval";
import { z } from "zod";

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

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

  // Workflow pauses here until the hook is resolved
  const { approved, comment } = await hook;

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

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

// Add to tool definitions
export const flightBookingTools = {
  // ... existing tools ...
  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,
  },
};
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.
3

Create the API Route

Create an API endpoint that the UI will call to submit the approval decision:
app/api/hooks/approval/route.ts
import { bookingApprovalHook } from "@/workflows/hooks/booking-approval";

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

  // Schema validation happens automatically
  await bookingApprovalHook.resume(toolCallId, {
    approved,
    comment,
  });

  return Response.json({ success: true });
}
4

Create the Approval Component

Build a component that reacts to the tool call data:
components/booking-approval.tsx
"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", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ toolCallId, approved, comment }),
      });
    } 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>
        {input && (
          <div className="text-sm text-muted-foreground space-y-2">
            <div>Flight: {input.flightNumber}</div>
            <div>Passenger: {input.passengerName}</div>
            <div>Price: ${input.price}</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>
  );
}
5

Render in the Chat UI

Use the component to render the tool call and approval controls:
app/page.tsx
import { BookingApproval } from "@/components/booking-approval";

export default function ChatPage() {
  const { messages } = useChat();

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, partIndex) => {
            // ... other rendering logic ...

            if (part.type === "tool-bookingApproval") {
              return (
                <BookingApproval
                  key={partIndex}
                  toolCallId={part.toolCallId}
                  input={part.input as any}
                  output={part.output as any}
                />
              );
            }

            return null;
          })}
        </div>
      ))}
    </div>
  );
}

Using Webhooks Directly

For simpler cases where you don’t need type-safe validation, use createWebhook() directly:
workflows/chat/steps/tools.ts
import { createWebhook } from "workflow";

async function executeBookingApproval(
  { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  const webhook = createWebhook();

  // 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
  const request = await webhook;
  const { approved, comment } = await request.json();

  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
  • Slack or Teams integrations

Advanced Patterns

Timeout with Default Action

Combine hooks with sleep() to implement timeouts:
lineNumbers
import { sleep } from "workflow";

async function executeBookingWithTimeout(
  { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  const hook = bookingApprovalHook.create({ token: toolCallId });

  // Race between approval and timeout
  const result = await Promise.race([
    hook.then((data) => ({ type: 'approval', data })),
    sleep("1h").then(() => ({ type: 'timeout' })),
  ]);

  if (result.type === 'timeout') {
    return "Booking request timed out after 1 hour. Auto-rejected.";
  }

  const { approved, comment } = result.data;
  if (!approved) {
    return `Booking rejected: ${comment || "No reason provided"}`;
  }

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

Multi-Step Approval

Implement multi-level approvals:
lineNumbers
async function executeMultiStepApproval(
  { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  // Manager approval
  const managerHook = defineHook({ schema: z.object({ approved: z.boolean() }) });
  const managerApproval = managerHook.create({ token: `${toolCallId}-manager` });
  const { approved: managerApproved } = await managerApproval;

  if (!managerApproved) {
    return "Booking rejected by manager";
  }

  // Finance approval (only if price > $1000)
  if (price > 1000) {
    const financeHook = defineHook({ schema: z.object({ approved: z.boolean() }) });
    const financeApproval = financeHook.create({ token: `${toolCallId}-finance` });
    const { approved: financeApproved } = await financeApproval;

    if (!financeApproved) {
      return "Booking rejected by finance";
    }
  }

  return `Booking approved for ${passengerName} on flight ${flightNumber}`;
}
Send approval links via email using webhooks:
lineNumbers
import { createWebhook } from "workflow";

async function executeEmailApproval(
  { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  const approveWebhook = createWebhook();
  const rejectWebhook = createWebhook();

  // Send email with approval/reject links
  await sendApprovalEmail({
    to: "[email protected]",
    approveUrl: approveWebhook.url,
    rejectUrl: rejectWebhook.url,
    details: { flightNumber, passengerName, price },
  });

  // Wait for either webhook to be called
  const result = await Promise.race([
    approveWebhook.then(() => ({ approved: true })),
    rejectWebhook.then(() => ({ approved: false })),
  ]);

  if (!result.approved) {
    return "Booking rejected";
  }

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

async function sendApprovalEmail(params: {
  to: string;
  approveUrl: string;
  rejectUrl: string;
  details: any;
}) {
  "use step";

  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      personalizations: [{ to: [{ email: params.to }] }],
      from: { email: "[email protected]" },
      subject: "Flight Booking Approval Required",
      content: [
        {
          type: "text/html",
          value: `
            <h2>Approval Required</h2>
            <p>Flight: ${params.details.flightNumber}</p>
            <p>Passenger: ${params.details.passengerName}</p>
            <p>Price: $${params.details.price}</p>
            <p>
              <a href="${params.approveUrl}">Approve</a> |
              <a href="${params.rejectUrl}">Reject</a>
            </p>
          `,
        },
      ],
    }),
  });
}

Build docs developers (and LLMs) love