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/guides/[[...slug]]/page.tsx b/docs/app/guides/[[...slug]]/page.tsx new file mode 100644 index 000000000..c88aa11ce --- /dev/null +++ b/docs/app/guides/[[...slug]]/page.tsx @@ -0,0 +1,151 @@ +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { + DocsBody as FumadocsDocsBody, + DocsDescription as FumadocsDocsDescription, + DocsPage as FumadocsDocsPage, + DocsTitle as FumadocsDocsTitle, +} from 'fumadocs-ui/page'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { notFound, redirect } from 'next/navigation'; +import type { CSSProperties } from 'react'; +import { AskAI } from '@/components/geistdocs/ask-ai'; +import { CopyPage } from '@/components/geistdocs/copy-page'; +import { EditSource } from '@/components/geistdocs/edit-source'; +import { Feedback } from '@/components/geistdocs/feedback'; +import { getMDXComponents } from '@/components/geistdocs/mdx-components'; +import { AgentTraces } from '@/components/guides/agent-traces'; +import { OpenInChat } from '@/components/geistdocs/open-in-chat'; +import { ScrollTop } from '@/components/geistdocs/scroll-top'; +import { TableOfContents } from '@/components/geistdocs/toc'; +import * as AccordionComponents from '@/components/ui/accordion'; +import { Badge } from '@/components/ui/badge'; +import { + getGuidesLLMText, + getPageImage, + guidesSource, +} from '@/lib/geistdocs/source'; +import { TSDoc } from '@/lib/tsdoc'; +import { cn } from '@/lib/utils'; +import type { Metadata } from 'next'; + +const containerStyle = { + '--fd-nav-height': '4rem', +} as CSSProperties; + +const Page = async (props: PageProps<'/guides/[[...slug]]'>) => { + const params = await props.params; + + // Redirect /guides to /guides/ai-agents + if (!params.slug || params.slug.length === 0) { + redirect('/guides/ai-agents'); + } + + const page = guidesSource.getPage(params.slug); + + if (!page) { + notFound(); + } + + const markdown = await getGuidesLLMText(page); + const MDX = page.data.body; + + return ( + + + + + + + + + ), + }} + > + + {page.data.title} + + {page.data.description} + + + + + ); +}; + +export const generateStaticParams = () => [ + { slug: [] }, // Root redirect + ...guidesSource.generateParams().map((params) => ({ + slug: params.slug, + })), +]; + +export const generateMetadata = async ( + props: PageProps<'/guides/[[...slug]]'> +): Promise => { + const params = await props.params; + + // Root path redirects, no metadata needed + if (!params.slug || params.slug.length === 0) { + return { title: 'Guides' }; + } + + const page = guidesSource.getPage(params.slug); + + if (!page) { + notFound(); + } + + const { segments, url } = getPageImage(page); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + title: page.data.title, + description: page.data.description, + type: 'article', + url: page.url, + images: [ + { + url, + width: 1200, + height: 630, + alt: segments.join(' - '), + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: page.data.title, + description: page.data.description, + images: [url], + }, + }; +}; + +export default Page; diff --git a/docs/app/guides/layout.tsx b/docs/app/guides/layout.tsx new file mode 100644 index 000000000..073b0a599 --- /dev/null +++ b/docs/app/guides/layout.tsx @@ -0,0 +1,43 @@ +import { DocsLayout as FumadocsDocsLayout } from 'fumadocs-ui/layouts/docs'; +import { Folder, Item, Separator } from '@/components/geistdocs/sidebar'; +import { guidesSource } from '@/lib/geistdocs/source'; + +export const GuidesLayout = ({ + children, +}: Pick, 'children'>) => ( + + {children} + +); + +const Layout = ({ children }: LayoutProps<'/guides'>) => ( + {children} +); + +export default Layout; diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx index b6ec2738e..a25ff356d 100644 --- a/docs/app/layout.tsx +++ b/docs/app/layout.tsx @@ -28,6 +28,10 @@ const links = [ label: 'Docs', href: '/docs', }, + { + label: 'Guides', + href: '/guides', + }, { label: 'Examples', href: 'https://github.com/vercel/workflow-examples', 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/components/guides/agent-traces.tsx b/docs/components/guides/agent-traces.tsx new file mode 100644 index 000000000..3bc495c03 --- /dev/null +++ b/docs/components/guides/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/content/docs/ai-agents/defining-tools.mdx b/docs/content/docs/ai-agents/defining-tools.mdx new file mode 100644 index 000000000..6d07d9074 --- /dev/null +++ b/docs/content/docs/ai-agents/defining-tools.mdx @@ -0,0 +1,109 @@ +--- +title: Defining Tools +--- + +Tools in Workflow DevKit follow the same structure as [AI SDK tools](https://ai-sdk.dev/docs/foundations/tools), but with additional context provided by `DurableAgent`. This page covers the key concepts for defining tools in durable agents. + + +For a complete walkthrough with examples, see the **[Building Durable AI Agents](/guides/ai-agents)** guide. + + +## Tool Execute Context + +When `DurableAgent` invokes a tool, the execute function receives additional context: + +```typescript lineNumbers +execute: async (input, options) => { + const { toolCallId, messages } = options; + + // toolCallId: Unique identifier for correlating stream chunks + // messages: Full conversation history (LanguageModelV2Prompt format) + + return result; +} +``` + +### Accessing Conversation Messages + +Tools can access the full conversation history through the `messages` option: + +```typescript lineNumbers +export const summarizeConversation = tool({ + description: 'Summarize the conversation so far', + inputSchema: z.object({ + focus: z.string().optional(), + }), + execute: async ({ focus }, { messages }) => { // [!code highlight] + const userMessages = messages + .filter(m => m.role === 'user') + .map(m => m.content.filter(p => p.type === 'text').map(p => p.text).join(' ')); + + return { summary: userMessages.slice(-3).join(', ') }; + }, +}); +``` + +## Streaming from Tools + +Tools can emit real-time updates by writing to the workflow's output stream. Since `getWritable()` requires the step context, streaming must be done in a step function: + +```typescript lineNumbers +async function emitProgress(status: string, toolCallId: string) { + 'use step'; // [!code highlight] + + const writable = getWritable(); // [!code highlight] + const writer = writable.getWriter(); // [!code highlight] + + await writer.write({ // [!code highlight] + id: toolCallId, + type: 'data-status', + data: { status }, + }); + + writer.releaseLock(); +} + +async function executeSearch({ query }: { query: string }, { toolCallId }: { toolCallId: string }) { + 'use step'; + await emitProgress('searching', toolCallId); // [!code highlight] + const results = await performSearch(query); + await emitProgress('complete', toolCallId); // [!code highlight] + return results; +} +``` + +## Step-Level vs Workflow-Level + +Tools can run at two levels with different capabilities: + +| Capability | Step-Level (`"use step"`) | Workflow-Level (`"use workflow"`) | +|------------|---------------------------|----------------| +| `getWritable()` | ✅ | ❌ | +| Automatic retries | ✅ | ❌ | +| `sleep()` | ❌ | ✅ | +| `createWebhook()` | ❌ | ✅ | + +Tools can combine both: use a step function for I/O operations, and keep the execute function at the workflow level for orchestration: + +```typescript 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; +} +``` + +## Related + +- [Building Durable AI Agents](/guides/ai-agents) - Complete guide with examples +- [Streaming](/docs/foundations/streaming) - How streaming works +- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding execution contexts +- [`DurableAgent` API Reference](/docs/api-reference/workflow-ai/durable-agent) - Full API documentation diff --git a/docs/content/docs/ai-agents/index.mdx b/docs/content/docs/ai-agents/index.mdx new file mode 100644 index 000000000..8282f365c --- /dev/null +++ b/docs/content/docs/ai-agents/index.mdx @@ -0,0 +1,41 @@ +--- +title: AI & Agents +--- + +Workflow DevKit provides first-class support for building durable AI agents through the `@workflow/ai` package. This package integrates with the [AI SDK](https://ai-sdk.dev) to provide production-ready agent primitives. + +## Guide + +For a complete walkthrough of building AI agents with Workflow DevKit, see the **[Building Durable AI Agents](/guides/ai-agents)** guide, which covers: + +- Converting AI SDK agents to durable agents +- Implementing resumable streams +- Adding sleep and scheduling capabilities +- Human-in-the-loop approval patterns + +## Core Concepts + + + + Learn how to define tools for durable agents, including streaming from tools and accessing conversation context. + + + +## API Reference + +The `@workflow/ai` package exports the following: + + + + The main class for creating AI agents that maintain state across workflow steps, call tools, and handle interruptions. + + + AI SDK transport for reliable message streaming with automatic reconnection and resumption. + + + +## Related + +- [Streaming](/docs/foundations/streaming) - How streaming works in Workflow DevKit +- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding the execution model +- [Hooks & Webhooks](/docs/foundations/hooks) - Pausing workflows for external events diff --git a/docs/content/docs/ai-agents/meta.json b/docs/content/docs/ai-agents/meta.json new file mode 100644 index 000000000..14aa95e51 --- /dev/null +++ b/docs/content/docs/ai-agents/meta.json @@ -0,0 +1,5 @@ +{ + "title": "AI & Agents", + "pages": ["defining-tools"], + "defaultOpen": true +} diff --git a/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx b/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx index 33fed12c3..acbd3fb97 100644 --- a/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx +++ b/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx @@ -74,6 +74,26 @@ import type { DurableAgentStreamOptions } from "@workflow/ai/agent"; export default DurableAgentStreamOptions;`} /> +### PrepareStepInfo + +Information passed to the `prepareStep` callback: + + + +### PrepareStepResult + +Return type from the `prepareStep` callback: + + + ## Key Features - **Durable Execution**: Agents can be interrupted and resumed without losing state @@ -87,6 +107,7 @@ export default DurableAgentStreamOptions;`} - Tools can use core library features like `sleep()` and Hooks within their `execute` functions - The agent processes tool calls iteratively until completion - The `stream()` method returns `{ messages }` containing the full conversation history, including initial messages, assistant responses, and tool results +- The `prepareStep` callback runs before each step and can modify the model or messages dynamically ## Examples @@ -300,8 +321,104 @@ async function agentWithLibraryFeaturesWorkflow(userRequest: string) { } ``` +### Dynamic Context with prepareStep + +Use `prepareStep` to modify settings before each step in the agent loop: + +```typescript +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable } from 'workflow'; +import type { UIMessageChunk } from 'ai'; + +async function agentWithPrepareStep(userMessage: string) { + 'use workflow'; + + const agent = new DurableAgent({ + model: 'openai/gpt-4.1-mini', // Default model + system: 'You are a helpful assistant.', + }); + + await agent.stream({ + messages: [{ role: 'user', content: userMessage }], + writable: getWritable(), + prepareStep: async ({ stepNumber, messages }) => { + // Switch to a stronger model for complex reasoning after initial steps + if (stepNumber > 2 && messages.length > 10) { + return { + model: 'anthropic/claude-sonnet-4.5', + }; + } + + // Trim context if messages grow too large + if (messages.length > 20) { + return { + messages: [ + messages[0], // Keep system message + ...messages.slice(-10), // Keep last 10 messages + ], + }; + } + + return {}; // No changes + }, + }); +} +``` + +### Message Injection with prepareStep + +Inject messages from external sources (like hooks) before each LLM call: + +```typescript +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable, defineHook } from 'workflow'; +import type { UIMessageChunk } from 'ai'; + +const messageHook = defineHook<{ message: string }>(); + +async function agentWithMessageQueue(initialMessage: string) { + 'use workflow'; + + const messageQueue: Array<{ role: 'user'; content: string }> = []; + + // Listen for incoming messages via hook + const hook = messageHook.create(); + hook.then(({ message }) => { + messageQueue.push({ role: 'user', content: message }); + }); + + const agent = new DurableAgent({ + model: 'anthropic/claude-haiku-4.5', + system: 'You are a helpful assistant.', + }); + + await agent.stream({ + messages: [{ role: 'user', content: initialMessage }], + writable: getWritable(), + prepareStep: ({ messages }) => { + // Inject queued messages before the next step + if (messageQueue.length > 0) { + const newMessages = messageQueue.splice(0); + return { + messages: [ + ...messages, + ...newMessages.map(m => ({ + role: m.role, + content: [{ type: 'text' as const, text: m.content }], + })), + ], + }; + } + return {}; + }, + }); +} +``` + ## See Also +- [Building Durable AI Agents](/guides/ai-agents) - Complete guide to creating durable agents +- [Queueing User Messages](/guides/ai-agents/message-queueing) - Using prepareStep for message injection - [WorkflowChatTransport](/docs/api-reference/workflow-ai/workflow-chat-transport) - Transport layer for AI SDK streams - [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals -- [AI SDK Documentation](https://ai-sdk.dev/docs) - AI SDK documentation reference +- [AI SDK Loop Control](https://ai-sdk.dev/docs/agents/loop-control) - AI SDK's agent loop control patterns diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index b527dc11f..89a95d728 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -6,6 +6,7 @@ "foundations", "how-it-works", "observability", + "ai-agents", "deploying", "errors", "api-reference" diff --git a/docs/content/guides/ai-agents/chat-session-modeling.mdx b/docs/content/guides/ai-agents/chat-session-modeling.mdx new file mode 100644 index 000000000..41d01d8e7 --- /dev/null +++ b/docs/content/guides/ai-agents/chat-session-modeling.mdx @@ -0,0 +1,392 @@ +--- +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="app/api/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable } from 'workflow'; +import type { ModelMessage, UIMessageChunk } from 'ai'; + +export async function chatWorkflow(messages: ModelMessage[]) { + 'use workflow'; + + const writable = getWritable(); + + const agent = new DurableAgent({ + model: 'anthropic/claude-sonnet-4', + system: 'You are a helpful assistant.', + tools: { /* ... */ }, + }); + + 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 { createUIMessageStreamResponse, convertToModelMessages } from 'ai'; +import { start } from 'workflow/api'; +import { chatWorkflow } from './workflow'; + +export async function POST(req: Request) { + const { messages } = 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](/guides/ai-agents/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](/guides/ai-agents) 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="app/api/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable } from 'workflow'; +import { chatMessageHook } from '@/ai/hooks/chat-message'; +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: 'anthropic/claude-sonnet-4', + system: 'You are a helpful assistant.', + tools: { /* ... */ }, + }); + + // 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 './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 '@/ai/hooks/chat-message'; + +export async function POST(req: Request) { + 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="ai/hooks/chat-message.ts" lineNumbers +import { defineHook } from 'workflow'; +import { z } from 'zod'; + +export const chatMessageHook = defineHook({ + schema: z.object({ + message: z.string(), + }), +}); +``` + + + + + +```typescript title="hooks/use-multi-turn-chat.ts" lineNumbers +'use client'; + +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: string) => { + 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: string) => { + 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/order-update/route.ts" lineNumbers +import { chatMessageHook } from '@/ai/hooks/chat-message'; + +// Called by your order processing system when status changes +export async function POST(req: Request) { + const { threadId, orderStatus } = await req.json(); + + await chatMessageHook.resume(`thread:${threadId}`, { // [!code highlight] + message: `[System] Order status updated: ${orderStatus}`, // [!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/stripe/route.ts" lineNumbers +import { chatMessageHook } from '@/ai/hooks/chat-message'; +import Stripe from 'stripe'; + +export async function POST(req: Request) { + const event = await req.json() as Stripe.Event; + + if (event.type === 'payment_intent.succeeded') { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + const threadId = paymentIntent.metadata.threadId; // [!code highlight] + + await chatMessageHook.resume(`thread:${threadId}`, { // [!code highlight] + message: `[Stripe] Payment of $${(paymentIntent.amount / 100).toFixed(2)} received.`, // [!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 '@/ai/hooks/chat-message'; +import { getUser } from '@/lib/auth'; + +export async function POST(req: Request, { params }) { + 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](/guides/ai-agents) - Foundation guide for durable agents +- [Message Queueing](/guides/ai-agents/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/guides/ai-agents/human-in-the-loop.mdx b/docs/content/guides/ai-agents/human-in-the-loop.mdx new file mode 100644 index 000000000..3c7d54b27 --- /dev/null +++ b/docs/content/guides/ai-agents/human-in-the-loop.mdx @@ -0,0 +1,339 @@ +--- +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 + + + + +`createWebhook()` generates a unique URL that can be used to resume a workflow. This can be packaged as a tool for the Agent to call, or used directly in your backend code. + + + +The workflow emits a data chunk containing the webhook URL, sending it to the consumer of the workflow, such as a chat UI. + + + +The workflow pauses at `await webhook` - no compute resources are consumed while waiting for the human to take action. + + + +The user of the workflow (e.g. a human in a chat UI) calls the webhook URL, optionally sending data back to the workflow. + + + +The workflow receives any data sent with the webhook (e.g. approval status, comments) and resumes execution. + + + + +## Creating an Approval Tool + +Add a tool that allows the agent to deliberately pauses execution until a human approves or rejects: + + + + +### Implement the tool + +Create a tool with an execute function that creates a webhook and emits a data chunk containing the webhook URL. + +```typescript title="ai/tools/human-approval.ts" lineNumbers +import { tool, type UIMessageChunk } from 'ai'; +import { createWebhook, getWritable } from 'workflow'; // [!code highlight] +import { z } from 'zod'; + +async function executeHumanApproval( + { message }: { message: string }, + { toolCallId }: { toolCallId: string } +) { + // Note: No "use step" - webhooks are workflow-level primitives // [!code highlight] + + const webhook = createWebhook(); // [!code highlight] + + // Emit the approval URL to the UI + await emitApprovalRequest( + { url: webhook.url, message }, + { toolCallId } + ); + + // 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 `Action rejected: ${comment || 'No reason provided'}`; + } + + return `Approved${comment ? ` with comment: ${comment}` : ''}`; +} + +export const humanApproval = tool({ + description: 'Request human approval before proceeding with an action', + inputSchema: z.object({ + message: z.string().describe('Description of what needs approval'), + }), + execute: executeHumanApproval, +}); + +// This is just a helper function to emit the approval request to the UI +async function emitApprovalRequest( + { url, message }: { url: string; message: string }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + await writer.write({ + id: toolCallId, + type: 'data-approval-required', + data: { url, message }, + }); + + writer.releaseLock(); +} +``` + + +The `createWebhook()` function must be called from within a workflow context, not from a step. This is why `executeHumanApproval` does not have `"use step"`, but the stream write operation requires a step context, which is why `emitApprovalRequest` is a separate function with `"use step"`. + + + + +### Create the Approval Component + +The UI receives a data chunk with type `data-approval-required`. Build a component that displays the approval request and handles the user's decision. Instead of giving the human UI to resolve this hook, this could also be sent to an external service, e.g. as a payment provider callback. + +```typescript title="components/approval-button.tsx" lineNumbers +'use client'; + +import { useState } from 'react'; + +interface ApprovalData { + url: string; + message: string; +} + +export function ApprovalButton({ data }: { data: ApprovalData }) { + const [comment, setComment] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isComplete, setIsComplete] = useState(false); + + const handleSubmit = async (approved: boolean) => { + setIsSubmitting(true); + try { + await fetch(data.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved, comment }), + }); + setIsComplete(true); + } finally { + setIsSubmitting(false); + } + }; + + if (isComplete) { + return
Response submitted
; + } + + return ( +
+

{data.message}

+ +