workflow
Version:
Workflow SDK - Build durable, resilient, and observable workflows
379 lines (290 loc) • 12.8 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 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 uses a client-side button for human approval, you could instead 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">
Call `defineHook().create()` from within a workflow context, not from within a step. `executeBookingApproval` does not have `"use step"` because 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 });
}
```
<Callout type="warn">
This route resumes whichever hook owns `toolCallId`, so anyone who learns the ID can submit a decision. In production, authenticate the request and check that the signed-in user may approve this booking before calling `resume()`. See [Hook and webhook security](/docs/foundations/hooks#security).
</Callout>
</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-gray-900">{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-gray-900">
{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. Anyone with the URL can resume it, so avoid this for approvals that need to know who approved, unless you use the incoming payload for authorization. See [Hook and webhook security](/docs/foundations/hooks#security).
```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