UNPKG

workflow

Version:

Workflow DevKit - Build durable, resilient, and observable workflows

375 lines (287 loc) 12.3 kB
--- 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