UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

379 lines (290 loc) • 12.8 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 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