diff --git a/.changeset/three-hands-open.md b/.changeset/three-hands-open.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/three-hands-open.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/app/(home)/components/frameworks.tsx b/docs/app/(home)/components/frameworks.tsx index 6d47cc91b..e07d2629f 100644 --- a/docs/app/(home)/components/frameworks.tsx +++ b/docs/app/(home)/components/frameworks.tsx @@ -179,8 +179,8 @@ export const Vite = (props: ComponentProps<'svg'>) => ( y2="344" gradientUnits="userSpaceOnUse" > - - + + ) => ( y2="292.989" gradientUnits="userSpaceOnUse" > - - - + + + diff --git a/docs/app/docs/[[...slug]]/page.tsx b/docs/app/docs/[[...slug]]/page.tsx index 246c1c513..6da6efc31 100644 --- a/docs/app/docs/[[...slug]]/page.tsx +++ b/docs/app/docs/[[...slug]]/page.tsx @@ -2,6 +2,7 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; import { createRelativeLink } from 'fumadocs-ui/mdx'; import { notFound } from 'next/navigation'; +import { AgentTraces } from '@/components/custom/agent-traces'; import { AskAI } from '@/components/geistdocs/ask-ai'; import { CopyPage } from '@/components/geistdocs/copy-page'; import { @@ -61,6 +62,7 @@ const Page = async (props: PageProps<'/docs/[[...slug]]'>) => { a: createRelativeLink(source, page), // Add your custom components here + AgentTraces, Badge, TSDoc, Step, diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index b6ec2738e..2881e7f48 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -39,6 +39,7 @@ const suggestions = [ 'How does retrying work?', 'What control flow patterns are there?', 'How do directives work?', + 'How do I build an AI agent?', ]; const Layout = ({ children }: LayoutProps<'/'>) => ( diff --git a/docs/components/custom/agent-traces.tsx b/docs/components/custom/agent-traces.tsx new file mode 100644 index 000000000..3bc495c03 --- /dev/null +++ b/docs/components/custom/agent-traces.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; + +// Color presets for trace rows +const colors = { + workflow: + 'bg-[#E1F0FF] dark:bg-[#00254D] border-[#99CEFF] text-[#0070F3] dark:border-[#0067D6] dark:text-[#52AEFF]', + stream: + 'bg-[#DCF6DC] dark:bg-[#1B311E] border-[#99E59F] text-[#46A758] dark:border-[#297C3B] dark:text-[#6CDA76]', + tool: 'bg-[#FFF4E5] dark:bg-[#3D2800] border-[#FFCC80] text-[#F5A623] dark:border-[#9A6700] dark:text-[#FFCA28]', + approval: + 'bg-[#FCE7F3] dark:bg-[#4A1D34] border-[#F9A8D4] text-[#EC4899] dark:border-[#BE185D] dark:text-[#F472B6]', + webhook: + 'bg-[#EDE9FE] dark:bg-[#2E1065] border-[#C4B5FD] text-[#7C3AED] dark:border-[#6D28D9] dark:text-[#A78BFA]', +}; + +type TraceRow = { + label: string; + className: string; + start: number; + duration: number; +}; + +const defaultRows: TraceRow[] = [ + { + label: 'chatWorkflow', + className: colors.workflow, + start: 0, + duration: 100, + }, + { label: 'agent.stream', className: colors.stream, start: 2, duration: 16 }, + { label: 'searchWeb', className: colors.tool, start: 20, duration: 13 }, + { label: 'agent.stream', className: colors.stream, start: 37, duration: 16 }, + { + label: 'waitForHumanApproval', + className: colors.approval, + start: 57, + duration: 24, + }, + { label: 'agent.stream', className: colors.stream, start: 84, duration: 16 }, +]; + +const messageQueueRows: TraceRow[] = [ + { + label: 'chatWorkflow', + className: colors.workflow, + start: 0, + duration: 100, + }, + { label: 'agent.stream', className: colors.stream, start: 2, duration: 16 }, + { + label: 'hook.enqueue()', + className: colors.webhook, + start: 12, + duration: 24, + }, + { + label: 'tool.checkDB()', + className: colors.tool, + start: 18, + duration: 18, + }, + { label: 'agent.stream', className: colors.stream, start: 36, duration: 16 }, + { + label: 'hook.enqueue()', + className: colors.webhook, + start: 46, + duration: 24, + }, + { + label: 'tool.search()', + className: colors.tool, + start: 52, + duration: 18, + }, + { label: 'agent.stream', className: colors.stream, start: 70, duration: 16 }, +]; + +const variants = { + default: defaultRows, + 'message-queue': messageQueueRows, +} as const; + +type Variant = keyof typeof variants; + +interface AgentTracesProps { + variant?: Variant; +} + +export const AgentTraces = ({ variant = 'default' }: AgentTracesProps) => { + const rows = variants[variant]; + + return ( +
+
+ {rows.map((row, index) => ( +
+
+ +
+ + {row.label} + + {index === 0 && ( + + {row.duration}ms + + )} +
+
+
+
+ ))} +
+
+ ); +}; diff --git a/docs/components/geistdocs/desktop-menu.tsx b/docs/components/geistdocs/desktop-menu.tsx index a74abd4b5..022c37a27 100644 --- a/docs/components/geistdocs/desktop-menu.tsx +++ b/docs/components/geistdocs/desktop-menu.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; import { NavigationMenu, NavigationMenuItem, @@ -8,6 +9,7 @@ import { NavigationMenuList, } from '@/components/ui/navigation-menu'; import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; type DesktopMenuProps = { items: { label: string; href: string }[]; @@ -15,6 +17,12 @@ type DesktopMenuProps = { export const DesktopMenu = ({ items }: DesktopMenuProps) => { const isMobile = useIsMobile(); + const pathname = usePathname(); + + const isActive = (href: string) => { + if (href.startsWith('http')) return false; + return pathname === href || pathname.startsWith(`${href}/`); + }; return ( @@ -23,7 +31,10 @@ export const DesktopMenu = ({ items }: DesktopMenuProps) => { {item.label} diff --git a/docs/components/geistdocs/docs-layout.tsx b/docs/components/geistdocs/docs-layout.tsx index a4ae61e79..800e262ee 100644 --- a/docs/components/geistdocs/docs-layout.tsx +++ b/docs/components/geistdocs/docs-layout.tsx @@ -18,7 +18,7 @@ export const DocsLayout = ({ }} sidebar={{ className: - 'md:static md:sticky md:top-16 md:h-fit md:w-auto! bg-background! md:bg-transparent! border-none transition-none', + 'md:static md:sticky md:top-16 md:max-h-[calc(100vh-4rem)] md:overflow-y-auto md:w-auto! bg-background! md:bg-transparent! border-none transition-none', collapsible: false, components: { Folder, diff --git a/docs/content/docs/ai/chat-session-modeling.mdx b/docs/content/docs/ai/chat-session-modeling.mdx new file mode 100644 index 000000000..f0c2c24c0 --- /dev/null +++ b/docs/content/docs/ai/chat-session-modeling.mdx @@ -0,0 +1,400 @@ +--- +title: Chat Session Modeling +--- + +Chat sessions in AI agents can be modeled at different layers of your architecture. The choice affects state ownership and how you handle interruptions and reconnections. + +While there are many ways to model chat sessions, the two most common categories are single-turn and multi-turn. + +## Single-Turn Workflows + +Each user message triggers a new workflow run. The client or API route owns the conversation history and sends the full message array with each request. + + + + + +```typescript title="workflows/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable } from 'workflow'; +import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from './steps/tools'; +import type { ModelMessage, UIMessageChunk } from 'ai'; + +export async function chatWorkflow(messages: ModelMessage[]) { + 'use workflow'; + + const writable = getWritable(); + + const agent = new DurableAgent({ + model: 'bedrock/claude-4-5-haiku-20251001-v1', + system: FLIGHT_ASSISTANT_PROMPT, + tools: flightBookingTools, + }); + + const { messages: result } = await agent.stream({ + messages, // [!code highlight] Full history from client + writable, + }); + + return { messages: result }; +} +``` + + + + + +```typescript title="app/api/chat/route.ts" lineNumbers +import type { UIMessage } from 'ai'; +import { createUIMessageStreamResponse, convertToModelMessages } from 'ai'; +import { start } from 'workflow/api'; +import { chatWorkflow } from '@/workflows/chat/workflow'; + +export async function POST(req: Request) { + const { messages }: { messages: UIMessage[] } = await req.json(); + const modelMessages = convertToModelMessages(messages); + + const run = await start(chatWorkflow, [modelMessages]); // [!code highlight] + + return createUIMessageStreamResponse({ + stream: run.readable, + }); +} +``` + + + + + +Chat messages need to be stored somewhere—typically a database. In this example, we assume a route like `/chats/:id` passes the session ID, allowing us to fetch existing messages and persist new ones. + +```typescript title="app/chats/[id]/page.tsx" lineNumbers +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { WorkflowChatTransport } from '@workflow/ai'; // [!code highlight] +import { useParams } from 'next/navigation'; +import { useMemo } from 'react'; + +// Fetch existing messages from your backend +async function getMessages(sessionId: string) { // [!code highlight] + const res = await fetch(`/api/chats/${sessionId}/messages`); // [!code highlight] + return res.json(); // [!code highlight] +} // [!code highlight] + +export function Chat({ initialMessages }) { + const { id: sessionId } = useParams<{ id: string }>(); + + const transport = useMemo( // [!code highlight] + () => // [!code highlight] + new WorkflowChatTransport({ // [!code highlight] + api: '/api/chat', // [!code highlight] + onChatEnd: async () => { // [!code highlight] + // Persist the updated messages to the chat session // [!code highlight] + await fetch(`/api/chats/${sessionId}/messages`, { // [!code highlight] + method: 'PUT', // [!code highlight] + headers: { 'Content-Type': 'application/json' }, // [!code highlight] + body: JSON.stringify({ messages }), // [!code highlight] + }); // [!code highlight] + }, // [!code highlight] + }), // [!code highlight] + [sessionId] // [!code highlight] + ); // [!code highlight] + + const { messages, input, handleInputChange, handleSubmit } = useChat({ + initialMessages, // [!code highlight] Loaded via getMessages(sessionId) + transport, // [!code highlight] + }); + + return ( +
+ {/* ... render messages ... */} + +
+ ); +} +``` + +
+ +
+ +In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's `useChat`, and past turns persisted to the backend. The current turn is either managed through the workflow by a resumable stream (see [Resumable Streams](/docs/ai/resumable-streams)), or a hook into `useChat` persists every new message to the backend, as messages come in. + +This is the pattern used in the [Building Durable AI Agents](/docs/ai) guide. + +## Multi-Turn Workflows + +A single workflow handles the entire conversation session across multiple turns, and owns the current conversation state. The clients/API routes inject new messages via hooks. + + + + + +```typescript title="workflows/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable } from 'workflow'; +import { chatMessageHook } from './hooks/chat-message'; +import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from './steps/tools'; +import type { ModelMessage, UIMessageChunk } from 'ai'; + +export async function chatWorkflow(threadId: string, initialMessage: string) { + 'use workflow'; + + const writable = getWritable(); + const messages: ModelMessage[] = [{ role: 'user', content: initialMessage }]; + + const agent = new DurableAgent({ + model: 'bedrock/claude-4-5-haiku-20251001-v1', + system: FLIGHT_ASSISTANT_PROMPT, + tools: flightBookingTools, + }); + + // Create hook with thread-specific token for resumption // [!code highlight] + const hook = chatMessageHook.create({ token: `thread:${threadId}` }); // [!code highlight] + + while (true) { + // Process current messages + const { messages: result } = await agent.stream({ + messages, + writable, + preventClose: true, // [!code highlight] Keep stream open for follow-ups + }); + messages.push(...result.slice(messages.length)); + + // Wait for next user message // [!code highlight] + const { message } = await hook; // [!code highlight] + if (message === '/done') break; + + messages.push({ role: 'user', content: message }); + } + + return { messages }; +} +``` + + + + + +Two endpoints: one to start the session, one to send follow-up messages. + +```typescript title="app/api/chat/route.ts" lineNumbers +import { createUIMessageStreamResponse } from 'ai'; +import { start } from 'workflow/api'; +import { chatWorkflow } from '@/workflows/chat/workflow'; + +export async function POST(req: Request) { + const { threadId, message } = await req.json(); + + const run = await start(chatWorkflow, [threadId, message]); // [!code highlight] + + return createUIMessageStreamResponse({ + stream: run.readable, + }); +} +``` + +```typescript title="app/api/chat/[id]/route.ts" lineNumbers +import { chatMessageHook } from '@/workflows/chat/hooks/chat-message'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }) // [!code highlight] +{ + const { message } = await req.json(); + const { id: threadId } = await params; // [!code highlight] + + await chatMessageHook.resume(`thread:${threadId}`, { message }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + + + + + +```typescript title="workflows/chat/hooks/chat-message.ts" lineNumbers +import { defineHook } from 'workflow'; +import { z } from 'zod'; + +export const chatMessageHook = defineHook({ + schema: z.object({ + message: z.string(), + }), +}); +``` + + + + + +We can replace our `useChat` react hook with a custom hook that manages the chat session. This hook will handle switching between the API endpoints for creating a new thread and sending follow-up messages. + +```typescript title="hooks/use-multi-turn-chat.ts" lineNumbers +'use client'; + +import type { UIMessage } from 'ai'; +import { useChat } from '@ai-sdk/react'; // [!code highlight] +import { WorkflowChatTransport } from '@workflow/ai'; // [!code highlight] +import { useState, useCallback, useMemo } from 'react'; + +export function useMultiTurnChat() { + const [threadId, setThreadId] = useState(null); + + const transport = useMemo( // [!code highlight] + () => // [!code highlight] + new WorkflowChatTransport({ // [!code highlight] + api: '/api/chat', // [!code highlight] + }), // [!code highlight] + [] // [!code highlight] + ); // [!code highlight] + + const { + messages, + sendMessage: sendInitialMessage, // [!code highlight] Renamed from sendMessage + ...chatProps + } = useChat({ transport }); // [!code highlight] + + const startSession = useCallback( + async (message: UIMessage) => { + const newThreadId = crypto.randomUUID(); + setThreadId(newThreadId); + + // Send initial message with threadId in body // [!code highlight] + await sendInitialMessage(message, { // [!code highlight] + body: { threadId: newThreadId }, // [!code highlight] + }); // [!code highlight] + }, + [sendInitialMessage] + ); + + // Follow-up messages go through the hook resumption endpoint // [!code highlight] + const sendMessage = useCallback( + async (message: UIMessage) => { + if (!threadId) return; + + await fetch(`/api/chat/${threadId}`, { // [!code highlight] + method: 'POST', // [!code highlight] + headers: { 'Content-Type': 'application/json' }, // [!code highlight] + body: JSON.stringify({ message }), // [!code highlight] + }); // [!code highlight] + }, + [threadId] + ); + + const endSession = useCallback(async () => { + if (!threadId) return; + await sendMessage('/done'); + setThreadId(null); + }, [threadId, sendMessage]); + + return { messages, threadId, startSession, sendMessage, endSession, ...chatProps }; +} +``` + + + + + +In this pattern, the workflow owns the entire conversation session. All messages are persisted in the workflow, and messages are injected into the workflow via hooks. The current **and** past turns are available in the UI by reconnecting to the main workflow stream. Alternatively to a stream, this could also use a step on the workflow side to persists (and possibly load) messages from an external store. Using an external database is more flexible, but less performant and harder to resume neatly than the built-in stream. + +## Choosing a Pattern + +| Consideration | Single-Turn | Multi-Turn | +|--------------|-------------|------------| +| State ownership | Client or API route | Workflow | +| Message injection from backend | Requires stitching together runs | Native via hooks | +| Workflow complexity | Lower | Higher | +| Workflow time horizon | Minutes | Hours to indefinitely | +| Observability scope | Per-turn traces | Full session traces | + +**Multi-turn is recommended for most production use-cases.** If you're starting fresh, go with multi-turn. It's more flexible and grows with your requirements. Server-owned state, native message injection, and full session observability become increasingly valuable as your agent matures. + +**Single-turn works well when adapting existing architectures.** If you already have a system for managing message state, and want to adopt durable agents incrementally, single-turn workflows slot in with minimal changes. Each turn maps cleanly to an independent workflow run. + +## Multi-Party Injection + +The multi-turn pattern also easily enables multi-party chat sessions. Parties can be system events, external services, and multiple users. Since a `hook` injects messages into workflow at any point, and the entire history is a single stream that clients can reconnect to, it doesn't matter where the injected messages come from. Here are different use-cases for multi-party chat sessions: + + + + + +Internal system events like scheduled tasks, background jobs, or database triggers can inject updates into an active conversation. + +```typescript title="app/api/internal/flight-update/route.ts" lineNumbers +import { chatMessageHook } from '@/workflows/chat/hooks/chat-message'; + +// Called by your flight status monitoring system +export async function POST(req: Request) { + const { threadId, flightNumber, newStatus } = await req.json(); + + await chatMessageHook.resume(`thread:${threadId}`, { // [!code highlight] + message: `[System] Flight ${flightNumber} status updated: ${newStatus}`, // [!code highlight] + }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + + + + + +External webhooks from third-party services (Stripe, Twilio, etc.) can notify the conversation of events. + +```typescript title="app/api/webhooks/payment/route.ts" lineNumbers +import { chatMessageHook } from '@/workflows/chat/hooks/chat-message'; + +export async function POST(req: Request) { + const { threadId, paymentStatus, amount } = await req.json(); + + if (paymentStatus === 'succeeded') { + await chatMessageHook.resume(`thread:${threadId}`, { // [!code highlight] + message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed!`, // [!code highlight] + }); // [!code highlight] + } + + return Response.json({ received: true }); +} +``` + + + + + +Multiple human users can participate in the same conversation. Each user's client connects to the same workflow stream. + +```typescript title="app/api/chat/[id]/route.ts" lineNumbers +import { chatMessageHook } from '@/workflows/chat/hooks/chat-message'; +import { getUser } from '@/lib/auth'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } // [!code highlight] +) { + const { id: threadId } = await params; + const { message } = await req.json(); + const user = await getUser(req); // [!code highlight] + + // Inject message with user attribution // [!code highlight] + await chatMessageHook.resume(`thread:${threadId}`, { // [!code highlight] + message: `[${user.name}] ${message}`, // [!code highlight] + }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + + + + + +## Related Documentation + +- [Building Durable AI Agents](/docs/ai) - Foundation guide for durable agents +- [Message Queueing](/docs/ai/message-queueing) - Queueing messages during tool execution +- [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Hook configuration options +- [`DurableAgent` API Reference](/docs/api-reference/workflow-ai/durable-agent) - Full API documentation diff --git a/docs/content/docs/ai/defining-tools.mdx b/docs/content/docs/ai/defining-tools.mdx new file mode 100644 index 000000000..204b385f1 --- /dev/null +++ b/docs/content/docs/ai/defining-tools.mdx @@ -0,0 +1,69 @@ +--- +title: Patterns for Defining Tools +--- + +This page covers the details for some common patterns when defining tools for AI agents using Workflow DevKit. + +## Accessing message context in tools + +Just like in regular AI SDK tool definitions, tool in DurableAgent are called with a first argument of the tool's input parameters, and a second argument of the tool call context. + +When you tool needs access to the full message history, you can access it via the `messages` property of the tool call context: + +```typescript title="tools.ts" lineNumbers +async function getWeather( + { city }: { city: string }, + { messages, toolCallId }: { messages: LanguageModelV2Prompt, toolCallId: string }) { // [!code highlight] + "use step"; + return `Weather in ${city} is sunny`; +} +``` + +## Writing to Streams + +As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step just to call `getWritable()` for writing custom data parts to the stream. + +This can be made generic, by creating a helper step function to write arbitrary data to the stream: + +```typescript title="tools.ts" lineNumbers +import { getWritable } from "workflow"; + +async function writeToStream(data: any) { + "use step"; + + const writable = getWritable(); + const writer = writable.getWriter(); + await writer.write(data); + writer.releaseLock(); +} +``` + +## Step-Level vs Workflow-Level Tools + +Tools can be implemented either at the step level or the workflow level, with different capabilities and constraints. + +| Capability | Step-Level (`"use step"`) | Workflow-Level (`"use workflow"`) | +|------------|---------------------------|----------------| +| `getWritable()` | ✅ | ❌ | +| Automatic retries | ✅ | ❌ | +| Side-effects (e.g. API calls) allowed | ✅ | ❌ | +| `sleep()` | ❌ | ✅ | +| `createWebhook()` | ❌ | ✅ | + +Tools can also combine both by starting out on the workflow level, and calling into steps for I/O operations, like so: + +```typescript title="tools.ts" lineNumbers +// Step: handles I/O with retries +async function performFetch(url: string) { + 'use step'; + const response = await fetch(url); + return response.json(); +} + +// Workflow-level: orchestrates steps and can use sleep() +async function executeFetchWithDelay({ url }: { url: string }) { + const result = await performFetch(url); + await sleep('5s'); // Only available at workflow level + return result; +} +``` diff --git a/docs/content/docs/ai/human-in-the-loop.mdx b/docs/content/docs/ai/human-in-the-loop.mdx new file mode 100644 index 000000000..f300c317e --- /dev/null +++ b/docs/content/docs/ai/human-in-the-loop.mdx @@ -0,0 +1,340 @@ +--- +title: 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](/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 + + + + +`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. + + + + +## 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: + +```typescript title="workflows/chat/hooks/booking-approval.ts" lineNumbers +import { defineHook } from 'workflow'; +import { z } from 'zod'; + +export const bookingApprovalHook = defineHook({ + schema: z.object({ + approved: z.boolean(), + comment: z.string().optional(), + }), +}); +``` + + + + + +### 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. + +```typescript title="workflows/chat/steps/tools.ts" lineNumbers +import { z } from 'zod'; +import { bookingApprovalHook } from '../hooks/booking-approval'; + +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, + }, +}; +``` + + +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 an API endpoint that the UI will call to submit the approval decision: + +```typescript title="app/api/approve-booking/route.ts" lineNumbers +import { bookingApprovalHook } from '@/workflows/chat/hooks/booking-approval'; + +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 }); +} +``` + + + + +### Show the Tool Status in the UI + +Render the tool call and approval controls in your chat interface. The tool call part includes all the input data needed to display the booking details: + +```typescript title="app/page.tsx" lineNumbers +export default function ChatPage() { + + // ... + + const { stop, messages, sendMessage, status, setMessages } = + useChat({ + // ... options + }); + + // ... + + return ( +
+ // ... + + + + {messages.map((message, index) => { + const hasText = message.parts.some((part) => part.type === "text"); + + return ( +
+ // ... + + + {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] + // [!code highlight] + ); // [!code highlight] + } // [!code highlight] + return null; + })} + + +
+ ); + })} +
+ +
+ + // ... +
+ ); +} +``` + +
+ + +### Create the Approval Component + +Build a component that displays the booking details from the tool input and handles the user's decision: + +```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 ( +
+

{output}

+
+ ); + } + + const handleSubmit = async (approved: boolean) => { + setIsSubmitting(true); + try { + await fetch('/api/approve-booking', { // [!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 ( +
+
+

Approve this booking?

+
+
Flight: {input.flightNumber}
+
Passenger: {input.passengerName}
+
Price: ${input.price}
+
+
+ +