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 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, and provides stability across code deployments.
If you need to react to external events programmatically, see the 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
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.
The workflow pauses at await hook - no compute resources are consumed while waiting for the human to take action.
The UI displays the pending tool call with its input data (flight details, price, etc.) and renders approval controls.
The user submits their decision through an API endpoint, which resumes the hook with the approval data.
The workflow receives the approval data and resumes execution.
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:
Define the Hook
Create a typed hook with a Zod schema for validation:
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 ...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.
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}` : ""}`;
}
// 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,
},
};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.
Create the API Route
Create a new API endpoint that the UI will call to submit the approval decision:
import { bookingApprovalHook } from "@/workflow/steps/tools";
export async function POST(request: Request) {
const { toolCallId, approved, comment } = await request.json();
// Schema validation happens automatically
// Can throw a zod schema validation error, or a
await bookingApprovalHook.resume(toolCallId, {
approved,
comment,
});
return Response.json({ success: true });
}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:
"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/approve-booking", {
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>
<div className="text-sm text-muted-foreground">
<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
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
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>
);
}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:
// ... 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") {
return (
<BookingApproval
key={partIndex}
toolCallId={part.toolCallId}
input={part.input}
output={part.output}
/>
);
}
return null;
})}
</MessageContent>
</Message>
</div>
);
})}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
// ...
</div>
);
}Using Webhooks Directly
For simpler cases where you don't need type-safe validation or programmatic resumption, you can use createWebhook() directly. This generates a unique URL that can be called to resume the workflow:
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();
// 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
Related Documentation
- Hooks & Webhooks - Complete guide to hooks and webhooks
createWebhook()API Reference - Webhook configuration optionsdefineHook()API Reference - Type-safe hook definitions