workflow
Version:
Workflow DevKit - Build durable, resilient, and observable workflows
375 lines (287 loc) • 12.3 kB
text/mdx
---
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
---
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](/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