+ );
+};
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 (
+
+ );
+}
+```
+
+
+
+
+
+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 (
+
+ );
+}
+```
+
+
+
+
+
+## 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
diff --git a/docs/content/docs/ai/index.mdx b/docs/content/docs/ai/index.mdx
new file mode 100644
index 000000000..5d2641b7f
--- /dev/null
+++ b/docs/content/docs/ai/index.mdx
@@ -0,0 +1,411 @@
+---
+title: Building Durable AI Agents
+---
+
+AI agents are built on the primitive of LLM and tool-call loops, often with additional processes for data fetching, resource provisioning, or reacting to external events.
+
+Workflow DevKit makes your agents production-ready, by turning them into durable, resumable workflows. It transforms your LLM calls, tool executions, and other async operations into retryable, scalable, and observable steps.
+
+
+
+This guide walks you through converting a basic AI chat app into a durable AI agent using Workflow DevKit.
+
+## Why Durable Agents?
+
+Aside from the usual challenges of getting your long-running tasks to be production-ready, building mature AI agents typically requires solving several **additional challenges**:
+
+- **Statefulness**: Persisting chat sessions and turning LLM and tool calls into async jobs with workers and queues.
+- **Observability**: Using services to collect traces and metrics, and managing them separately from your messages and user history.
+- **Resumability**: Resuming streams requires not just storing your messages, but also storing streams, and piping them across services.
+- **Human-in-the-loop**: Your client, API, and async job orchestration need to work together to create, track, route to, and display human approval requests, or similar webhook operations.
+
+Workflow DevKit provides all of these capabilities out of the box. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure.
+
+## Getting Started
+
+To make an Agent durable, we first need an Agent, which we'll be setting up here. If you already have an app you'd like to follow along with, you can skip this section.
+
+For our example, we'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow DevKit to it. We'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI.
+
+
+
+
+### Clone example app
+
+We'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow DevKit to it. For the follow-along steps, we'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI.
+
+If you have your own project, you can skip this step, and simply apply the changes of the following steps to your own project.
+
+```bash
+git clone https://github.com/vercel/workflow-examples -b plain-ai-sdk
+cd workflow-examples/flight-booking-app
+```
+
+
+
+
+
+### Set up API keys
+
+In order to connect to an LLM, we'll need to set up an API key. The easiest way to do this is to use Vercel Gateway (works with all providers at zero markup), or you can configure a custom provider.
+
+
+
+
+Get a Gateway API key from the [Vercel Gateway](https://vercel.com/docs/gateway/api-reference/overview) page.
+
+Then add it to your `.env.local` file:
+
+```bash title=".env.local" lineNumbers
+GATEWAY_API_KEY=...
+```
+
+
+
+
+
+This is an example of how to use the OpenAI provider for AI SDK. For details on other providers and more details, see the [AI SDK provider guide](https://ai-sdk.dev/providers/ai-sdk-providers).
+
+```package-install
+npm i @ai-sdk/openai
+```
+
+Set your OpenAI API key in your environment variables:
+
+```bash title=".env.local" lineNumbers
+OPENAI_API_KEY=...
+```
+
+Then modify your API endpoint to use the OpenAI provider:
+
+```typescript title="app/api/chat/route.ts" lineNumbers
+// ...
+import { openai } from '@workflow/ai/openai'; // [!code highlight]
+
+export async function POST(req: Request) {
+ // ...
+ const agent = new Agent({
+ // This uses the OPENAI_API_KEY environment variable by default, but you
+ // can also pass { apiKey: string } as an option.
+ model: openai('gpt-5.1'), // [!code highlight]
+ // ...
+ });
+```
+
+
+
+
+
+
+
+### Get familiar with the code
+
+Let's take a moment to see what we're working with. Run the app with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. You should see a simple chat interface to play with. Go ahead and give it a try.
+
+The core code that makes all of this happen is quite simple. Here's a breakdown of the main parts. Note that there's no changes needed here, we're simply taking a look at the code to understand what's happening.
+
+
+
+
+
+Our API route makes a simple call to [AI SDK's `Agent` class](https://ai-sdk.dev/docs/agents/overview), which is a simple wrapper around [AI SDK's `streamText` function](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#streamtext). This is also where we pass tools to the agent.
+
+```typescript title="app/api/chat/route.ts" lineNumbers
+export async function POST(req: Request) {
+ const { messages }: { messages: UIMessage[] } = await req.json();
+ const agent = new Agent({ // [!code highlight]
+ model: gateway('bedrock/claude-4-5-haiku-20251001-v1'),
+ system: FLIGHT_ASSISTANT_PROMPT,
+ tools: flightBookingTools,
+ });
+ const modelMessages = convertToModelMessages(messages);
+ const stream = agent.stream({ messages: modelMessages }); // [!code highlight]
+ return createUIMessageStreamResponse({
+ stream: stream.toUIMessageStream(),
+ });
+}
+```
+
+
+
+
+
+Our tools are mostly mocked out for the sake of the example. We use AI SDK's `tool` function to define the tool, and pass it to the agent. In your own app, this might be any kind of tool call, like database queries, calls to external services, etc.
+
+```typescript title="workflows/chat/steps/tools.ts" lineNumbers
+import { tool } from 'ai';
+import { z } from 'zod';
+
+export const tools = {
+ searchFlights: tool({
+ description: 'Search for flights',
+ inputSchema: z.object({ query: z.string() }),
+ execute: searchFlights,
+ }),
+};
+
+async function searchFlights({ from, to, date }: { from: string; to: string; date: string }) {
+ // ... generate some fake flights
+}
+```
+
+
+
+
+
+Our `ChatPage` component has a lot of logic for nicely displaying the chat messages, but at it's core, it's simply managing input/output for the [`useChat` hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) from AI SDK.
+
+```typescript title="app/chat.tsx" lineNumbers
+'use client';
+
+import { useChat } from '@ai-sdk/react';
+
+export default function ChatPage() {
+ const { messages, input, handleInputChange, handleSubmit } = useChat({ // [!code highlight]
+ // ... other options ...
+ });
+
+ // ... more UI logic
+
+ return (
+
+ // This is a simplified example of the rendering logic
+ {messages.map((m) => (
+
+ {m.role}:
+ {m.parts.map((part, i) => {
+ if (part.type === 'text') { // [!code highlight]
+ return {part.text};
+ }
+ if (part.type === 'tool-searchFlights') { // [!code highlight]
+ // ... some special rendering for our tool results
+ }
+ return null;
+ })}
+
+ ))}
+
+
+ );
+}
+```
+
+
+
+
+
+
+
+
+
+## Integrating Workflow DevKit
+
+Now that we have a basic agent using AI SDK, we can modify it to make it durable.
+
+
+
+
+### Install Dependencies
+
+Add the Workflow DevKit packages to your project:
+
+```package-install
+npm i workflow @workflow/ai
+```
+
+and extend the Next.js config to transform your workflow code (see [Getting Started](/docs/getting-started/next) for more details).
+
+```typescript title="next.config.ts" lineNumbers
+import { withWorkflow } from 'workflow/next';
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ // ... rest of your Next.js config
+};
+
+export default withWorkflow(nextConfig);
+```
+
+
+
+
+
+### Create a Workflow Function
+
+Move the agent logic into a separate function, which will serve as our workflow definition.
+
+```typescript title="workflows/chat/workflow.ts" lineNumbers
+import { DurableAgent } from '@workflow/ai/agent'; // [!code highlight]
+import { getWritable } from 'workflow'; // [!code highlight]
+import { tools } from '@/ai/tools';
+import { openai } from '@workflow/ai/openai';
+import type { ModelMessage, UIMessageChunk } from 'ai';
+
+export async function chatWorkflow(messages: ModelMessage[]) {
+ 'use workflow'; // [!code highlight]
+
+ const writable = getWritable(); // [!code highlight]
+
+ const agent = new DurableAgent({ // [!code highlight]
+
+ // If using AI Gateway, just specify the model name as a string:
+ model: 'bedrock/claude-4-5-haiku-20251001-v1', // [!code highlight]
+
+ // ELSE if using a custom provider, pass the provider call as an argument:
+ model: openai('gpt-5.1'), // [!code highlight]
+
+ system: FLIGHT_ASSISTANT_PROMPT,
+ tools: flightBookingTools,
+ });
+
+ await agent.stream({ // [!code highlight]
+ messages,
+ writable,
+ });
+}
+```
+
+Key changes:
+
+- Add the `"use workflow"` directive to mark our Agent as a workflow function
+- Replaced `Agent` with [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) from `@workflow/ai/agent`. This ensures that all calls to the LLM are executed as "steps", and results are aggregated within the workflow context (see [Workflows and Steps](/docs/foundations/workflows-and-steps) for more details on how workflows/steps are defined).
+- Use [`getWritable()`](/docs/api-reference/workflow/get-writable) to get a stream for agent output. This stream is persistent, and API endpoints can read from a run's stream at any time.
+
+
+
+### Update the API Route
+
+Remove the agent call that we just extracted, and replace it with a call to `start()` to run the workflow:
+
+```typescript title="app/api/chat/route.ts" lineNumbers
+import type { UIMessage } from 'ai';
+import { convertToModelMessages, createUIMessageStreamResponse } 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, // [!code highlight]
+ });
+}
+```
+
+Key changes:
+
+- Call `start()` to run the workflow function. This returns a `Run` object, which contains the run ID and the readable stream (see [Starting Workflows](/docs/foundations/starting-workflows) for more details on the `Run` object).
+- Pass the `writable` to `agent.stream()` instead of returning a stream directly, ensuring all the Agent output is written to to the run's stream.
+
+
+
+
+### Convert Tools to Steps
+
+Mark all tool definitions with `"use step"` to make them durable. This enables automatic retries and observability for each tool call:
+
+```typescript title="workflows/chat/steps/tools.ts" lineNumbers
+// ...
+
+export async function searchFlights(
+ // ... arguments
+) {
+ 'use step'; // [!code highlight]
+
+ // ... rest of the tool code
+}
+
+export async function checkFlightStatus(
+ // ... arguments
+) {
+ 'use step'; // [!code highlight]
+
+ // ... rest of the tool code
+}
+
+export async function getAirportInfo(
+ // ... arguments
+) {
+ 'use step'; // [!code highlight]
+
+ // ... rest of the tool code
+}
+
+export async function bookFlight({
+ // ... arguments
+}) {
+ 'use step'; // [!code highlight]
+
+ // ... rest of the tool code
+}
+
+export async function checkBaggageAllowance(
+ // ... arguments
+) {
+ 'use step'; // [!code highlight]
+
+ // ... rest of the tool code
+ }
+}
+```
+
+With `"use step"`:
+
+- The tool execution runs in a separate step with full Node.js access. In production, each step is executed in a separate worker process, which scales automatically with your workload.
+- Failed tool calls are automatically retried (up to 3 times by default). See [Errors and Retries](/docs/foundations/errors-and-retries) for more details.
+- Each tool execution appears as a discrete step in observability tools. See [Observability](/docs/observability) for more details.
+
+
+
+
+That's all you need to do to convert your basic AI SDK agent into a durable agent. If you run your development server, and send a chat message, you should see your agent respond just as before, but now with added durability and observability.
+
+## Observability
+
+In your app directory, you can open up the observability dashboard to see your workflow in action, using the CLI:
+
+```bash
+npx workflow web
+```
+
+This opens a local dashboard showing all workflow runs and their status, as well as a trace viewer to inspect the workflow in detail, including retry attempts, and the data being passed between steps.
+
+## Next Steps
+
+Now that you have a basic durable agent, it's a only a short step to add these additional features:
+
+
+
+ Stream progress updates from tools to the UI while they're executing.
+
+
+ Enable clients to reconnect to interrupted streams without losing data.
+
+
+ Add native sleep, suspense, and scheduling functionality to your Agent and workflow.
+
+
+ Implement approval steps to wait for human input or external events.
+
+
+
+## Complete Example
+
+A complete example that includes all of the above, plus all of the "next steps" features is available on the main branch of the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example.
+
+## Related Documentation
+
+- [Tools](/docs/ai/defining-tools) - Patterns for defining tools for your agent
+- [`DurableAgent` API Reference](/docs/api-reference/workflow-ai/durable-agent) - Full API documentation
+- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Core concepts
+- [Streaming](/docs/foundations/streaming) - In-depth streaming guide
+- [Errors and Retries](/docs/foundations/errors-and-retries) - Error handling patterns
diff --git a/docs/content/docs/ai/message-queueing.mdx b/docs/content/docs/ai/message-queueing.mdx
new file mode 100644
index 000000000..5567e2ed1
--- /dev/null
+++ b/docs/content/docs/ai/message-queueing.mdx
@@ -0,0 +1,102 @@
+---
+title: Queueing User Messages
+---
+
+When using [multi-turn workflows](/docs/ai/chat-session-modeling#multi-turn-workflows), messages typically arrive between agent turns. The workflow waits at a hook, receives a message, then starts a new turn. But sometimes you need to inject messages *during* an agent's turn, before tool calls complete or while the model is reasoning.
+
+`DurableAgent`'s `prepareStep` callback enables this by running before each step in the agent loop, giving you a chance to inject queued messages into the conversation. `prepareStep` also allows you to modify the model choice and existing messages mid-turn, see AI SDK's [prepareStep callback](https://ai-sdk.dev/docs/agents/loop-control#prepare-step) for more details.
+
+## When to Use This
+
+Message queueing is useful when:
+
+- Users send follow-up messages while the agent is still searching for flights or processing bookings
+- External systems need to inject context mid-turn (e.g., a flight status webhook fires during processing)
+- You want messages to influence the agent's next step rather than waiting for the current turn to complete
+
+
+If you just need basic multi-turn conversations where messages arrive between turns, see [Chat Session Modeling](/docs/ai/chat-session-modeling). This guide covers the more advanced case of injecting messages *during* turns.
+
+
+## The `prepareStep` Callback
+
+The `prepareStep` callback runs before each step in the agent loop. It receives the current state and can modify the messages sent to the model:
+
+```typescript lineNumbers
+interface PrepareStepInfo {
+ model: string | (() => Promise); // Current model
+ stepNumber: number; // 0-indexed step count
+ steps: StepResult[]; // Previous step results
+ messages: LanguageModelV2Prompt; // Messages to be sent
+}
+
+interface PrepareStepResult {
+ model?: string | (() => Promise); // Override model
+ messages?: LanguageModelV2Prompt; // Override messages
+}
+```
+
+## Injecting Queued Messages
+
+Once you have a [multi-turn workflow](/docs/ai/chat-session-modeling#multi-turn-workflows), you can combine a message queue with `prepareStep` to inject messages that arrive during processing:
+
+```typescript title="workflows/chat/workflow.ts" lineNumbers
+import { DurableAgent } from '@workflow/ai/agent';
+import type { UIMessageChunk } from 'ai';
+import { getWritable } from 'workflow';
+import { chatMessageHook } from './hooks/chat-message';
+import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from './steps/tools';
+
+export async function chatWorkflow(threadId: string, initialMessage: string) {
+ 'use workflow';
+
+ const writable = getWritable();
+ const messageQueue: Array<{ role: 'user'; content: string }> = []; // [!code highlight]
+
+ const agent = new DurableAgent({
+ model: 'bedrock/claude-4-5-haiku-20251001-v1',
+ system: FLIGHT_ASSISTANT_PROMPT,
+ tools: flightBookingTools,
+ });
+
+ // Listen for messages in background (non-blocking) // [!code highlight]
+ const hook = chatMessageHook.create({ token: `thread:${threadId}` }); // [!code highlight]
+ hook.then(({ message }) => { // [!code highlight]
+ messageQueue.push({ role: 'user', content: message }); // [!code highlight]
+ }); // [!code highlight]
+
+ await agent.stream({
+ messages: [{ role: 'user', content: initialMessage }],
+ writable,
+ prepareStep: ({ messages: currentMessages }) => { // [!code highlight]
+ // Inject any queued messages before the next LLM call // [!code highlight]
+ if (messageQueue.length > 0) { // [!code highlight]
+ const newMessages = messageQueue.splice(0); // Drain queue // [!code highlight]
+ return { // [!code highlight]
+ messages: [ // [!code highlight]
+ ...currentMessages, // [!code highlight]
+ ...newMessages.map(m => ({ // [!code highlight]
+ role: m.role, // [!code highlight]
+ content: [{ type: 'text' as const, text: m.content }], // [!code highlight]
+ })), // [!code highlight]
+ ], // [!code highlight]
+ }; // [!code highlight]
+ } // [!code highlight]
+ return {}; // [!code highlight]
+ }, // [!code highlight]
+ });
+}
+```
+
+Messages sent via `chatMessageHook.resume()` accumulate in the queue and get injected before the next step, whether that's a tool call or another LLM request.
+
+
+The `prepareStep` callback receives messages in `LanguageModelV2Prompt` format (with content arrays), which is the internal format used by the AI SDK.
+
+
+## Related Documentation
+
+- [Chat Session Modeling](/docs/ai/chat-session-modeling) - Single-turn vs multi-turn patterns
+- [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents
+- [`DurableAgent` API Reference](/docs/api-reference/workflow-ai/durable-agent) - Full API documentation
+- [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Hook configuration options
diff --git a/docs/content/docs/ai/meta.json b/docs/content/docs/ai/meta.json
new file mode 100644
index 000000000..592d2228e
--- /dev/null
+++ b/docs/content/docs/ai/meta.json
@@ -0,0 +1,12 @@
+{
+ "title": "AI Agents",
+ "pages": [
+ "index",
+ "streaming-updates-from-tools",
+ "resumable-streams",
+ "sleep-and-delays",
+ "human-in-the-loop",
+ "defining-tools"
+ ],
+ "defaultOpen": true
+}
diff --git a/docs/content/docs/ai/resumable-streams.mdx b/docs/content/docs/ai/resumable-streams.mdx
new file mode 100644
index 000000000..87aaccafd
--- /dev/null
+++ b/docs/content/docs/ai/resumable-streams.mdx
@@ -0,0 +1,157 @@
+---
+title: Resumable Streams
+---
+
+When building chat interfaces, it's common to run into network interruptions, page refreshes, or serverless function timeouts, which can break the connection to an in-progress agent.
+
+Where a standard chat implementation would require the user to resend their message and wait for the entire response again, workflow runs are durable, and so are the streams attached to them. This means a stream can be resumed at any point, optionally only syncing the data that was missed since the last connection.
+
+Resumable streams come out of the box with Workflow DevKit, however, the client needs to recognize that a stream exists, and needs to know which stream to reconnect to, and needs to know where to start from. For this, Workflow DevKit provides the [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) helper, a drop-in transport for the AI SDK that handles client-side resumption logic for you.
+
+## Implementing stream resumption
+
+Let's add stream resumption to our Flight Booking Agent that we build in the [Building Durable AI Agents](/docs/ai) guide.
+
+
+
+
+
+### Return the Run ID from Your API
+
+Modify your chat endpoint to include the workflow run ID in a response header. The Run ID uniquely identifies the run's stream, so it allows the client to know which stream to reconnect to.
+
+```typescript title="app/api/chat/route.ts" lineNumbers
+// ... imports ...
+
+export async function POST(req: Request) {
+
+ // ... existing logic to create the workflow ...
+
+ const run = await start(chatWorkflow, [modelMessages]);
+
+ return createUIMessageStreamResponse({
+ stream: run.readable,
+ headers: { // [!code highlight
+ 'x-workflow-run-id': run.runId, // [!code highlight]
+ }, // [!code highlight]
+ });
+}
+```
+
+
+
+
+
+### Add a Stream Reconnection Endpoint
+
+Currently we only have one API endpoint that always creates a new run, so we need to create a new API route that returns the stream for an existing run:
+
+```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers
+import { createUIMessageStreamResponse } from 'ai';
+import { getRun } from 'workflow/api'; // [!code highlight]
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params;
+ const { searchParams } = new URL(request.url);
+
+ // Client provides the last chunk index they received
+ const startIndexParam = searchParams.get('startIndex'); // [!code highlight]
+ const startIndex = startIndexParam
+ ? parseInt(startIndexParam, 10)
+ : undefined;
+
+ // Instead of starting a new run, we fetch an existing run.
+ const run = getRun(id); // [!code highlight]
+ const stream = run.getReadable({ startIndex }); // [!code highlight]
+
+ return createUIMessageStreamResponse({ stream }); // [!code highlight]
+}
+```
+
+The `startIndex` parameter ensures the client can choose where to resume the stream from. For instance, if the function times out during streaming, the chat transport will use `startIndex` to resume the stream exactly from the last token it received.
+
+
+
+
+
+### Use `WorkflowChatTransport` in the Client
+
+Replace the default transport in AI-SDK's `useChat` with [`WorkflowChatTransport`](
+ /docs/api-reference/workflow-ai/workflow-chat-transport
+), and update the callbacks to store and use the latest run ID. For now, we'll store the run ID in localStorage. For your own app, this would be stored wherever you store session information.
+
+```typescript title="app/page.tsx" lineNumbers
+'use client';
+
+import { useChat } from '@ai-sdk/react';
+import { WorkflowChatTransport } from '@workflow/ai'; // [!code highlight]
+import { useMemo, useState } from 'react';
+
+export default function ChatPage() {
+
+ // Check for an active workflow run on mount
+ const activeRunId = useMemo(() => { // [!code highlight]
+ if (typeof window === 'undefined') return; // [!code highlight]
+ return localStorage.getItem('active-workflow-run-id') ?? undefined; // [!code highlight]
+ }, []); // [!code highlight]
+
+ const { messages, sendMessage, status } = useChat({
+ resume: Boolean(activeRunId), // [!code highlight]
+ transport: new WorkflowChatTransport({ // [!code highlight]
+ api: '/api/chat',
+
+ // Store the run ID when a new chat starts
+ onChatSendMessage: (response) => { // [!code highlight]
+ const workflowRunId = response.headers.get('x-workflow-run-id'); // [!code highlight]
+ if (workflowRunId) { // [!code highlight]
+ localStorage.setItem('active-workflow-run-id', workflowRunId); // [!code highlight]
+ } // [!code highlight]
+ }, // [!code highlight]
+
+ // Clear the run ID when the chat completes
+ onChatEnd: () => { // [!code highlight]
+ localStorage.removeItem('active-workflow-run-id'); // [!code highlight]
+ }, // [!code highlight]
+
+ // Use the stored run ID for reconnection
+ prepareReconnectToStreamRequest: ({ api, ...rest }) => { // [!code highlight]
+ const runId = localStorage.getItem('active-workflow-run-id'); // [!code highlight]
+ if (!runId) throw new Error('No active workflow run ID found'); // [!code highlight]
+ return { // [!code highlight]
+ ...rest, // [!code highlight]
+ api: `/api/chat/${encodeURIComponent(runId)}/stream`, // [!code highlight]
+ }; // [!code highlight]
+ }, // [!code highlight]
+ }), // [!code highlight]
+ });
+
+ // ... render your chat UI
+}
+```
+
+
+
+
+
+Now try the flight booking example again. Open it up in a separate tab, or spam the refresh button, and see how the client connects to the same chat stream every time.
+
+## How It Works
+
+1. When the user sends a message, `WorkflowChatTransport` makes a POST to `/api/chat`
+2. The API starts a workflow and returns the run ID in the `x-workflow-run-id` header
+3. `onChatSendMessage` stores this run ID in localStorage
+4. If the stream is interrupted before receiving a "finish" chunk, the transport automatically reconnects
+5. `prepareReconnectToStreamRequest` builds the reconnection URL using the stored run ID, pointing to the new endpoint `/api/chat/{runId}/stream`
+6. The reconnection endpoint returns the stream from where the client left off
+7. When the stream completes, `onChatEnd` clears the stored run ID
+
+This approach also handles page refreshes, as the client will automatically reconnect to the stream from the last known position when the UI loads with a stored run ID, following the behavior of [AI SDK's stream resumption](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams#chatbot-resume-streams).
+
+## Related Documentation
+
+- [`WorkflowChatTransport` API Reference](/docs/api-reference/workflow-ai/workflow-chat-transport) - Full configuration options
+- [Streaming](/docs/foundations/streaming) - Understanding workflow streams
+- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Retrieving existing runs
diff --git a/docs/content/docs/ai/sleep-and-delays.mdx b/docs/content/docs/ai/sleep-and-delays.mdx
new file mode 100644
index 000000000..2ae7cf3c2
--- /dev/null
+++ b/docs/content/docs/ai/sleep-and-delays.mdx
@@ -0,0 +1,201 @@
+---
+title: Sleep, Suspense, and Scheduling
+---
+
+AI agents sometimes need to pause execution in order to schedule recurring or future actions, wait before retrying an operation (e.g. for rate limiting), or wait for external state to be available.
+
+Workflow DevKit's `sleep` function enables Agents to pause execution without consuming resources, and resume at a specified time, after a specified duration, or in response to an external event. Workflow operation that suspend will survive restarts, new deploys, and infrastructure changes, independent of whether the suspense takes seconds or months.
+
+
+See the [`sleep()` API Reference](/docs/api-reference/workflow/sleep) for the full list of supported duration formats and detailed API documentation, and see the [hooks](/docs/foundations/hooks) documentation for more information on how to resume in response to external events.
+
+
+## Adding a Sleep Tool
+
+We can expose the sleep tool directly to the Agent by wrapping it in a tool.
+
+
+
+
+
+### Define the Tool
+
+Add a new "sleep" tool to the `tools` defined in `workflows/chat/steps/tools.ts`:
+
+```typescript title="workflows/chat/steps/tools.ts" lineNumbers
+import { getWritable, sleep } from 'workflow'; // [!code highlight]
+
+// ... existing imports ...
+
+async function executeSleep( // [!code highlight]
+ { durationMs }: { durationMs: number }, // [!code highlight]
+ { toolCallId }: { toolCallId: string } // [!code highlight]
+) { // [!code highlight]
+ // Note: No "use step" here - sleep is a workflow-level function // [!code highlight]
+ await sleep(durationMs); // [!code highlight]
+ return { message: `Slept for ${durationMs}ms` }; // [!code highlight]
+}
+
+// ... existing tool functions ...
+
+export const flightBookingTools = {
+ // ... existing tool definitions ...
+ sleep: { // [!code highlight]
+ description: 'Pause execution for a specified duration', // [!code highlight]
+ inputSchema: durationMs: z.number().describe('Duration to sleep in milliseconds'), // [!code highlight]
+ execute: executeSleep, // [!code highlight]
+ } // [!code highlight]
+}
+```
+
+
+ Note that the `sleep()` function must be called from within a workflow context, not from within a step. This is why `executeSleep` does not have `"use step"` - it runs in the workflow context where `sleep()` is available.
+
+
+ This already makes the full sleep functionality available to the Agent!
+
+
+
+
+
+### Show the tool status in the UI
+
+To round it off, extend the UI to display the tool call status. This can be done either by displaying the tool call information directly, or by emitting custom data parts to the stream (see [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools) for more details). In this case, since there aren't any fine-grained progress updates to show, we'll just display the tool call information directly:
+
+```typescript title="app/page.tsx" lineNumbers
+export default function ChatPage() {
+
+ // ...
+
+ const { stop, messages, sendMessage, status, setMessages } =
+ useChat({
+ // ... options
+ });
+
+ // ...
+
+ return (
+
// [!code highlight]
+ ); // [!code highlight]
+ }
+ // ...
+}
+
+```
+
+
+
+
+
+## Use Cases
+
+Aside from providing `sleep()` as a tool, there are other use cases for Agents that commonly call for suspension and resumption.
+
+### Rate Limiting
+
+When hitting API rate limits, use `RetryableError` with a delay:
+
+```typescript lineNumbers
+async function callRateLimitedAPI(endpoint: string) {
+ 'use step';
+
+ const response = await fetch(endpoint);
+
+ if (response.status === 429) {
+ const retryAfter = response.headers.get('Retry-After');
+ throw new RetryableError('Rate limited', {
+ retryAfter: retryAfter ? parseInt(retryAfter) * 1000 : '1m',
+ });
+ }
+
+ return response.json();
+}
+```
+
+### Polling with Backoff
+
+Poll for a result with increasing delays:
+
+```typescript lineNumbers
+export async function pollForResult(jobId: string) {
+ 'use workflow';
+
+ let attempt = 0;
+ const maxAttempts = 10;
+
+ while (attempt < maxAttempts) {
+ const result = await checkJobStatus(jobId);
+
+ if (result.status === 'complete') {
+ return result.data;
+ }
+
+ attempt++;
+ await sleep(Math.min(1000 * 2 ** attempt, 60000)); // Exponential backoff, max 1 minute
+ }
+
+ throw new Error('Job did not complete in time');
+}
+
+async function checkJobStatus(jobId: string) {
+ 'use step';
+ // Check job status...
+}
+```
+
+## Related Documentation
+
+- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full API documentation with all duration formats
+- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow context
+- [Errors and Retries](/docs/foundations/errors-and-retries) - Using `RetryableError` with delays
diff --git a/docs/content/docs/ai/streaming-updates-from-tools.mdx b/docs/content/docs/ai/streaming-updates-from-tools.mdx
new file mode 100644
index 000000000..b8a0904a0
--- /dev/null
+++ b/docs/content/docs/ai/streaming-updates-from-tools.mdx
@@ -0,0 +1,138 @@
+---
+title: Streaming Updates from Tools
+---
+
+After [building a durable AI agent](/docs/ai), we already get UI message chunks for displaying tool invocations and return values. However, for long-running steps, we may want to show progress updates, or stream step output to the user while it's being generated.
+
+Workflow DevKit enables this by letting step functions write custom chunks to the same stream the agent uses. These chunks appear as data parts in your messages, which you can render however you like.
+
+As an example, we'll extend out Flight Booking Agent to use emit more granular progress updates while searching for flights.
+
+
+
+
+
+### Define Your Data Part Type
+
+First, define a TypeScript type for your custom data part. This ensures type safety across your tool and client code:
+
+```typescript title="schemas/chat.ts" lineNumbers
+export interface FoundFlightDataPart {
+ type: 'data-found-flight'; // [!code highlight]
+ id: string;
+ data: {
+ flightNumber: string;
+ from: string;
+ to: string;
+ };
+}
+```
+
+The `type` field must be a string starting with `data-` followed by your custom identifier. The `id` field should match the `toolCallId` so the client can associate the data with the correct tool invocation. Learn more about [data parts](https://ai-sdk.dev/docs/ai-sdk-ui/streaming-data#data-parts-persistent) in the AI SDK documentation.
+
+
+
+
+
+### Emit Updates from Your Tool
+
+Use [`getWritable()`](/docs/api-reference/workflow/get-writable) inside a step function to get a handle to the stream. This is the same stream that the LLM and other tools calls are writing to, so we can inject out own data packets directly.
+
+```typescript title="workflows/chat/steps/tools.ts" lineNumbers
+import { getWritable } from 'workflow'; // [!code highlight]
+import type { UIMessageChunk } from 'ai';
+
+export async function searchFlights(
+ { from, to, date }: { from: string; to: string; date: string },
+ { toolCallId }: { toolCallId: string } // [!code highlight]
+) {
+ 'use step';
+
+ const writable = getWritable(); // [!code highlight]
+ const writer = writable.getWriter(); // [!code highlight]
+
+ // ... existing logic to generate flights ...
+
+ for (const flight of generatedFlights) { // [!code highlight]
+
+ // Simulate the time it takes to find each flight
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // [!code highlight]
+
+ await writer.write({ // [!code highlight]
+ id: `${toolCallId}-${flight.flightNumber}`, // [!code highlight]
+ type: 'data-found-flight', // [!code highlight]
+ data: flight, // [!code highlight]
+ }); // [!code highlight]
+ } // [!code highlight]
+
+ writer.releaseLock(); // [!code highlight]
+
+ return {
+ message: `Found ${generatedFlights.length} flights from ${from} to ${to} on ${date}`,
+ flights: generatedFlights.sort((a, b) => a.price - b.price), // Sort by price
+ };
+}
+```
+
+Key points:
+
+- Call `getWritable()` to get the stream
+- Use `getWriter()` to acquire a writer
+- Write objects with `type`, `id`, and `data` fields
+- Always call `releaseLock()` when done writing (learn more about [streaming](/docs/foundations/streaming))
+
+
+
+
+### Handle Data Parts in the Client
+
+Update your chat component to detect and render the custom data parts. Data parts are stored in the message's `parts` array alongside text and tool invocation parts:
+
+```typescript title="app/page.tsx" lineNumbers
+{message.parts.map((part, partIndex) => {
+ // Render text parts
+ if (part.type === 'text') {
+ return (
+
+ {part.text}
+
+ );
+ }
+
+ // Render streaming flight data parts // [!code highlight]
+ if (part.type === 'data-found-flight') { // [!code highlight]
+ const flight = part.data as { // [!code highlight]
+ flightNumber: string; // [!code highlight]
+ airline: string; // [!code highlight]
+ from: string; // [!code highlight]
+ to: string; // [!code highlight]
+ }; // [!code highlight]
+ return ( // [!code highlight]
+
// [!code highlight]
+
{flight.airline} - {flight.flightNumber}
// [!code highlight]
+
{flight.from} → {flight.to}
// [!code highlight]
+
// [!code highlight]
+ ); // [!code highlight]
+ } // [!code highlight]
+
+ // ... other rendering logic ...
+})}
+```
+
+The pattern is:
+
+1. Data parts have a `type` field starting with `data-`
+2. Match the type to your custom identifier (e.g., `data-found-flight`)
+3. Use the data part's payload to display progress or intermediate results
+
+
+
+
+
+Now, when you run the agent to search for flights, you'll see the flight results pop up one after another. This will be most useful if you have tool calls that take minutes to complete, and you need to show granular progress updates to the user.
+
+## Related Documentation
+
+- [Building Durable AI Agents](/docs/ai) - Complete guide to durable agents
+- [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Stream API details
+- [Streaming](/docs/foundations/streaming) - Understanding workflow streams
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..4d97707f4 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](/docs/ai) - Complete guide to creating durable agents
+- [Queueing User Messages](/docs/ai/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/getting-started/express.mdx b/docs/content/docs/getting-started/express.mdx
index 14d54d3a6..f41f97143 100644
--- a/docs/content/docs/getting-started/express.mdx
+++ b/docs/content/docs/getting-started/express.mdx
@@ -233,7 +233,10 @@ Check the Express development server logs to see your workflow execute as well a
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
-npx workflow inspect runs # add '--web' for an interactive Web based UI
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
+npx workflow inspect runs
```
diff --git a/docs/content/docs/getting-started/hono.mdx b/docs/content/docs/getting-started/hono.mdx
index ef12960e3..df7cb3ded 100644
--- a/docs/content/docs/getting-started/hono.mdx
+++ b/docs/content/docs/getting-started/hono.mdx
@@ -218,7 +218,10 @@ Check the Hono development server logs to see your workflow execute as well as t
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
-npx workflow inspect runs # add '--web' for an interactive Web based UI
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
+npx workflow inspect runs
```
diff --git a/docs/content/docs/getting-started/next.mdx b/docs/content/docs/getting-started/next.mdx
index b822f170e..b4e09535e 100644
--- a/docs/content/docs/getting-started/next.mdx
+++ b/docs/content/docs/getting-started/next.mdx
@@ -241,8 +241,10 @@ Check the Next.js development server logs to see your workflow execute, as well
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
-# or add '--web' for an interactive Web based UI
```
diff --git a/docs/content/docs/getting-started/nitro.mdx b/docs/content/docs/getting-started/nitro.mdx
index 94ec98748..ae622c789 100644
--- a/docs/content/docs/getting-started/nitro.mdx
+++ b/docs/content/docs/getting-started/nitro.mdx
@@ -202,7 +202,10 @@ Check the Nitro development server logs to see your workflow execute as well as
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
-npx workflow inspect runs # add '--web' for an interactive Web based UI
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
+npx workflow inspect runs
```
diff --git a/docs/content/docs/getting-started/nuxt.mdx b/docs/content/docs/getting-started/nuxt.mdx
index d89bd7803..a23954703 100644
--- a/docs/content/docs/getting-started/nuxt.mdx
+++ b/docs/content/docs/getting-started/nuxt.mdx
@@ -202,7 +202,10 @@ Check the Nuxt development server logs to see your workflow execute as well as t
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
-npx workflow inspect runs # add '--web' for an interactive Web based UI
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
+npx workflow inspect runs
```
@@ -222,4 +225,3 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b
- Learn more about the [Foundations](/docs/foundations).
- Check [Errors](/docs/errors) if you encounter issues.
- Explore the [API Reference](/docs/api-reference).
-
diff --git a/docs/content/docs/getting-started/sveltekit.mdx b/docs/content/docs/getting-started/sveltekit.mdx
index 451e67523..0cd85534e 100644
--- a/docs/content/docs/getting-started/sveltekit.mdx
+++ b/docs/content/docs/getting-started/sveltekit.mdx
@@ -206,8 +206,10 @@ Check the SvelteKit development server logs to see your workflow execute as well
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
-# or add '--web' for an interactive Web based UI
```
diff --git a/docs/content/docs/getting-started/vite.mdx b/docs/content/docs/getting-started/vite.mdx
index 62eced9d3..cf01afdeb 100644
--- a/docs/content/docs/getting-started/vite.mdx
+++ b/docs/content/docs/getting-started/vite.mdx
@@ -209,8 +209,10 @@ Check the Vite development server logs to see your workflow execute as well as t
Additionally, you can use the [Workflow DevKit CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail.
```bash
+# Open the observability Web UI
+npx workflow web
+# or if you prefer a terminal interface, use the CLI inspect command
npx workflow inspect runs
-# or add '--web' for an interactive Web based UI
```
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index b527dc11f..218fd4fa5 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -6,6 +6,7 @@
"foundations",
"how-it-works",
"observability",
+ "ai",
"deploying",
"errors",
"api-reference"
diff --git a/docs/content/docs/observability/index.mdx b/docs/content/docs/observability/index.mdx
index e25c538c7..4daf9b867 100644
--- a/docs/content/docs/observability/index.mdx
+++ b/docs/content/docs/observability/index.mdx
@@ -36,7 +36,7 @@ npx workflow inspect runs --web
## Backends
-The Workflow DevKit CLI can inspect data from any [World](/docs/deploying#what-are-worlds). By default, it inspects data in your local development environment. For example, if you are using NextJS to develop workflows locally, the
+The Workflow DevKit CLI can inspect data from any [World](/docs/deploying#what-are-worlds). By default, it inspects data in your local development environment. For example, if you are using Next.js to develop workflows locally, the
CLI will find the data in your `.next/workflow-data/` directory.
If you're deploying workflows to a production environment, but want to inspect the data by using the CLI, you can specify the world you are using by setting the `--backend` flag to your world's name or package name, e.g. `vercel`.
diff --git a/docs/content/guides/meta.json b/docs/content/guides/meta.json
new file mode 100644
index 000000000..94799779e
--- /dev/null
+++ b/docs/content/guides/meta.json
@@ -0,0 +1,3 @@
+{
+ "pages": ["ai-agents"]
+}
diff --git a/docs/scripts/lint.ts b/docs/scripts/lint.ts
old mode 100644
new mode 100755