Overview

Human-in-the-Loop

Pause an AI agent to wait for human approval, then resume based on the decision.

Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds.

Pattern

Create a typed hook using defineHook(). When the agent calls the approval tool, the tool creates a hook instance using the tool call ID as the token, then awaits it. The UI renders approval controls, and an API route resumes the hook with the decision.

Simplified

import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, sleep, getWritable } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";

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

declare function confirmBooking(args: { flightId: string; passenger: string }): Promise<{ confirmationId: string }>; // @setup

// This tool runs at the workflow level (no "use step") because hooks are workflow primitives
async function requestBookingApproval(
  { flightId, passenger, price }: { flightId: string; passenger: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  const hook = bookingApprovalHook.create({ token: toolCallId });

  const result = await Promise.race([
    hook.then((payload) => ({ type: "decision" as const, ...payload })),
    sleep("24h").then(() => ({ type: "timeout" as const, approved: false })),
  ]);

  if (result.type === "timeout") return "Booking request expired after 24 hours.";
  if (!result.approved) return `Booking rejected: ${result.comment || "No reason given"}`;

  const booking = await confirmBooking({ flightId, passenger });
  return `Booked! Confirmation: ${booking.confirmationId}`;
}

export async function bookingAgent(messages: ModelMessage[]) {
  "use workflow";

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions: "You help book flights. Always request approval before booking.",
    tools: {
      requestBookingApproval: {
        description: "Request human approval before booking a flight",
        inputSchema: z.object({
          flightId: z.string(),
          passenger: z.string(),
          price: z.number(),
        }),
        execute: requestBookingApproval,
      },
    },
  });

  await agent.stream({
    messages,
    writable: getWritable<UIMessageChunk>(),
  });
}

Full Implementation

import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, sleep, getWritable } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";

// Define the approval hook with schema validation
export const bookingApprovalHook = defineHook({
  schema: z.object({
    approved: z.boolean(),
    comment: z.string().optional(),
  }),
});

// Step: Search for flights (full Node.js access, automatic retries)
async function searchFlights({
  from,
  to,
  date,
}: {
  from: string;
  to: string;
  date: string;
}) {
  "use step";

  // Your real flight search API call here
  await new Promise((resolve) => setTimeout(resolve, 500));
  return {
    flights: [
      { id: "FL-100", airline: "Example Air", price: 299, from, to, date },
      { id: "FL-200", airline: "Demo Airlines", price: 349, from, to, date },
    ],
  };
}

// Step: Confirm the booking after approval
async function confirmBooking({
  flightId,
  passenger,
}: {
  flightId: string;
  passenger: string;
}) {
  "use step";

  await new Promise((resolve) => setTimeout(resolve, 500));
  return { confirmationId: `CONF-${flightId}-${Date.now().toString(36)}` };
}

// Workflow-level tool: hooks must be created in workflow context, not inside steps
async function requestBookingApproval(
  {
    flightId,
    passenger,
    price,
  }: { flightId: string; passenger: string; price: number },
  { toolCallId }: { toolCallId: string }
) {
  // No "use step" — hooks are workflow-level primitives

  const hook = bookingApprovalHook.create({ token: toolCallId });

  // Race: human approval vs. 24-hour timeout
  const result = await Promise.race([
    hook.then((payload) => ({ type: "decision" as const, ...payload })),
    sleep("24h").then(() => ({ type: "timeout" as const, approved: false })),
  ]);

  if (result.type === "timeout") {
    return "Booking request expired after 24 hours.";
  }

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

  // Approved — proceed with booking
  const booking = await confirmBooking({ flightId, passenger });
  return `Flight ${flightId} booked for ${passenger}. Confirmation: ${booking.confirmationId}`;
}

export async function bookingAgent(messages: ModelMessage[]) {
  "use workflow";

  const writable = getWritable<UIMessageChunk>();

  const agent = new DurableAgent({
    model: "anthropic/claude-haiku-4.5",
    instructions:
      "You are a flight booking assistant. Search for flights, then request approval before booking.",
    tools: {
      searchFlights: {
        description: "Search for available flights",
        inputSchema: z.object({
          from: z.string().describe("Departure airport code"),
          to: z.string().describe("Arrival airport code"),
          date: z.string().describe("Travel date (YYYY-MM-DD)"),
        }),
        execute: searchFlights,
      },
      requestBookingApproval: {
        description: "Request human approval before booking a flight",
        inputSchema: z.object({
          flightId: z.string().describe("Flight ID to book"),
          passenger: z.string().describe("Passenger name"),
          price: z.number().describe("Total price"),
        }),
        execute: requestBookingApproval,
      },
    },
  });

  await agent.stream({ messages, writable });
}

API Route for Approvals

import { bookingApprovalHook } from "@/workflows/booking-agent";

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

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

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

Approval Component

"use client";

import { useState } from "react";

export function BookingApproval({
  toolCallId,
  input,
  output,
}: {
  toolCallId: string;
  input?: { flightId: string; passenger: string; price: number };
  output?: string;
}) {
  const [comment, setComment] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);

  if (output) {
    return <p className="text-sm text-muted-foreground">{output}</p>;
  }

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

  return (
    <div className="border rounded-lg p-4 space-y-3">
      {input && (
        <div className="text-sm space-y-1">
          <div>Flight: {input.flightId}</div>
          <div>Passenger: {input.passenger}</div>
          <div>Price: ${input.price}</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}>
          Approve
        </button>
        <button type="button" onClick={() => handleSubmit(false)} disabled={isSubmitting}>
          Reject
        </button>
      </div>
    </div>
  );
}

Key APIs

On this page

GitHubEdit this page on GitHub