From 704b0021bb42c91c7c72760e83b37986ce2a23b7 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 13:44:32 -0800 Subject: [PATCH 01/38] [docs] Add AI Agent docs --- .../docs/ai-agents/human-in-the-loop.mdx | 307 ++++++++++++++++++ docs/content/docs/ai-agents/index.mdx | 237 ++++++++++++++ docs/content/docs/ai-agents/meta.json | 10 + .../docs/ai-agents/resumable-streams.mdx | 210 ++++++++++++ .../docs/ai-agents/sleep-and-delays.mdx | 219 +++++++++++++ docs/content/docs/meta.json | 1 + 6 files changed, 984 insertions(+) create mode 100644 docs/content/docs/ai-agents/human-in-the-loop.mdx create mode 100644 docs/content/docs/ai-agents/index.mdx create mode 100644 docs/content/docs/ai-agents/meta.json create mode 100644 docs/content/docs/ai-agents/resumable-streams.mdx create mode 100644 docs/content/docs/ai-agents/sleep-and-delays.mdx diff --git a/docs/content/docs/ai-agents/human-in-the-loop.mdx b/docs/content/docs/ai-agents/human-in-the-loop.mdx new file mode 100644 index 000000000..c5e1ee0fb --- /dev/null +++ b/docs/content/docs/ai-agents/human-in-the-loop.mdx @@ -0,0 +1,307 @@ +--- +title: Human-in-the-Loop +--- + +Some agent actions require human approval before proceeding - deploying to production, sending emails to customers, or making financial transactions. Workflow DevKit's webhook and hook primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action. + +## Creating an Approval Tool + +Add a tool that pauses the agent until a human approves or rejects: + +```typescript title="ai/tools/human-approval.ts" lineNumbers +import { tool } from 'ai'; +import { createWebhook, getWritable } from 'workflow'; // [!code highlight] +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +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(); +} + +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] + await webhook; // [!code highlight] + + return 'Approval received. Proceeding with action.'; +} + +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, +}); +``` + + +The `createWebhook()` function must be called from within a workflow context, not from a step. This is why `executeHumanApproval` does not have `"use step"`. + + +## How It Works + +1. The agent calls the `humanApproval` tool with a message describing what needs approval +2. `createWebhook()` generates a unique URL that can resume the workflow +3. The tool emits a data chunk containing the approval URL to the UI +4. The workflow pauses at `await webhook` - no compute resources are consumed +5. When a human visits the webhook URL, the workflow resumes +6. The tool returns and the agent continues with the approved action + +## Handling the Approval UI + +The UI receives a data chunk with type `data-approval-required`. Display an approval button that triggers the webhook: + +```typescript title="components/approval-button.tsx" lineNumbers +'use client'; + +interface ApprovalData { + url: string; + message: string; +} + +export function ApprovalButton({ data }: { data: ApprovalData }) { + const handleApprove = async () => { + await fetch(data.url, { method: 'POST' }); + }; + + return ( +
+

{data.message}

+ +
+ ); +} +``` + +## Receiving Approval Data + +To receive data from the approval action (such as approve/reject status or comments), read the webhook request body: + +```typescript title="ai/tools/human-approval.ts" lineNumbers +async function executeHumanApproval( + { message }: { message: string }, + { toolCallId }: { toolCallId: string } +) { + const webhook = createWebhook(); + + await emitApprovalRequest({ url: webhook.url, message }, { toolCallId }); + + const request = await webhook; // [!code highlight] + const { approved, comment } = await request.json(); // [!code highlight] + + if (!approved) { + return `Action rejected: ${comment}`; + } + + return `Approved with comment: ${comment}`; +} +``` + +The UI sends the approval data in the request body: + +```typescript lineNumbers +const handleApprove = async () => { + await fetch(data.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved: true, comment: 'Looks good!' }), + }); +}; + +const handleReject = async () => { + await fetch(data.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved: false, comment: 'Not ready yet' }), + }); +}; +``` + +## Using Hooks for Type-Safe Approvals + +For stronger type safety, use [`defineHook()`](/docs/api-reference/workflow/define-hook) with a schema: + +```typescript title="ai/tools/deployment-approval.ts" lineNumbers +import { tool } from 'ai'; +import { defineHook, getWritable } from 'workflow'; +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +// Define a typed hook for deployment approvals +const deploymentApprovalHook = defineHook({ // [!code highlight] + schema: z.object({ // [!code highlight] + approved: z.boolean(), // [!code highlight] + approvedBy: z.string(), // [!code highlight] + environment: z.enum(['staging', 'production']), // [!code highlight] + }), // [!code highlight] +}); // [!code highlight] + +async function emitDeploymentApproval( + token: string, + environment: string, + toolCallId: string +) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + await writer.write({ + id: toolCallId, + type: 'data-deployment-approval', + data: { token, environment }, + }); + + writer.releaseLock(); +} + +async function executeDeploymentApproval( + { environment }: { environment: 'staging' | 'production' }, + { toolCallId }: { toolCallId: string } +) { + const hook = deploymentApprovalHook.create(); // [!code highlight] + + await emitDeploymentApproval(hook.token, environment, toolCallId); + + const approval = await hook; // [!code highlight] + + if (!approval.approved) { + return `Deployment to ${environment} rejected by ${approval.approvedBy}`; + } + + return `Deployment to ${environment} approved by ${approval.approvedBy}`; +} + +export const deploymentApproval = tool({ + description: 'Request approval for a deployment', + inputSchema: z.object({ + environment: z.enum(['staging', 'production']), + }), + execute: executeDeploymentApproval, +}); +``` + +Resume the hook from your approval API: + +```typescript title="app/api/approve-deployment/route.ts" lineNumbers +import { deploymentApprovalHook } from '@/ai/tools/deployment-approval'; + +export async function POST(request: Request) { + const { token, approved, approvedBy, environment } = await request.json(); + + try { + // Schema validation happens automatically // [!code highlight] + await deploymentApprovalHook.resume(token, { // [!code highlight] + approved, // [!code highlight] + approvedBy, // [!code highlight] + environment, // [!code highlight] + }); // [!code highlight] + + return Response.json({ success: true }); + } catch (error) { + return Response.json( + { error: 'Invalid token or validation failed' }, + { status: 400 } + ); + } +} +``` + +## Use Cases + +### Email Approval + +Wait for approval before sending an email to customers: + +```typescript lineNumbers +async function executeSendEmail( + { recipients, subject, body }: EmailParams, + { toolCallId }: { toolCallId: string } +) { + const webhook = createWebhook(); + + await emitEmailApproval({ + url: webhook.url, + recipients, + subject, + preview: body.substring(0, 200), + }, toolCallId); + + const request = await webhook; + const { approved } = await request.json(); + + if (!approved) { + return 'Email cancelled by user'; + } + + await sendEmail({ recipients, subject, body }); + return `Email sent to ${recipients.length} recipients`; +} +``` + +### Multi-Step Approval + +Chain multiple approvals for high-risk actions: + +```typescript lineNumbers +export async function criticalActionWorkflow(action: string) { + 'use workflow'; + + // First approval: Team lead + const leadApproval = await requestApproval({ + role: 'team-lead', + action, + }); + + if (!leadApproval.approved) { + return { status: 'rejected', stage: 'team-lead' }; + } + + // Second approval: Manager + const managerApproval = await requestApproval({ + role: 'manager', + action, + }); + + if (!managerApproval.approved) { + return { status: 'rejected', stage: 'manager' }; + } + + await executeCriticalAction(action); + return { status: 'completed' }; +} +``` + +## 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-agents/index.mdx b/docs/content/docs/ai-agents/index.mdx new file mode 100644 index 000000000..7f9e653c2 --- /dev/null +++ b/docs/content/docs/ai-agents/index.mdx @@ -0,0 +1,237 @@ +--- +title: Building Durable AI Agents +--- + + +`@workflow/ai` is in public beta and should be considered experimental. + + +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 allows you to designate these loops as stateful, resumable workflows, with LLM calls, tool calls, and other async operations as individual, retryable, observable steps. + +This guide walks through converting a basic AI SDK agent into a durable agent using Workflow DevKit. + +## Why Durable Agents? + +Building production-ready AI agents typically requires solving several challenges: + +- **Durability**: Queue management, tracking sub-jobs, and handling task execution across failures +- **Reliability**: Error handling, retries, and recovery from partial failures +- **Observability**: Storing messages, emitting traces, and debugging agent behavior +- **Resumability**: Persisting state to a database, managing stream providers for reconnection +- **Long-running operations**: Sleep, delays, and month-long workflows without idle costs +- **Human approval loops**: Queues, async workers, and database-backed state management + +Workflow DevKit provides 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 + +Start with a Next.js application using the AI SDK's `Agent` class: + +```typescript title="app/api/chat/route.ts" lineNumbers +import { createUIMessageStreamResponse, Agent, stepCountIs } from 'ai'; +import { tools } from '@/ai/tools'; + +export async function POST(req: Request) { + const { messages, modelId } = await req.json(); + + const agent = new Agent({ + model: modelId, + system: 'You are a helpful assistant.', + tools: tools(), + stopWhen: stepCountIs(20), + }); + + const stream = agent.stream({ messages }); + + return createUIMessageStreamResponse({ + stream: stream.toUIMessageStream(), + }); +} +``` + +### Step 1: Install Dependencies + +Add the Workflow DevKit packages to your project: + +```bash +npm install workflow @workflow/ai +``` + +### Step 2: Create a Workflow Function + +Move the agent logic into a separate workflow function: + +```typescript title="app/api/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; // [!code highlight] +import { getWritable } from 'workflow'; // [!code highlight] +import { stepCountIs } from 'ai'; +import { tools } from '@/ai/tools'; +import type { ModelMessage, UIMessageChunk } from 'ai'; + +export async function chatWorkflow({ + messages, + modelId, +}: { + messages: ModelMessage[]; + modelId: string; +}) { + 'use workflow'; // [!code highlight] + + const writable = getWritable(); // [!code highlight] + + const agent = new DurableAgent({ // [!code highlight] + model: modelId, + system: 'You are a helpful assistant.', + tools: tools(), + }); + + await agent.stream({ // [!code highlight] + messages, + writable, + stopWhen: stepCountIs(20), + }); +} +``` + +Key changes: + +- Replace `Agent` with [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) from `@workflow/ai/agent` +- Add the `"use workflow"` directive to mark this as a workflow function +- Use [`getWritable()`](/docs/api-reference/workflow/get-writable) to get a stream for agent output +- Pass the `writable` to `agent.stream()` instead of returning a stream directly + +### Step 3: Update the API Route + +Replace the agent call with [`start()`](/docs/api-reference/workflow-api/start) to run the workflow: + +```typescript title="app/api/chat/route.ts" lineNumbers +import { createUIMessageStreamResponse, convertToModelMessages } from 'ai'; +import { start } from 'workflow/api'; // [!code highlight] +import { chatWorkflow } from './workflow'; // [!code highlight] + +export async function POST(req: Request) { + const { messages, modelId } = await req.json(); + const modelMessages = convertToModelMessages(messages); + + const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); // [!code highlight] + + return createUIMessageStreamResponse({ + stream: run.readable, // [!code highlight] + }); +} +``` + +### Step 4: Convert Tools to Steps + +Mark tool execution functions with `"use step"` to make them durable. This enables automatic retries and observability: + +```typescript title="ai/tools/search-web.ts" lineNumbers +import { tool } from 'ai'; +import { z } from 'zod'; + +async function executeSearch({ query }: { query: string }) { + 'use step'; // [!code highlight] + + const response = await fetch(`https://api.search.com?q=${query}`); + return response.json(); +} + +export const searchWeb = tool({ + description: 'Search the web for information', + inputSchema: z.object({ query: z.string() }), + execute: executeSearch, +}); +``` + +With `"use step"`: + +- The tool execution runs in a separate step with full Node.js access +- Failed tool calls are automatically retried (up to 3 times by default) +- Each tool execution appears as a discrete step in observability tools +- Results are persisted, so replays skip already-completed tools + +### Step 5: Stream Progress Updates from Tools + +Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status: + +```typescript title="ai/tools/run-command.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable } from 'workflow'; // [!code highlight] +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +async function executeRunCommand( + { command }: { command: string }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); // [!code highlight] + const writer = writable.getWriter(); // [!code highlight] + + // Emit a progress update // [!code highlight] + await writer.write({ // [!code highlight] + id: toolCallId, // [!code highlight] + type: 'data-run-command', // [!code highlight] + data: { command, status: 'executing' }, // [!code highlight] + }); // [!code highlight] + + const result = await runCommand(command); + + await writer.write({ // [!code highlight] + id: toolCallId, // [!code highlight] + type: 'data-run-command', // [!code highlight] + data: { command, status: 'done', result }, // [!code highlight] + }); // [!code highlight] + + writer.releaseLock(); // [!code highlight] + + return result; +} + +export const runCommand = tool({ + description: 'Run a shell command', + inputSchema: z.object({ command: z.string() }), + execute: executeRunCommand, +}); +``` + +The UI can handle these data chunks to display real-time progress. + +## Running the Workflow + +Run your development server, then open the observability dashboard to see your workflow in action: + +```bash +npx workflow web +``` + +This opens a local dashboard showing: + +- All workflow runs and their status +- Individual step executions with timing +- Errors and retry attempts +- Stream data flowing through the workflow + +## Next Steps + +Now that you have a basic durable agent, explore these additional capabilities: + + + + Enable clients to reconnect to interrupted streams without losing data. + + + Add native sleep functionality for time-based workflows. + + + Implement approval workflows that wait for human input. + + + +## Related Documentation + +- [`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-agents/meta.json b/docs/content/docs/ai-agents/meta.json new file mode 100644 index 000000000..3595e6aeb --- /dev/null +++ b/docs/content/docs/ai-agents/meta.json @@ -0,0 +1,10 @@ +{ + "title": "AI Agents", + "pages": [ + "index", + "resumable-streams", + "sleep-and-delays", + "human-in-the-loop" + ], + "defaultOpen": true +} diff --git a/docs/content/docs/ai-agents/resumable-streams.mdx b/docs/content/docs/ai-agents/resumable-streams.mdx new file mode 100644 index 000000000..1c79502ab --- /dev/null +++ b/docs/content/docs/ai-agents/resumable-streams.mdx @@ -0,0 +1,210 @@ +--- +title: Resumable Streams +--- + + +The `@workflow/ai` package is currently in active development and should be considered experimental. + + +When building chat interfaces, network interruptions or page refreshes can break the connection to an in-progress agent. Workflow DevKit provides [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport), a drop-in transport for the AI SDK that enables automatic stream reconnection. + +## The Problem + +A typical chat application loses state when: +- The user refreshes the page during a response +- Network connectivity drops temporarily +- Serverless function timeouts occur mid-stream + +With a standard chat implementation, users must resend their message and wait for the entire response again. + +## The Solution + +`WorkflowChatTransport` solves this by: +1. Tracking the workflow run ID returned from your API +2. Automatically reconnecting to the stream from the last received chunk +3. Storing the run ID for session resumption across page loads + +## Implementation + +### Step 1: Return the Run ID from Your API + +Modify your chat endpoint to include the workflow run ID in a response header: + +```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, modelId } = await req.json(); + const modelMessages = convertToModelMessages(messages); + + const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); + + return createUIMessageStreamResponse({ + stream: run.readable, + headers: { + 'x-workflow-run-id': run.runId, // [!code highlight] + }, + }); +} +``` + +### Step 2: Add a Stream Reconnection Endpoint + +Create an endpoint 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; + + const run = getRun(id); // [!code highlight] + const stream = run.getReadable({ startIndex }); // [!code highlight] + + return createUIMessageStreamResponse({ stream }); +} +``` + +The `startIndex` parameter ensures the client only receives chunks it missed, avoiding duplicate data. + +### Step 3: Use WorkflowChatTransport in the Client + +Replace the default transport in `useChat` with `WorkflowChatTransport`: + +```typescript title="app/chat.tsx" lineNumbers +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { WorkflowChatTransport } from '@workflow/ai'; // [!code highlight] +import { useMemo, useState } from 'react'; + +export function Chat() { + const [input, setInput] = useState(''); + + // 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, options) => { // [!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] + + maxConsecutiveErrors: 5, + }), // [!code highlight] + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: {m.content} +
+ ))} +
{ + e.preventDefault(); + sendMessage({ text: input }); + setInput(''); + }} + > + setInput(e.target.value)} + placeholder="Type a message..." + /> +
+
+ ); +} +``` + +## 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 +6. The reconnection endpoint returns the stream from where the client left off +7. When the stream completes, `onChatEnd` clears the stored run ID + +## Handling Page Refreshes + +The `resume` option tells `useChat` to attempt reconnection on mount. Combined with localStorage persistence, this enables seamless recovery: + +```typescript lineNumbers +const activeRunId = useMemo(() => { + if (typeof window === 'undefined') return; + return localStorage.getItem('active-workflow-run-id') ?? undefined; +}, []); + +const { messages, sendMessage } = useChat({ + resume: Boolean(activeRunId), // Attempt to resume if a run ID exists // [!code highlight] + transport: new WorkflowChatTransport({ /* ... */ }), +}); +``` + +When the user refreshes the page: +1. The component checks localStorage for an active run ID +2. If found, `resume: true` triggers a reconnection attempt +3. The transport fetches the stream from the last known position +4. Messages continue appearing where they left off + +## Error Handling + +Configure retry behavior with `maxConsecutiveErrors`: + +```typescript lineNumbers +new WorkflowChatTransport({ + maxConsecutiveErrors: 5, // Give up after 5 consecutive failures // [!code highlight] + // ... +}) +``` + +The transport automatically retries reconnection on network errors. After reaching the limit, it stops attempting and surfaces the error. + +## 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-agents/sleep-and-delays.mdx b/docs/content/docs/ai-agents/sleep-and-delays.mdx new file mode 100644 index 000000000..ec286a912 --- /dev/null +++ b/docs/content/docs/ai-agents/sleep-and-delays.mdx @@ -0,0 +1,219 @@ +--- +title: Sleep and Delays +--- + +AI agents sometimes need to pause execution - waiting before retrying an operation, implementing rate limiting, or scheduling future actions. Workflow DevKit's [`sleep()`](/docs/api-reference/workflow/sleep) function enables time-based delays without consuming compute resources. + +## Adding a Sleep Tool + +Create a tool that allows the agent to pause for a specified duration: + +```typescript title="ai/tools/sleep.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable, sleep } from 'workflow'; // [!code highlight] +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +const inputSchema = z.object({ + durationMs: z.number().describe('Duration to sleep in milliseconds'), +}); + +async function reportSleep( + { durationMs }: { durationMs: number }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + const seconds = Math.ceil(durationMs / 1000); + + await writer.write({ + id: toolCallId, + type: 'data-wait', + data: { text: `Sleeping for ${seconds} seconds` }, + }); + + writer.releaseLock(); +} + +async function executeSleep( + { durationMs }: z.infer, + { toolCallId }: { toolCallId: string } +) { + // Note: No "use step" here - sleep is a workflow-level function // [!code highlight] + + await reportSleep({ durationMs }, { toolCallId }); + await sleep(durationMs); // [!code highlight] + + return `Slept for ${durationMs}ms`; +} + +export const sleepTool = tool({ + description: 'Pause execution for a specified duration', + inputSchema, + execute: executeSleep, +}); +``` + + +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. + + +## How Sleep Works + +When `sleep()` is called: + +1. The workflow records the wake-up time in the event log +2. The workflow suspends, releasing all compute resources +3. At the specified time, the workflow resumes execution + +This differs from `setTimeout` or `await new Promise(resolve => setTimeout(resolve, ms))`: +- No compute resources are consumed during the sleep +- The workflow survives restarts, deploys, and infrastructure changes +- Sleep durations can span hours, days, or months + +## Duration Formats + +The `sleep()` function accepts multiple duration formats: + +```typescript lineNumbers +// Milliseconds (number) +await sleep(5000); + +// Duration strings +await sleep('30s'); // 30 seconds +await sleep('5m'); // 5 minutes +await sleep('2h'); // 2 hours +await sleep('1d'); // 1 day +await sleep('1 month'); // 1 month + +// Date instance +await sleep(new Date('2025-12-31T23:59:59Z')); +``` + +## Emitting Progress Updates + +When sleeping for long durations, emit a progress update so the UI can display the waiting state: + +```typescript title="ai/tools/schedule-task.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable, sleep } from 'workflow'; +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +async function emitWaitingStatus(message: string, toolCallId: string) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + await writer.write({ + id: toolCallId, + type: 'data-wait', + data: { text: message }, + }); + + writer.releaseLock(); +} + +async function executeScheduleTask( + { delayMinutes, taskName }: { delayMinutes: number; taskName: string }, + { toolCallId }: { toolCallId: string } +) { + await emitWaitingStatus( + `Scheduled "${taskName}" to run in ${delayMinutes} minutes`, + toolCallId + ); + + await sleep(`${delayMinutes}m`); // [!code highlight] + + return `Task "${taskName}" is now ready to execute`; +} + +export const scheduleTask = tool({ + description: 'Schedule a task to run after a delay', + inputSchema: z.object({ + delayMinutes: z.number(), + taskName: z.string(), + }), + execute: executeScheduleTask, +}); +``` + +## Use Cases + +### Rate Limiting + +When hitting API rate limits, sleep before retrying: + +```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(); +} +``` + +### Scheduled Notifications + +Send a reminder after a delay: + +```typescript lineNumbers +async function executeReminder( + { message, delayHours }: { message: string; delayHours: number } +) { + await sleep(`${delayHours}h`); + await sendNotification(message); + + return `Reminder sent: ${message}`; +} +``` + +### 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 +- [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/meta.json b/docs/content/docs/meta.json index b527dc11f..74cc963af 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -4,6 +4,7 @@ "---", "getting-started", "foundations", + "ai-agents", "how-it-works", "observability", "deploying", From 0a65357bac50b2da1104a61b48c4978402583df1 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 13:44:53 -0800 Subject: [PATCH 02/38] Fix scrolling of left sidebar --- docs/components/geistdocs/docs-layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From eb49e18ad39cae523ccf0bdcf877e839e379ad59 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 13:52:45 -0800 Subject: [PATCH 03/38] Polish --- docs/content/docs/ai-agents/index.mdx | 13 ++++--------- docs/content/docs/ai-agents/resumable-streams.mdx | 8 +++----- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/docs/content/docs/ai-agents/index.mdx b/docs/content/docs/ai-agents/index.mdx index 7f9e653c2..289aa5311 100644 --- a/docs/content/docs/ai-agents/index.mdx +++ b/docs/content/docs/ai-agents/index.mdx @@ -2,10 +2,6 @@ title: Building Durable AI Agents --- - -`@workflow/ai` is in public beta and should be considered experimental. - - 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 allows you to designate these loops as stateful, resumable workflows, with LLM calls, tool calls, and other async operations as individual, retryable, observable steps. This guide walks through converting a basic AI SDK agent into a durable agent using Workflow DevKit. @@ -14,12 +10,11 @@ This guide walks through converting a basic AI SDK agent into a durable agent us Building production-ready AI agents typically requires solving several challenges: -- **Durability**: Queue management, tracking sub-jobs, and handling task execution across failures +- **Durability**: Splitting calls into async jobs, managing associated workers, queuing, and data persistence - **Reliability**: Error handling, retries, and recovery from partial failures -- **Observability**: Storing messages, emitting traces, and debugging agent behavior -- **Resumability**: Persisting state to a database, managing stream providers for reconnection -- **Long-running operations**: Sleep, delays, and month-long workflows without idle costs -- **Human approval loops**: Queues, async workers, and database-backed state management +- **Observability**: Storing messages and emitting traces, often using external services for observability +- **Resumability**: Repeatedly persisting and loading state from a database, storing streams in a separate service, and managing stream reconnects +- **Long-running operations and human approval loops**: Wiring up queues, async workers, and connecting state to your existing API and interface Workflow DevKit provides these capabilities out of the box. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure. diff --git a/docs/content/docs/ai-agents/resumable-streams.mdx b/docs/content/docs/ai-agents/resumable-streams.mdx index 1c79502ab..5c3c6e97c 100644 --- a/docs/content/docs/ai-agents/resumable-streams.mdx +++ b/docs/content/docs/ai-agents/resumable-streams.mdx @@ -2,15 +2,12 @@ title: Resumable Streams --- - -The `@workflow/ai` package is currently in active development and should be considered experimental. - - When building chat interfaces, network interruptions or page refreshes can break the connection to an in-progress agent. Workflow DevKit provides [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport), a drop-in transport for the AI SDK that enables automatic stream reconnection. ## The Problem A typical chat application loses state when: + - The user refreshes the page during a response - Network connectivity drops temporarily - Serverless function timeouts occur mid-stream @@ -20,6 +17,7 @@ With a standard chat implementation, users must resend their message and wait fo ## The Solution `WorkflowChatTransport` solves this by: + 1. Tracking the workflow run ID returned from your API 2. Automatically reconnecting to the stream from the last received chunk 3. Storing the run ID for session resumption across page loads @@ -184,6 +182,7 @@ const { messages, sendMessage } = useChat({ ``` When the user refreshes the page: + 1. The component checks localStorage for an active run ID 2. If found, `resume: true` triggers a reconnection attempt 3. The transport fetches the stream from the last known position @@ -207,4 +206,3 @@ The transport automatically retries reconnection on network errors. After reachi - [`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 - From e83bbf3aded04f28c45950c3f73633d04c5c9ea2 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 13:54:55 -0800 Subject: [PATCH 04/38] Empty changeset --- .changeset/three-hands-open.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/three-hands-open.md 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 @@ +--- +--- From 8e8acab994dd6c4e3a70939427f9ce2535db2f7c Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:06:19 -0800 Subject: [PATCH 05/38] Polish --- docs/content/docs/ai-agents/index.mdx | 15 +++++++-------- docs/content/docs/ai-agents/meta.json | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/ai-agents/index.mdx b/docs/content/docs/ai-agents/index.mdx index 289aa5311..a5aacfd2e 100644 --- a/docs/content/docs/ai-agents/index.mdx +++ b/docs/content/docs/ai-agents/index.mdx @@ -2,21 +2,20 @@ 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 allows you to designate these loops as stateful, resumable workflows, with LLM calls, tool calls, and other async operations as individual, retryable, observable steps. +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 your agents into durable, resumable workflows, and managing your LLM calls, tool executions, and other async operations as retryable and observable steps. This guide walks through converting a basic AI SDK agent into a durable agent using Workflow DevKit. ## Why Durable Agents? -Building production-ready AI agents typically requires solving several challenges: +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**: -- **Durability**: Splitting calls into async jobs, managing associated workers, queuing, and data persistence -- **Reliability**: Error handling, retries, and recovery from partial failures -- **Observability**: Storing messages and emitting traces, often using external services for observability -- **Resumability**: Repeatedly persisting and loading state from a database, storing streams in a separate service, and managing stream reconnects -- **Long-running operations and human approval loops**: Wiring up queues, async workers, and connecting state to your existing API and interface +- **Durability**: Persisting chat sessions and turning all LLM and tool calls into separate async jobs, with workers, queues, and state management, which repeatedly save and re-load state from a database. +- **Observability**: Using services to collect traces and metrics, separately storing your messages and user history, and then combining them to get a complete picture of your agent's behavior. +- **Resumability**: Separately from storing messages for replayability, most LLM calls are streams, and recovering from a call failure without re-doing the entire call requires piping and storing streams separately from your messages, usually in a separate service. +- **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. If your stack is disjointed, this simple feature becomes a major orchestration challenge. -Workflow DevKit provides these capabilities out of the box. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure. +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 diff --git a/docs/content/docs/ai-agents/meta.json b/docs/content/docs/ai-agents/meta.json index 3595e6aeb..872b5d978 100644 --- a/docs/content/docs/ai-agents/meta.json +++ b/docs/content/docs/ai-agents/meta.json @@ -6,5 +6,5 @@ "sleep-and-delays", "human-in-the-loop" ], - "defaultOpen": true + "defaultOpen": false } From 51e7aaca374b9fa410dbf5bb3983f7fe059787b9 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:20:27 -0800 Subject: [PATCH 06/38] Move to new section --- docs/app/guides/[[...slug]]/page.tsx | 123 ++++++++++ docs/app/guides/layout.tsx | 43 ++++ docs/app/guides/page.tsx | 5 + docs/app/layout.tsx | 4 + docs/content/docs/ai-agents/index.mdx | 28 +-- docs/content/docs/meta.json | 1 - .../ai-agents/human-in-the-loop.mdx | 0 docs/content/guides/ai-agents/index.mdx | 231 ++++++++++++++++++ .../{docs => guides}/ai-agents/meta.json | 2 +- .../ai-agents/resumable-streams.mdx | 0 .../ai-agents/sleep-and-delays.mdx | 0 docs/content/guides/meta.json | 3 + docs/lib/geistdocs/source.ts | 18 +- docs/source.config.ts | 13 + 14 files changed, 453 insertions(+), 18 deletions(-) create mode 100644 docs/app/guides/[[...slug]]/page.tsx create mode 100644 docs/app/guides/layout.tsx create mode 100644 docs/app/guides/page.tsx rename docs/content/{docs => guides}/ai-agents/human-in-the-loop.mdx (100%) create mode 100644 docs/content/guides/ai-agents/index.mdx rename docs/content/{docs => guides}/ai-agents/meta.json (85%) rename docs/content/{docs => guides}/ai-agents/resumable-streams.mdx (100%) rename docs/content/{docs => guides}/ai-agents/sleep-and-delays.mdx (100%) create mode 100644 docs/content/guides/meta.json diff --git a/docs/app/guides/[[...slug]]/page.tsx b/docs/app/guides/[[...slug]]/page.tsx new file mode 100644 index 000000000..b8db99716 --- /dev/null +++ b/docs/app/guides/[[...slug]]/page.tsx @@ -0,0 +1,123 @@ +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { notFound } from 'next/navigation'; +import { AskAI } from '@/components/geistdocs/ask-ai'; +import { CopyPage } from '@/components/geistdocs/copy-page'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from '@/components/geistdocs/docs-page'; +import { EditSource } from '@/components/geistdocs/edit-source'; +import { Feedback } from '@/components/geistdocs/feedback'; +import { getMDXComponents } from '@/components/geistdocs/mdx-components'; +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 type { Metadata } from 'next'; + +const Page = async (props: PageProps<'/guides/[[...slug]]'>) => { + const params = await props.params; + + const page = guidesSource.getPage(params.slug); + + if (!page) { + notFound(); + } + + const markdown = await getGuidesLLMText(page); + const MDX = page.data.body; + + return ( + + + + + + + + + ), + }} + toc={page.data.toc} + > + {page.data.title} + {page.data.description} + + + + + ); +}; + +export const generateStaticParams = () => + guidesSource.generateParams().map((params) => ({ + slug: params.slug, + })); + +export const generateMetadata = async ( + props: PageProps<'/guides/[[...slug]]'> +): Promise => { + const params = await props.params; + 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/guides/page.tsx b/docs/app/guides/page.tsx new file mode 100644 index 000000000..1822f3150 --- /dev/null +++ b/docs/app/guides/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function GuidesPage() { + redirect('/guides/ai-agents'); +} 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/content/docs/ai-agents/index.mdx b/docs/content/docs/ai-agents/index.mdx index a5aacfd2e..2f8fcf187 100644 --- a/docs/content/docs/ai-agents/index.mdx +++ b/docs/content/docs/ai-agents/index.mdx @@ -32,7 +32,6 @@ export async function POST(req: Request) { model: modelId, system: 'You are a helpful assistant.', tools: tools(), - stopWhen: stepCountIs(20), }); const stream = agent.stream({ messages }); @@ -62,7 +61,7 @@ import { stepCountIs } from 'ai'; import { tools } from '@/ai/tools'; import type { ModelMessage, UIMessageChunk } from 'ai'; -export async function chatWorkflow({ +export async function chatWorkflow({ // [!code highlight] messages, modelId, }: { @@ -79,10 +78,9 @@ export async function chatWorkflow({ tools: tools(), }); - await agent.stream({ // [!code highlight] + await agent.stream({ messages, writable, - stopWhen: stepCountIs(20), }); } ``` @@ -107,6 +105,7 @@ export async function POST(req: Request) { const { messages, modelId } = await req.json(); const modelMessages = convertToModelMessages(messages); + // This will start the workflow in the background const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); // [!code highlight] return createUIMessageStreamResponse({ @@ -144,9 +143,11 @@ With `"use step"`: - Each tool execution appears as a discrete step in observability tools - Results are persisted, so replays skip already-completed tools +Note that any side-effects or network operations need to be run inside step functions, not in your workflow code. + ### Step 5: Stream Progress Updates from Tools -Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status: +Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status and live updates from steps, without having to manually wire up streams: ```typescript title="ai/tools/run-command.ts" lineNumbers import { tool } from 'ai'; @@ -194,32 +195,29 @@ The UI can handle these data chunks to display real-time progress. ## Running the Workflow -Run your development server, then open the observability dashboard to see your workflow in action: +Run your development server, kick off a chat session, and then open the observability dashboard to see your workflow running live: ```bash npx workflow web ``` -This opens a local dashboard showing: - -- All workflow runs and their status -- Individual step executions with timing -- Errors and retry attempts -- Stream data flowing through the workflow +This opens a local dashboard showing all your workflow runs, with trace-viewers showing detailed views of each run, errors, retries, timing, and more. +The UI also allows you to cancel, re-run, and start new runs. ## Next Steps -Now that you have a basic durable agent, explore these additional capabilities: +Now that you have a basic durable agent, it's a short step to adding additional capabilities like resumable streams, sleep and delays, and human-in-the-loop. Enable clients to reconnect to interrupted streams without losing data. - Add native sleep functionality for time-based workflows. + Add native sleep functionality to allow your workflows to pause and resume, enable work on schedules, and more. - Implement approval workflows that wait for human input. + Implement approval workflows that wait for human input or other external events, + like PR updates, webhooks, and more. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 74cc963af..b527dc11f 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -4,7 +4,6 @@ "---", "getting-started", "foundations", - "ai-agents", "how-it-works", "observability", "deploying", diff --git a/docs/content/docs/ai-agents/human-in-the-loop.mdx b/docs/content/guides/ai-agents/human-in-the-loop.mdx similarity index 100% rename from docs/content/docs/ai-agents/human-in-the-loop.mdx rename to docs/content/guides/ai-agents/human-in-the-loop.mdx diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx new file mode 100644 index 000000000..d3d8b7b87 --- /dev/null +++ b/docs/content/guides/ai-agents/index.mdx @@ -0,0 +1,231 @@ +--- +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 your agents into durable, resumable workflows, and managing your LLM calls, tool executions, and other async operations as retryable and observable steps. + +This guide walks through converting a basic AI SDK agent into a durable 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**: + +- **Durability**: Persisting chat sessions and turning all LLM and tool calls into separate async jobs, with workers, queues, and state management, which repeatedly save and re-load state from a database. +- **Observability**: Using services to collect traces and metrics, separately storing your messages and user history, and then combining them to get a complete picture of your agent's behavior. +- **Resumability**: Separately from storing messages for replayability, most LLM calls are streams, and recovering from a call failure without re-doing the entire call requires piping and storing streams separately from your messages, usually in a separate service. +- **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. If your stack is disjointed, this simple feature becomes a major orchestration challenge. + +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 + +Start with a Next.js application using the AI SDK's `Agent` class: + +```typescript title="app/api/chat/route.ts" lineNumbers +import { createUIMessageStreamResponse, Agent, stepCountIs } from 'ai'; +import { tools } from '@/ai/tools'; + +export async function POST(req: Request) { + const { messages, modelId } = await req.json(); + + const agent = new Agent({ + model: modelId, + system: 'You are a helpful assistant.', + tools: tools(), + stopWhen: stepCountIs(20), + }); + + const stream = agent.stream({ messages }); + + return createUIMessageStreamResponse({ + stream: stream.toUIMessageStream(), + }); +} +``` + +### Step 1: Install Dependencies + +Add the Workflow DevKit packages to your project: + +```bash +npm install workflow @workflow/ai +``` + +### Step 2: Create a Workflow Function + +Move the agent logic into a separate workflow function: + +```typescript title="app/api/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; // [!code highlight] +import { getWritable } from 'workflow'; // [!code highlight] +import { stepCountIs } from 'ai'; +import { tools } from '@/ai/tools'; +import type { ModelMessage, UIMessageChunk } from 'ai'; + +export async function chatWorkflow({ + messages, + modelId, +}: { + messages: ModelMessage[]; + modelId: string; +}) { + 'use workflow'; // [!code highlight] + + const writable = getWritable(); // [!code highlight] + + const agent = new DurableAgent({ // [!code highlight] + model: modelId, + system: 'You are a helpful assistant.', + tools: tools(), + }); + + await agent.stream({ // [!code highlight] + messages, + writable, + stopWhen: stepCountIs(20), + }); +} +``` + +Key changes: + +- Replace `Agent` with [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) from `@workflow/ai/agent` +- Add the `"use workflow"` directive to mark this as a workflow function +- Use [`getWritable()`](/docs/api-reference/workflow/get-writable) to get a stream for agent output +- Pass the `writable` to `agent.stream()` instead of returning a stream directly + +### Step 3: Update the API Route + +Replace the agent call with [`start()`](/docs/api-reference/workflow-api/start) to run the workflow: + +```typescript title="app/api/chat/route.ts" lineNumbers +import { createUIMessageStreamResponse, convertToModelMessages } from 'ai'; +import { start } from 'workflow/api'; // [!code highlight] +import { chatWorkflow } from './workflow'; // [!code highlight] + +export async function POST(req: Request) { + const { messages, modelId } = await req.json(); + const modelMessages = convertToModelMessages(messages); + + const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); // [!code highlight] + + return createUIMessageStreamResponse({ + stream: run.readable, // [!code highlight] + }); +} +``` + +### Step 4: Convert Tools to Steps + +Mark tool execution functions with `"use step"` to make them durable. This enables automatic retries and observability: + +```typescript title="ai/tools/search-web.ts" lineNumbers +import { tool } from 'ai'; +import { z } from 'zod'; + +async function executeSearch({ query }: { query: string }) { + 'use step'; // [!code highlight] + + const response = await fetch(`https://api.search.com?q=${query}`); + return response.json(); +} + +export const searchWeb = tool({ + description: 'Search the web for information', + inputSchema: z.object({ query: z.string() }), + execute: executeSearch, +}); +``` + +With `"use step"`: + +- The tool execution runs in a separate step with full Node.js access +- Failed tool calls are automatically retried (up to 3 times by default) +- Each tool execution appears as a discrete step in observability tools +- Results are persisted, so replays skip already-completed tools + +### Step 5: Stream Progress Updates from Tools + +Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status: + +```typescript title="ai/tools/run-command.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable } from 'workflow'; // [!code highlight] +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +async function executeRunCommand( + { command }: { command: string }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); // [!code highlight] + const writer = writable.getWriter(); // [!code highlight] + + // Emit a progress update // [!code highlight] + await writer.write({ // [!code highlight] + id: toolCallId, // [!code highlight] + type: 'data-run-command', // [!code highlight] + data: { command, status: 'executing' }, // [!code highlight] + }); // [!code highlight] + + const result = await runCommand(command); + + await writer.write({ // [!code highlight] + id: toolCallId, // [!code highlight] + type: 'data-run-command', // [!code highlight] + data: { command, status: 'done', result }, // [!code highlight] + }); // [!code highlight] + + writer.releaseLock(); // [!code highlight] + + return result; +} + +export const runCommand = tool({ + description: 'Run a shell command', + inputSchema: z.object({ command: z.string() }), + execute: executeRunCommand, +}); +``` + +The UI can handle these data chunks to display real-time progress. + +## Running the Workflow + +Run your development server, then open the observability dashboard to see your workflow in action: + +```bash +npx workflow web +``` + +This opens a local dashboard showing: + +- All workflow runs and their status +- Individual step executions with timing +- Errors and retry attempts +- Stream data flowing through the workflow + +## Next Steps + +Now that you have a basic durable agent, explore these additional capabilities: + + + + Enable clients to reconnect to interrupted streams without losing data. + + + Add native sleep functionality for time-based workflows. + + + Implement approval workflows that wait for human input. + + + +## Related Documentation + +- [`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-agents/meta.json b/docs/content/guides/ai-agents/meta.json similarity index 85% rename from docs/content/docs/ai-agents/meta.json rename to docs/content/guides/ai-agents/meta.json index 872b5d978..3595e6aeb 100644 --- a/docs/content/docs/ai-agents/meta.json +++ b/docs/content/guides/ai-agents/meta.json @@ -6,5 +6,5 @@ "sleep-and-delays", "human-in-the-loop" ], - "defaultOpen": false + "defaultOpen": true } diff --git a/docs/content/docs/ai-agents/resumable-streams.mdx b/docs/content/guides/ai-agents/resumable-streams.mdx similarity index 100% rename from docs/content/docs/ai-agents/resumable-streams.mdx rename to docs/content/guides/ai-agents/resumable-streams.mdx diff --git a/docs/content/docs/ai-agents/sleep-and-delays.mdx b/docs/content/guides/ai-agents/sleep-and-delays.mdx similarity index 100% rename from docs/content/docs/ai-agents/sleep-and-delays.mdx rename to docs/content/guides/ai-agents/sleep-and-delays.mdx 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/lib/geistdocs/source.ts b/docs/lib/geistdocs/source.ts index b7c5bd58a..8bd3ead59 100644 --- a/docs/lib/geistdocs/source.ts +++ b/docs/lib/geistdocs/source.ts @@ -1,6 +1,6 @@ import { type InferPageType, loader } from 'fumadocs-core/source'; import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons'; -import { docs } from '@/.source'; +import { docs, guides } from '@/.source'; // See https://fumadocs.dev/docs/headless/source-api for more info export const source = loader({ @@ -9,6 +9,12 @@ export const source = loader({ plugins: [lucideIconsPlugin()], }); +export const guidesSource = loader({ + baseUrl: '/guides', + source: guides.toFumadocsSource(), + plugins: [lucideIconsPlugin()], +}); + export const getPageImage = (page: InferPageType) => { const segments = [...page.slugs, 'image.png']; @@ -25,3 +31,13 @@ export const getLLMText = async (page: InferPageType) => { ${processed}`; }; + +export const getGuidesLLMText = async ( + page: InferPageType +) => { + const processed = await page.data.getText('processed'); + + return `# ${page.data.title} + +${processed}`; +}; diff --git a/docs/source.config.ts b/docs/source.config.ts index ae3996d7a..e20b5c9d7 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -21,6 +21,19 @@ export const docs = defineDocs({ }, }); +export const guides = defineDocs({ + dir: 'content/guides', + docs: { + schema: frontmatterSchema, + postprocess: { + includeProcessedMarkdown: true, + }, + }, + meta: { + schema: metaSchema, + }, +}); + export default defineConfig({ mdxOptions: { remarkPlugins: [remarkMdxMermaid], From 108ac78a5dc50a13d6b5c956d62172d647d59461 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:27:03 -0800 Subject: [PATCH 07/38] Polish --- docs/content/guides/ai-agents/index.mdx | 49 +++++++++++++++++++++---- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index d3d8b7b87..db547de21 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -19,10 +19,11 @@ Workflow DevKit provides all of these capabilities out of the box. Your agent be ## Getting Started -Start with a Next.js application using the AI SDK's `Agent` class: +We start out with a basic Next.js application using the AI SDK's `Agent` class, +which is a simple wrapper around AI SDK's `streamText` function. It comes with a basic tool for reading web pages. ```typescript title="app/api/chat/route.ts" lineNumbers -import { createUIMessageStreamResponse, Agent, stepCountIs } from 'ai'; +import { createUIMessageStreamResponse, Experimental_Agent as Agent, stepCountIs } from 'ai'; import { tools } from '@/ai/tools'; export async function POST(req: Request) { @@ -31,7 +32,7 @@ export async function POST(req: Request) { const agent = new Agent({ model: modelId, system: 'You are a helpful assistant.', - tools: tools(), + tools, stopWhen: stepCountIs(20), }); @@ -43,7 +44,26 @@ export async function POST(req: Request) { } ``` -### Step 1: Install Dependencies +```typescript title="ai/tools" lineNumbers +import { tool } from 'ai'; +import { z } from 'zod'; + +export const tools = { + searchWeb: tool({ + description: 'Read a web page and return the content', + inputSchema: z.object({ url: z.string() }), + execute: async ({ url }: { url: string }) => { + const response = await fetch(url); + return response.text(); + }, + }), +}; +``` + + + + +### Install Dependencies Add the Workflow DevKit packages to your project: @@ -51,7 +71,10 @@ Add the Workflow DevKit packages to your project: npm install workflow @workflow/ai ``` -### Step 2: Create a Workflow Function + + + +### Create a Workflow Function Move the agent logic into a separate workflow function: @@ -93,8 +116,10 @@ Key changes: - Add the `"use workflow"` directive to mark this as a workflow function - Use [`getWritable()`](/docs/api-reference/workflow/get-writable) to get a stream for agent output - Pass the `writable` to `agent.stream()` instead of returning a stream directly + -### Step 3: Update the API Route + +### Update the API Route Replace the agent call with [`start()`](/docs/api-reference/workflow-api/start) to run the workflow: @@ -115,7 +140,10 @@ export async function POST(req: Request) { } ``` -### Step 4: Convert Tools to Steps + + + +### Convert Tools to Steps Mark tool execution functions with `"use step"` to make them durable. This enables automatic retries and observability: @@ -143,8 +171,10 @@ With `"use step"`: - Failed tool calls are automatically retried (up to 3 times by default) - Each tool execution appears as a discrete step in observability tools - Results are persisted, so replays skip already-completed tools + -### Step 5: Stream Progress Updates from Tools + +### Stream Progress Updates from Tools Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status: @@ -191,6 +221,9 @@ export const runCommand = tool({ ``` The UI can handle these data chunks to display real-time progress. + + + ## Running the Workflow From b04412d6c8a7283cb0a2fd49dc99020f422b3800 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:44:49 -0800 Subject: [PATCH 08/38] Show more code snippets and full state --- docs/content/guides/ai-agents/index.mdx | 332 ++++++++++++++++++++++-- 1 file changed, 315 insertions(+), 17 deletions(-) diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index db547de21..d9c1bb7a4 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -22,6 +22,10 @@ Workflow DevKit provides all of these capabilities out of the box. Your agent be We start out with a basic Next.js application using the AI SDK's `Agent` class, which is a simple wrapper around AI SDK's `streamText` function. It comes with a basic tool for reading web pages. + + + + ```typescript title="app/api/chat/route.ts" lineNumbers import { createUIMessageStreamResponse, Experimental_Agent as Agent, stepCountIs } from 'ai'; import { tools } from '@/ai/tools'; @@ -44,7 +48,11 @@ export async function POST(req: Request) { } ``` -```typescript title="ai/tools" lineNumbers + + + + +```typescript title="ai/tools/index.ts" lineNumbers import { tool } from 'ai'; import { z } from 'zod'; @@ -60,6 +68,52 @@ export const tools = { }; ``` + + + + +```typescript title="app/chat.tsx" lineNumbers +'use client'; + +import { useChat } from '@ai-sdk/react'; + +export function Chat() { + const { messages, input, handleInputChange, handleSubmit } = useChat({ + api: '/api/chat', + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: + {m.parts.map((part, i) => { + if (part.type === 'text') { + return {part.text}; + } + if (part.type === 'tool-invocation') { + return
Calling {part.toolInvocation.toolName}...
; + } + return null; + })} +
+ ))} +
+ +
+
+ ); +} +``` + +
+ +
+ @@ -176,16 +230,20 @@ With `"use step"`: ### Stream Progress Updates from Tools -Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status: +Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status. + + -```typescript title="ai/tools/run-command.ts" lineNumbers + + +```typescript title="ai/tools/search-web.ts" lineNumbers import { tool } from 'ai'; -import { getWritable } from 'workflow'; // [!code highlight] +import { getWritable } from 'workflow'; import { z } from 'zod'; import type { UIMessageChunk } from 'ai'; -async function executeRunCommand( - { command }: { command: string }, +async function executeSearch( + { url }: { url: string }, { toolCallId }: { toolCallId: string } ) { 'use step'; @@ -196,31 +254,97 @@ async function executeRunCommand( // Emit a progress update // [!code highlight] await writer.write({ // [!code highlight] id: toolCallId, // [!code highlight] - type: 'data-run-command', // [!code highlight] - data: { command, status: 'executing' }, // [!code highlight] + type: 'data-search-web', // [!code highlight] + data: { url, status: 'fetching' }, // [!code highlight] }); // [!code highlight] - const result = await runCommand(command); + const response = await fetch(url); + const content = await response.text(); await writer.write({ // [!code highlight] id: toolCallId, // [!code highlight] - type: 'data-run-command', // [!code highlight] - data: { command, status: 'done', result }, // [!code highlight] + type: 'data-search-web', // [!code highlight] + data: { url, status: 'done' }, // [!code highlight] }); // [!code highlight] writer.releaseLock(); // [!code highlight] - return result; + return content; } -export const runCommand = tool({ - description: 'Run a shell command', - inputSchema: z.object({ command: z.string() }), - execute: executeRunCommand, +export const searchWeb = tool({ + description: 'Read a web page and return the content', + inputSchema: z.object({ url: z.string() }), + execute: executeSearch, }); ``` -The UI can handle these data chunks to display real-time progress. + + + + +Handle the `data-search-web` chunks in your client to display progress: + +```typescript title="app/chat.tsx" lineNumbers +'use client'; + +import { useChat } from '@ai-sdk/react'; +import type { DataUIPart } from 'ai'; + +type DataPart = + | { type: 'data-search-web'; data: { url: string; status: string } }; + +export function Chat() { + const [toolStatus, setToolStatus] = useState>({}); + + const { messages, input, handleInputChange, handleSubmit } = useChat({ + api: '/api/chat', + onData: (part: DataUIPart) => { // [!code highlight] + if (part.type === 'data-search-web') { // [!code highlight] + setToolStatus((prev) => ({ ...prev, [part.id]: part.data })); // [!code highlight] + } // [!code highlight] + }, // [!code highlight] + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: + {m.parts.map((part, i) => { + if (part.type === 'text') { + return {part.text}; + } + if (part.type === 'tool-invocation') { + const status = toolStatus[part.toolInvocation.toolCallId]; // [!code highlight] + return ( +
+ {status?.status === 'fetching' // [!code highlight] + ? `Fetching ${status.url}...` // [!code highlight] + : `Called ${part.toolInvocation.toolName}`} // [!code highlight] +
+ ); + } + return null; + })} +
+ ))} +
+ +
+
+ ); +} +``` + +
+ +
+
@@ -256,6 +380,180 @@ Now that you have a basic durable agent, explore these additional capabilities: +## Complete Example + +Here is the complete code for the durable agent after all the steps above: + + + + + +```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, modelId } = await req.json(); + const modelMessages = convertToModelMessages(messages); + + const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); + + return createUIMessageStreamResponse({ + stream: run.readable, + }); +} +``` + + + + + +```typescript title="app/api/chat/workflow.ts" lineNumbers +import { DurableAgent } from '@workflow/ai/agent'; +import { getWritable } from 'workflow'; +import { stepCountIs } from 'ai'; +import { tools } from '@/ai/tools'; +import type { ModelMessage, UIMessageChunk } from 'ai'; + +export async function chatWorkflow({ + messages, + modelId, +}: { + messages: ModelMessage[]; + modelId: string; +}) { + 'use workflow'; + + const writable = getWritable(); + + const agent = new DurableAgent({ + model: modelId, + system: 'You are a helpful assistant.', + tools, + }); + + await agent.stream({ + messages, + writable, + stopWhen: stepCountIs(20), + }); +} +``` + + + + + +```typescript title="ai/tools/index.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable } from 'workflow'; +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +async function executeSearch( + { url }: { url: string }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + await writer.write({ + id: toolCallId, + type: 'data-search-web', + data: { url, status: 'fetching' }, + }); + + const response = await fetch(url); + const content = await response.text(); + + await writer.write({ + id: toolCallId, + type: 'data-search-web', + data: { url, status: 'done' }, + }); + + writer.releaseLock(); + + return content; +} + +export const tools = { + searchWeb: tool({ + description: 'Read a web page and return the content', + inputSchema: z.object({ url: z.string() }), + execute: executeSearch, + }), +}; +``` + + + + + +```typescript title="app/chat.tsx" lineNumbers +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { useState } from 'react'; +import type { DataUIPart } from 'ai'; + +type DataPart = + | { type: 'data-search-web'; data: { url: string; status: string } }; + +export function Chat() { + const [toolStatus, setToolStatus] = useState>({}); + + const { messages, input, handleInputChange, handleSubmit } = useChat({ + api: '/api/chat', + onData: (part: DataUIPart) => { + if (part.type === 'data-search-web') { + setToolStatus((prev) => ({ ...prev, [part.id]: part.data })); + } + }, + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: + {m.parts.map((part, i) => { + if (part.type === 'text') { + return {part.text}; + } + if (part.type === 'tool-invocation') { + const status = toolStatus[part.toolInvocation.toolCallId]; + return ( +
+ {status?.status === 'fetching' + ? `Fetching ${status.url}...` + : `Called ${part.toolInvocation.toolName}`} +
+ ); + } + return null; + })} +
+ ))} +
+ +
+
+ ); +} +``` + +
+ +
+ ## Related Documentation - [`DurableAgent` API Reference](/docs/api-reference/workflow-ai/durable-agent) - Full API documentation From 4811a94edd941ab17279b3bcd761b91040665db3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:49:17 -0800 Subject: [PATCH 09/38] Fix --- docs/content/guides/ai-agents/index.mdx | 37 ++++++++----------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index d9c1bb7a4..0d90cc982 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -283,27 +283,16 @@ export const searchWeb = tool({ -Handle the `data-search-web` chunks in your client to display progress: +Handle the `data-search-web` chunks in your client to display progress. Data parts are stored in the message, so you can find the latest status directly: ```typescript title="app/chat.tsx" lineNumbers 'use client'; import { useChat } from '@ai-sdk/react'; -import type { DataUIPart } from 'ai'; - -type DataPart = - | { type: 'data-search-web'; data: { url: string; status: string } }; export function Chat() { - const [toolStatus, setToolStatus] = useState>({}); - const { messages, input, handleInputChange, handleSubmit } = useChat({ api: '/api/chat', - onData: (part: DataUIPart) => { // [!code highlight] - if (part.type === 'data-search-web') { // [!code highlight] - setToolStatus((prev) => ({ ...prev, [part.id]: part.data })); // [!code highlight] - } // [!code highlight] - }, // [!code highlight] }); return ( @@ -316,7 +305,11 @@ export function Chat() { return {part.text}; } if (part.type === 'tool-invocation') { - const status = toolStatus[part.toolInvocation.toolCallId]; // [!code highlight] + // Find the latest data part for this tool call // [!code highlight] + const dataPart = m.parts.findLast( // [!code highlight] + (p) => p.type === 'data' && p.id === part.toolInvocation.toolCallId // [!code highlight] + ); // [!code highlight] + const status = dataPart?.type === 'data' ? dataPart.data : null; // [!code highlight] return (
{status?.status === 'fetching' // [!code highlight] @@ -497,22 +490,10 @@ export const tools = { 'use client'; import { useChat } from '@ai-sdk/react'; -import { useState } from 'react'; -import type { DataUIPart } from 'ai'; - -type DataPart = - | { type: 'data-search-web'; data: { url: string; status: string } }; export function Chat() { - const [toolStatus, setToolStatus] = useState>({}); - const { messages, input, handleInputChange, handleSubmit } = useChat({ api: '/api/chat', - onData: (part: DataUIPart) => { - if (part.type === 'data-search-web') { - setToolStatus((prev) => ({ ...prev, [part.id]: part.data })); - } - }, }); return ( @@ -525,7 +506,11 @@ export function Chat() { return {part.text}; } if (part.type === 'tool-invocation') { - const status = toolStatus[part.toolInvocation.toolCallId]; + // Find the latest data part for this tool call + const dataPart = m.parts.findLast( + (p) => p.type === 'data' && p.id === part.toolInvocation.toolCallId + ); + const status = dataPart?.type === 'data' ? dataPart.data : null; return (
{status?.status === 'fetching' From da6e94eb35f70379d1f3015a392940348dfd773c Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:49:27 -0800 Subject: [PATCH 10/38] Delete previous --- docs/content/docs/ai-agents/index.mdx | 229 -------------------------- 1 file changed, 229 deletions(-) delete mode 100644 docs/content/docs/ai-agents/index.mdx diff --git a/docs/content/docs/ai-agents/index.mdx b/docs/content/docs/ai-agents/index.mdx deleted file mode 100644 index 2f8fcf187..000000000 --- a/docs/content/docs/ai-agents/index.mdx +++ /dev/null @@ -1,229 +0,0 @@ ---- -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 your agents into durable, resumable workflows, and managing your LLM calls, tool executions, and other async operations as retryable and observable steps. - -This guide walks through converting a basic AI SDK agent into a durable 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**: - -- **Durability**: Persisting chat sessions and turning all LLM and tool calls into separate async jobs, with workers, queues, and state management, which repeatedly save and re-load state from a database. -- **Observability**: Using services to collect traces and metrics, separately storing your messages and user history, and then combining them to get a complete picture of your agent's behavior. -- **Resumability**: Separately from storing messages for replayability, most LLM calls are streams, and recovering from a call failure without re-doing the entire call requires piping and storing streams separately from your messages, usually in a separate service. -- **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. If your stack is disjointed, this simple feature becomes a major orchestration challenge. - -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 - -Start with a Next.js application using the AI SDK's `Agent` class: - -```typescript title="app/api/chat/route.ts" lineNumbers -import { createUIMessageStreamResponse, Agent, stepCountIs } from 'ai'; -import { tools } from '@/ai/tools'; - -export async function POST(req: Request) { - const { messages, modelId } = await req.json(); - - const agent = new Agent({ - model: modelId, - system: 'You are a helpful assistant.', - tools: tools(), - }); - - const stream = agent.stream({ messages }); - - return createUIMessageStreamResponse({ - stream: stream.toUIMessageStream(), - }); -} -``` - -### Step 1: Install Dependencies - -Add the Workflow DevKit packages to your project: - -```bash -npm install workflow @workflow/ai -``` - -### Step 2: Create a Workflow Function - -Move the agent logic into a separate workflow function: - -```typescript title="app/api/chat/workflow.ts" lineNumbers -import { DurableAgent } from '@workflow/ai/agent'; // [!code highlight] -import { getWritable } from 'workflow'; // [!code highlight] -import { stepCountIs } from 'ai'; -import { tools } from '@/ai/tools'; -import type { ModelMessage, UIMessageChunk } from 'ai'; - -export async function chatWorkflow({ // [!code highlight] - messages, - modelId, -}: { - messages: ModelMessage[]; - modelId: string; -}) { - 'use workflow'; // [!code highlight] - - const writable = getWritable(); // [!code highlight] - - const agent = new DurableAgent({ // [!code highlight] - model: modelId, - system: 'You are a helpful assistant.', - tools: tools(), - }); - - await agent.stream({ - messages, - writable, - }); -} -``` - -Key changes: - -- Replace `Agent` with [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) from `@workflow/ai/agent` -- Add the `"use workflow"` directive to mark this as a workflow function -- Use [`getWritable()`](/docs/api-reference/workflow/get-writable) to get a stream for agent output -- Pass the `writable` to `agent.stream()` instead of returning a stream directly - -### Step 3: Update the API Route - -Replace the agent call with [`start()`](/docs/api-reference/workflow-api/start) to run the workflow: - -```typescript title="app/api/chat/route.ts" lineNumbers -import { createUIMessageStreamResponse, convertToModelMessages } from 'ai'; -import { start } from 'workflow/api'; // [!code highlight] -import { chatWorkflow } from './workflow'; // [!code highlight] - -export async function POST(req: Request) { - const { messages, modelId } = await req.json(); - const modelMessages = convertToModelMessages(messages); - - // This will start the workflow in the background - const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); // [!code highlight] - - return createUIMessageStreamResponse({ - stream: run.readable, // [!code highlight] - }); -} -``` - -### Step 4: Convert Tools to Steps - -Mark tool execution functions with `"use step"` to make them durable. This enables automatic retries and observability: - -```typescript title="ai/tools/search-web.ts" lineNumbers -import { tool } from 'ai'; -import { z } from 'zod'; - -async function executeSearch({ query }: { query: string }) { - 'use step'; // [!code highlight] - - const response = await fetch(`https://api.search.com?q=${query}`); - return response.json(); -} - -export const searchWeb = tool({ - description: 'Search the web for information', - inputSchema: z.object({ query: z.string() }), - execute: executeSearch, -}); -``` - -With `"use step"`: - -- The tool execution runs in a separate step with full Node.js access -- Failed tool calls are automatically retried (up to 3 times by default) -- Each tool execution appears as a discrete step in observability tools -- Results are persisted, so replays skip already-completed tools - -Note that any side-effects or network operations need to be run inside step functions, not in your workflow code. - -### Step 5: Stream Progress Updates from Tools - -Tools can emit progress updates to the same stream the agent uses. This allows the UI to display tool status and live updates from steps, without having to manually wire up streams: - -```typescript title="ai/tools/run-command.ts" lineNumbers -import { tool } from 'ai'; -import { getWritable } from 'workflow'; // [!code highlight] -import { z } from 'zod'; -import type { UIMessageChunk } from 'ai'; - -async function executeRunCommand( - { command }: { command: string }, - { toolCallId }: { toolCallId: string } -) { - 'use step'; - - const writable = getWritable(); // [!code highlight] - const writer = writable.getWriter(); // [!code highlight] - - // Emit a progress update // [!code highlight] - await writer.write({ // [!code highlight] - id: toolCallId, // [!code highlight] - type: 'data-run-command', // [!code highlight] - data: { command, status: 'executing' }, // [!code highlight] - }); // [!code highlight] - - const result = await runCommand(command); - - await writer.write({ // [!code highlight] - id: toolCallId, // [!code highlight] - type: 'data-run-command', // [!code highlight] - data: { command, status: 'done', result }, // [!code highlight] - }); // [!code highlight] - - writer.releaseLock(); // [!code highlight] - - return result; -} - -export const runCommand = tool({ - description: 'Run a shell command', - inputSchema: z.object({ command: z.string() }), - execute: executeRunCommand, -}); -``` - -The UI can handle these data chunks to display real-time progress. - -## Running the Workflow - -Run your development server, kick off a chat session, and then open the observability dashboard to see your workflow running live: - -```bash -npx workflow web -``` - -This opens a local dashboard showing all your workflow runs, with trace-viewers showing detailed views of each run, errors, retries, timing, and more. -The UI also allows you to cancel, re-run, and start new runs. - -## Next Steps - -Now that you have a basic durable agent, it's a short step to adding additional capabilities like resumable streams, sleep and delays, and human-in-the-loop. - - - - Enable clients to reconnect to interrupted streams without losing data. - - - Add native sleep functionality to allow your workflows to pause and resume, enable work on schedules, and more. - - - Implement approval workflows that wait for human input or other external events, - like PR updates, webhooks, and more. - - - -## Related Documentation - -- [`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 From 2b3c67201283acd67a05852d22ecb642b4105b78 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 14:52:48 -0800 Subject: [PATCH 11/38] fix --- docs/content/guides/ai-agents/index.mdx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index 0d90cc982..064d33f91 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -37,7 +37,6 @@ export async function POST(req: Request) { model: modelId, system: 'You are a helpful assistant.', tools, - stopWhen: stepCountIs(20), }); const stream = agent.stream({ messages }); @@ -125,6 +124,19 @@ Add the Workflow DevKit packages to your project: npm install workflow @workflow/ai ``` +and extend the NextJS compiler to transform the code: + +```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); +``` + From 7f3514ff76d8e0e6d25bf22ec6091f650b3f6efd Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 15:02:50 -0800 Subject: [PATCH 12/38] Fix routing --- docs/app/guides/[[...slug]]/page.tsx | 62 +++++--- docs/app/guides/page.tsx | 5 - .../guides/ai-agents/human-in-the-loop.mdx | 103 +++++++++++++ .../guides/ai-agents/resumable-streams.mdx | 145 +++++++++++++++++- .../guides/ai-agents/sleep-and-delays.mdx | 52 +++++++ 5 files changed, 341 insertions(+), 26 deletions(-) delete mode 100644 docs/app/guides/page.tsx diff --git a/docs/app/guides/[[...slug]]/page.tsx b/docs/app/guides/[[...slug]]/page.tsx index b8db99716..6a795537b 100644 --- a/docs/app/guides/[[...slug]]/page.tsx +++ b/docs/app/guides/[[...slug]]/page.tsx @@ -1,15 +1,16 @@ 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 } from 'next/navigation'; +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 { - DocsBody, - DocsDescription, - DocsPage, - DocsTitle, -} from '@/components/geistdocs/docs-page'; import { EditSource } from '@/components/geistdocs/edit-source'; import { Feedback } from '@/components/geistdocs/feedback'; import { getMDXComponents } from '@/components/geistdocs/mdx-components'; @@ -24,11 +25,21 @@ import { 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) { @@ -39,8 +50,14 @@ const Page = async (props: PageProps<'/guides/[[...slug]]'>) => { const MDX = page.data.body; return ( - @@ -53,11 +70,12 @@ const Page = async (props: PageProps<'/guides/[[...slug]]'>) => { ), }} - toc={page.data.toc} > - {page.data.title} - {page.data.description} - + + {page.data.title} + + {page.data.description} + ) => { Tab, })} /> - - + + ); }; -export const generateStaticParams = () => - guidesSource.generateParams().map((params) => ({ +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) { diff --git a/docs/app/guides/page.tsx b/docs/app/guides/page.tsx deleted file mode 100644 index 1822f3150..000000000 --- a/docs/app/guides/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation'; - -export default function GuidesPage() { - redirect('/guides/ai-agents'); -} diff --git a/docs/content/guides/ai-agents/human-in-the-loop.mdx b/docs/content/guides/ai-agents/human-in-the-loop.mdx index c5e1ee0fb..834984505 100644 --- a/docs/content/guides/ai-agents/human-in-the-loop.mdx +++ b/docs/content/guides/ai-agents/human-in-the-loop.mdx @@ -299,6 +299,109 @@ export async function criticalActionWorkflow(action: string) { } ``` +## Complete Example + +A human approval tool with UI component: + + + + + +```typescript title="ai/tools/human-approval.ts" lineNumbers +import { tool } from 'ai'; +import { createWebhook, getWritable } from 'workflow'; +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +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(); +} + +async function executeHumanApproval( + { message }: { message: string }, + { toolCallId }: { toolCallId: string } +) { + // No "use step" - webhooks are workflow-level primitives + const webhook = createWebhook(); + + await emitApprovalRequest({ url: webhook.url, message }, { toolCallId }); + + const request = await webhook; + const { approved, comment } = await request.json(); + + if (!approved) { + return `Action rejected: ${comment}`; + } + + return `Approved 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, +}); +``` + + + + + +```typescript title="components/approval-button.tsx" lineNumbers +'use client'; + +interface ApprovalData { + url: string; + message: string; +} + +export function ApprovalButton({ data }: { data: ApprovalData }) { + const handleApprove = async () => { + await fetch(data.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved: true, comment: 'Approved' }), + }); + }; + + const handleReject = async () => { + await fetch(data.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approved: false, comment: 'Rejected' }), + }); + }; + + return ( +
+

{data.message}

+ + +
+ ); +} +``` + +
+ +
+ ## Related Documentation - [Hooks & Webhooks](/docs/foundations/hooks) - Complete guide to hooks and webhooks diff --git a/docs/content/guides/ai-agents/resumable-streams.mdx b/docs/content/guides/ai-agents/resumable-streams.mdx index 5c3c6e97c..634edbd02 100644 --- a/docs/content/guides/ai-agents/resumable-streams.mdx +++ b/docs/content/guides/ai-agents/resumable-streams.mdx @@ -24,7 +24,10 @@ With a standard chat implementation, users must resend their message and wait fo ## Implementation -### Step 1: Return the Run ID from Your API + + + +### Return the Run ID from Your API Modify your chat endpoint to include the workflow run ID in a response header: @@ -48,7 +51,10 @@ export async function POST(req: Request) { } ``` -### Step 2: Add a Stream Reconnection Endpoint + + + +### Add a Stream Reconnection Endpoint Create an endpoint that returns the stream for an existing run: @@ -78,7 +84,10 @@ export async function GET( The `startIndex` parameter ensures the client only receives chunks it missed, avoiding duplicate data. -### Step 3: Use WorkflowChatTransport in the Client + + + +### Use `WorkflowChatTransport` in the Client Replace the default transport in `useChat` with `WorkflowChatTransport`: @@ -155,6 +164,10 @@ export function Chat() { } ``` + + + + ## How It Works 1. When the user sends a message, `WorkflowChatTransport` makes a POST to `/api/chat` @@ -201,6 +214,132 @@ new WorkflowChatTransport({ The transport automatically retries reconnection on network errors. After reaching the limit, it stops attempting and surfaces the error. +## Complete Example + + + + + +```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, modelId } = await req.json(); + const modelMessages = convertToModelMessages(messages); + + const run = await start(chatWorkflow, [{ messages: modelMessages, modelId }]); + + return createUIMessageStreamResponse({ + stream: run.readable, + headers: { + 'x-workflow-run-id': run.runId, + }, + }); +} +``` + + + + + +```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers +import { createUIMessageStreamResponse } from 'ai'; +import { getRun } from 'workflow/api'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + + const startIndexParam = searchParams.get('startIndex'); + const startIndex = startIndexParam + ? parseInt(startIndexParam, 10) + : undefined; + + const run = getRun(id); + const stream = run.getReadable({ startIndex }); + + return createUIMessageStreamResponse({ stream }); +} +``` + + + + + +```typescript title="app/chat.tsx" lineNumbers +'use client'; + +import { useChat } from '@ai-sdk/react'; +import { WorkflowChatTransport } from '@workflow/ai'; +import { useMemo, useState } from 'react'; + +export function Chat() { + const [input, setInput] = useState(''); + + const activeRunId = useMemo(() => { + if (typeof window === 'undefined') return; + return localStorage.getItem('active-workflow-run-id') ?? undefined; + }, []); + + const { messages, sendMessage, status } = useChat({ + resume: Boolean(activeRunId), + transport: new WorkflowChatTransport({ + api: '/api/chat', + onChatSendMessage: (response) => { + const workflowRunId = response.headers.get('x-workflow-run-id'); + if (workflowRunId) { + localStorage.setItem('active-workflow-run-id', workflowRunId); + } + }, + onChatEnd: () => { + localStorage.removeItem('active-workflow-run-id'); + }, + prepareReconnectToStreamRequest: ({ api, ...rest }) => { + const runId = localStorage.getItem('active-workflow-run-id'); + if (!runId) throw new Error('No active workflow run ID found'); + return { + ...rest, + api: `/api/chat/${encodeURIComponent(runId)}/stream`, + }; + }, + maxConsecutiveErrors: 5, + }), + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: {m.content} +
+ ))} +
{ + e.preventDefault(); + sendMessage({ text: input }); + setInput(''); + }} + > + setInput(e.target.value)} + placeholder="Type a message..." + /> +
+
+ ); +} +``` + +
+ +
+ ## Related Documentation - [`WorkflowChatTransport` API Reference](/docs/api-reference/workflow-ai/workflow-chat-transport) - Full configuration options diff --git a/docs/content/guides/ai-agents/sleep-and-delays.mdx b/docs/content/guides/ai-agents/sleep-and-delays.mdx index ec286a912..5c816c1a1 100644 --- a/docs/content/guides/ai-agents/sleep-and-delays.mdx +++ b/docs/content/guides/ai-agents/sleep-and-delays.mdx @@ -211,6 +211,58 @@ async function checkJobStatus(jobId: string) { } ``` +## Complete Example + +A sleep tool that pauses the agent for a specified duration: + +```typescript title="ai/tools/sleep.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable, sleep } from 'workflow'; +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +const inputSchema = z.object({ + durationMs: z.number().describe('Duration to sleep in milliseconds'), +}); + +async function reportSleep( + { durationMs }: { durationMs: number }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + const seconds = Math.ceil(durationMs / 1000); + + await writer.write({ + id: toolCallId, + type: 'data-wait', + data: { text: `Sleeping for ${seconds} seconds` }, + }); + + writer.releaseLock(); +} + +async function executeSleep( + { durationMs }: z.infer, + { toolCallId }: { toolCallId: string } +) { + // No "use step" - sleep is a workflow-level function + await reportSleep({ durationMs }, { toolCallId }); + await sleep(durationMs); + + return `Slept for ${durationMs}ms`; +} + +export const sleepTool = tool({ + description: 'Pause execution for a specified duration', + inputSchema, + execute: executeSleep, +}); +``` + ## Related Documentation - [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full API documentation From 11edb5ba5fb62969888974eda595ef5a25744807 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 15:18:43 -0800 Subject: [PATCH 13/38] Add nice graphic --- docs/app/guides/[[...slug]]/page.tsx | 2 + docs/components/guides/agent-traces.tsx | 94 +++++++++++++++++++ docs/content/guides/ai-agents/index.mdx | 2 + .../guides/ai-agents/resumable-streams.mdx | 4 +- 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 docs/components/guides/agent-traces.tsx diff --git a/docs/app/guides/[[...slug]]/page.tsx b/docs/app/guides/[[...slug]]/page.tsx index 6a795537b..c88aa11ce 100644 --- a/docs/app/guides/[[...slug]]/page.tsx +++ b/docs/app/guides/[[...slug]]/page.tsx @@ -14,6 +14,7 @@ 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'; @@ -81,6 +82,7 @@ const Page = async (props: PageProps<'/guides/[[...slug]]'>) => { a: createRelativeLink(guidesSource, page), // Add your custom components here + AgentTraces, Badge, TSDoc, Step, diff --git a/docs/components/guides/agent-traces.tsx b/docs/components/guides/agent-traces.tsx new file mode 100644 index 000000000..6232b1b51 --- /dev/null +++ b/docs/components/guides/agent-traces.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { motion } from 'motion/react'; +import { cn } from '@/lib/utils'; + +const rows = [ + { + label: 'chatWorkflow', + className: + 'bg-[#E1F0FF] dark:bg-[#00254D] border-[#99CEFF] text-[#0070F3] dark:border-[#0067D6] dark:text-[#52AEFF]', + start: 0, + duration: 100, + }, + { + label: 'agent.stream', + className: + 'bg-[#DCF6DC] dark:bg-[#1B311E] border-[#99E59F] text-[#46A758] dark:border-[#297C3B] dark:text-[#6CDA76]', + start: 2, + duration: 16, + }, + { + label: 'searchWeb', + className: + 'bg-[#FFF4E5] dark:bg-[#3D2800] border-[#FFCC80] text-[#F5A623] dark:border-[#9A6700] dark:text-[#FFCA28]', + start: 20, + duration: 13, + }, + { + label: 'agent.stream', + className: + 'bg-[#DCF6DC] dark:bg-[#1B311E] border-[#99E59F] text-[#46A758] dark:border-[#297C3B] dark:text-[#6CDA76]', + start: 37, + duration: 16, + }, + { + label: 'waitForHumanApproval', + className: + 'bg-[#FCE7F3] dark:bg-[#4A1D34] border-[#F9A8D4] text-[#EC4899] dark:border-[#BE185D] dark:text-[#F472B6]', + start: 57, + duration: 24, + }, + { + label: 'agent.stream', + className: + 'bg-[#DCF6DC] dark:bg-[#1B311E] border-[#99E59F] text-[#46A758] dark:border-[#297C3B] dark:text-[#6CDA76]', + start: 84, + duration: 16, + }, +]; + +export const AgentTraces = () => ( +
+
+ {rows.map((row, index) => ( +
+
+ +
+ + {row.label} + + {index === 0 && ( + + {row.duration}ms + + )} +
+
+
+
+ ))} +
+
+); diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index 064d33f91..a2e12273f 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -4,6 +4,8 @@ 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 your agents into durable, resumable workflows, and managing your LLM calls, tool executions, and other async operations as retryable and observable steps. + + This guide walks through converting a basic AI SDK agent into a durable agent using Workflow DevKit. ## Why Durable Agents? diff --git a/docs/content/guides/ai-agents/resumable-streams.mdx b/docs/content/guides/ai-agents/resumable-streams.mdx index 634edbd02..59aa79482 100644 --- a/docs/content/guides/ai-agents/resumable-streams.mdx +++ b/docs/content/guides/ai-agents/resumable-streams.mdx @@ -216,9 +216,9 @@ The transport automatically retries reconnection on network errors. After reachi ## Complete Example - + - + ```typescript title="app/api/chat/route.ts" lineNumbers import { createUIMessageStreamResponse, convertToModelMessages } from 'ai'; From 64d72657bd8b415078ae4dd9ba7dc07de23adb52 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 15:20:34 -0800 Subject: [PATCH 14/38] Polish --- docs/content/guides/ai-agents/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index a2e12273f..72adbbe72 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -22,7 +22,7 @@ Workflow DevKit provides all of these capabilities out of the box. Your agent be ## Getting Started We start out with a basic Next.js application using the AI SDK's `Agent` class, -which is a simple wrapper around AI SDK's `streamText` function. It comes with a basic tool for reading web pages. +which is a simple wrapper around AI SDK's `streamText` function. It comes with a simple tool for reading web pages. From afdb0b196f0d832dff93740d28c72927ac81c121 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 15:31:34 -0800 Subject: [PATCH 15/38] Package install polish --- docs/content/guides/ai-agents/index.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/guides/ai-agents/index.mdx b/docs/content/guides/ai-agents/index.mdx index 72adbbe72..8452267d2 100644 --- a/docs/content/guides/ai-agents/index.mdx +++ b/docs/content/guides/ai-agents/index.mdx @@ -122,8 +122,8 @@ export function Chat() { Add the Workflow DevKit packages to your project: -```bash -npm install workflow @workflow/ai +```package-install +npm i workflow @workflow/ai ``` and extend the NextJS compiler to transform the code: From 465c18d466ef30ec1df4a37e6345457deb58a457 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Wed, 26 Nov 2025 15:52:30 -0800 Subject: [PATCH 16/38] CHanges --- .../guides/ai-agents/defining-tools.mdx | 323 +++++++++++++++++ .../guides/ai-agents/human-in-the-loop.mdx | 324 +++++++++++++----- docs/content/guides/ai-agents/meta.json | 1 + .../guides/ai-agents/sleep-and-delays.mdx | 154 ++------- packages/ai/docs.md | 213 ++++++++++++ 5 files changed, 804 insertions(+), 211 deletions(-) create mode 100644 docs/content/guides/ai-agents/defining-tools.mdx create mode 100644 packages/ai/docs.md diff --git a/docs/content/guides/ai-agents/defining-tools.mdx b/docs/content/guides/ai-agents/defining-tools.mdx new file mode 100644 index 000000000..602db68ed --- /dev/null +++ b/docs/content/guides/ai-agents/defining-tools.mdx @@ -0,0 +1,323 @@ +--- +title: Defining Tool Calls +--- + +Tools are the primary way AI agents interact with the outside world. In Workflow DevKit, tools can be implemented as workflow steps for automatic retries and persistence, or they can use workflow-level primitives like `sleep()` and webhooks. This guide covers the patterns and best practices for defining tools in durable agents. + +## Tool Structure + +Every tool has three key parts: + +1. **Input Schema** - A Zod schema defining what the LLM can pass to the tool +2. **Description** - A natural language description helping the LLM decide when to use the tool +3. **Execute Function** - The logic that runs when the tool is called + +```typescript title="ai/tools/search-web.ts" lineNumbers +import { tool } from 'ai'; +import { z } from 'zod'; + +export const searchWeb = tool({ + description: 'Search the web for information', + inputSchema: z.object({ + query: z.string().describe('The search query'), + maxResults: z.number().optional().describe('Maximum number of results'), + }), + execute: async ({ query, maxResults = 5 }) => { + // Tool implementation + return { results: [] }; + }, +}); +``` + +## The Execute Function Context + +The execute function receives two arguments: + +1. **Input** - The validated input matching your schema +2. **Options** - Context about the tool call, including: + - `toolCallId` - Unique identifier for this invocation + - `messages` - The conversation history up to and including this tool call + +```typescript lineNumbers +execute: async (input, options) => { + const { toolCallId, messages } = options; + + // toolCallId: Use for correlating stream chunks with this tool call + // messages: Access the full conversation context + + return result; +} +``` + +## Accessing Conversation Messages + +Tools can access the full conversation history through the `messages` option. This is useful when your tool needs context about what was discussed: + +```typescript title="ai/tools/summarize-conversation.ts" lineNumbers +import { tool } from 'ai'; +import { z } from 'zod'; + +export const summarizeConversation = tool({ + description: 'Summarize the conversation so far', + inputSchema: z.object({ + focus: z.string().optional().describe('What aspect to focus on'), + }), + execute: async ({ focus }, { messages }) => { // [!code highlight] + // messages contains the full LanguageModelV2Prompt + const userMessages = messages + .filter(m => m.role === 'user') + .map(m => { + // Extract text content from the message + const textParts = m.content + .filter(p => p.type === 'text') + .map(p => p.text); + return textParts.join(' '); + }); + + // Use the conversation context in your tool logic + return { + messageCount: messages.length, + userMessageCount: userMessages.length, + summary: `Conversation about: ${userMessages.slice(-3).join(', ')}`, + }; + }, +}); +``` + +The messages follow the `LanguageModelV2Prompt` format from the AI SDK, which includes: +- User messages with text and file content +- Assistant messages with text and tool calls +- Tool result messages from previous tool executions + +## Streaming from Tools + +Tools can emit real-time updates to the UI by writing to the workflow's output stream. This must be done within a step function: + + + + +### Create a Step for Stream Writes + +Since `getWritable()` requires the step context, create a separate step function for emitting chunks: + +```typescript lineNumbers +import { getWritable } from 'workflow'; +import type { UIMessageChunk } from 'ai'; + +async function emitProgress( + { status, url }: { status: string; url?: string }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; // [!code highlight] + + const writable = getWritable(); + const writer = writable.getWriter(); + + await writer.write({ + id: toolCallId, + type: 'data-status', + data: { status, url }, + }); + + writer.releaseLock(); +} +``` + + + +### Call from the Execute Function + +The execute function can call this step to emit progress: + +```typescript lineNumbers +async function executeSearchWeb( + { query }: { query: string }, + { toolCallId }: { toolCallId: string } +) { + await emitProgress({ status: 'searching', url: query }, { toolCallId }); // [!code highlight] + + const results = await performSearch(query); + + await emitProgress({ status: 'complete' }, { toolCallId }); // [!code highlight] + + return results; +} +``` + + + + +### Why Separate Steps? + +The `getWritable()` function is only available within steps, not at the workflow level. This is because: + +1. Steps provide the transactional boundary for stream writes +2. Each step's writes are persisted and can be replayed on recovery +3. The workflow context handles orchestration, not I/O + +## Workflow-Level vs Step-Level Tools + +Tools can run at two levels, with different capabilities: + +### Step-Level Tools + +When your execute function uses `"use step"`, the entire tool runs as a retryable step: + +```typescript lineNumbers +async function executeWithRetries({ data }: { data: string }) { + 'use step'; // [!code highlight] + + // ✅ Can use getWritable() + // ✅ Automatic retries on transient failures + // ✅ Results persisted in event log + // ❌ Cannot call sleep() or createWebhook() + + const result = await fetch(`https://api.example.com?q=${data}`); + return result.json(); +} +``` + +### Workflow-Level Tools + +When your execute function does NOT use `"use step"`, it runs in the workflow context: + +```typescript lineNumbers +async function executeWithPause({ duration }: { duration: number }) { + // No "use step" - runs at workflow level // [!code highlight] + + // ❌ Cannot use getWritable() directly + // ✅ Can call sleep() + // ✅ Can call createWebhook() + // ✅ Can call step functions + + await reportStatus('pausing'); // Step function for streaming + await sleep(duration); // Workflow-level function + + return 'Resumed after pause'; +} +``` + +### Combining Both Levels + +Most tools combine both approaches - use step functions for I/O and retryable operations, while keeping the execute function at the workflow level for orchestration: + +```typescript title="ai/tools/fetch-with-progress.ts" lineNumbers +import { tool } from 'ai'; +import { getWritable, sleep } from 'workflow'; +import { z } from 'zod'; +import type { UIMessageChunk } from 'ai'; + +// Step function: handles streaming +async function emitFetchStatus( + { url, status }: { url: string; status: 'fetching' | 'complete' | 'retrying' }, + { toolCallId }: { toolCallId: string } +) { + 'use step'; + + const writable = getWritable(); + const writer = writable.getWriter(); + + await writer.write({ + id: toolCallId, + type: 'data-fetch-status', + data: { url, status }, + }); + + writer.releaseLock(); +} + +// Step function: handles the actual fetch with retries +async function performFetch(url: string) { + 'use step'; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Fetch failed: ${response.status}`); + } + return response.json(); +} + +// Workflow-level execute: orchestrates steps +async function executeFetchWithProgress( + { url }: { url: string }, + { toolCallId }: { toolCallId: string } +) { + await emitFetchStatus({ url, status: 'fetching' }, { toolCallId }); + + const result = await performFetch(url); + + await emitFetchStatus({ url, status: 'complete' }, { toolCallId }); + + return result; +} + +export const fetchWithProgress = tool({ + description: 'Fetch a URL and report progress', + inputSchema: z.object({ + url: z.string().url(), + }), + execute: executeFetchWithProgress, +}); +``` + +## Custom Data Chunk Types + +When emitting data chunks, use descriptive type names that your UI can handle: + +```typescript lineNumbers +// In your tool +await writer.write({ + id: toolCallId, + type: 'data-run-command', // Custom type + data: { + command: 'npm install', + cwd: '/project', + }, +}); + +await writer.write({ + id: toolCallId, + type: 'data-file-created', // Another custom type + data: { + path: '/project/package.json', + size: 1234, + }, +}); +``` + +The UI handles these custom types by checking the part type: + +```typescript title="components/chat.tsx" lineNumbers +{messages.map((m) => ( +
+ {m.parts.map((part, i) => { + if (part.type === 'text') { + return {part.text}; + } + if (part.type === 'data-run-command') { + return ( +
+ $ {part.data.command} +
+ ); + } + if (part.type === 'data-file-created') { + return ( +
+ Created: {part.data.path} +
+ ); + } + return null; + })} +
+))} +``` + +## Related Documentation + +- [Building Durable AI Agents](/guides/ai-agents) - Getting started with durable agents +- [Streaming](/docs/foundations/streaming) - How streaming works in Workflow DevKit +- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding the execution model +- [`DurableAgent` API Reference](/docs/api-reference/workflow-ai/durable-agent) - Complete 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 index 834984505..f7b6fd82b 100644 --- a/docs/content/guides/ai-agents/human-in-the-loop.mdx +++ b/docs/content/guides/ai-agents/human-in-the-loop.mdx @@ -4,14 +4,49 @@ title: Human-in-the-Loop Some agent actions require human approval before proceeding - deploying to production, sending emails to customers, or making financial transactions. Workflow DevKit's webhook and hook primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action. +## How It Works + + + + +The agent calls a tool (like `humanApproval`) with a message describing what needs approval. + + + +`createWebhook()` generates a unique URL that can resume the workflow. + + + +The tool emits a data chunk containing the approval URL to the UI. + + + +The workflow pauses at `await webhook` - no compute resources are consumed. + + + +When a human clicks approve/reject and the webhook URL is called, the workflow resumes. + + + +The tool receives any data sent with the webhook (approval status, comments) and returns. + + + + ## Creating an Approval Tool Add a tool that pauses the agent until a human approves or rejects: + + + +### Create the Emit Helper + +First, create a step function to emit the approval request to the UI: + ```typescript title="ai/tools/human-approval.ts" lineNumbers -import { tool } from 'ai'; -import { createWebhook, getWritable } from 'workflow'; // [!code highlight] -import { z } from 'zod'; +import { getWritable } from 'workflow'; import type { UIMessageChunk } from 'ai'; async function emitApprovalRequest( @@ -31,6 +66,18 @@ async function emitApprovalRequest( writer.releaseLock(); } +``` + + + +### Implement the Execute Function + +The execute function creates a webhook and waits for approval: + +```typescript title="ai/tools/human-approval.ts" lineNumbers +import { tool } from 'ai'; +import { createWebhook } from 'workflow'; // [!code highlight] +import { z } from 'zod'; async function executeHumanApproval( { message }: { message: string }, @@ -47,9 +94,14 @@ async function executeHumanApproval( ); // Workflow pauses here until the webhook is called // [!code highlight] - await webhook; // [!code highlight] + const request = await webhook; // [!code highlight] + const { approved, comment } = await request.json(); // [!code highlight] - return 'Approval received. Proceeding with action.'; + if (!approved) { + return `Action rejected: ${comment || 'No reason provided'}`; + } + + return `Approved${comment ? ` with comment: ${comment}` : ''}`; } export const humanApproval = tool({ @@ -64,104 +116,149 @@ export const humanApproval = tool({ The `createWebhook()` function must be called from within a workflow context, not from a step. This is why `executeHumanApproval` does not have `"use step"`. + -## How It Works - -1. The agent calls the `humanApproval` tool with a message describing what needs approval -2. `createWebhook()` generates a unique URL that can resume the workflow -3. The tool emits a data chunk containing the approval URL to the UI -4. The workflow pauses at `await webhook` - no compute resources are consumed -5. When a human visits the webhook URL, the workflow resumes -6. The tool returns and the agent continues with the approved action + ## Handling the Approval UI -The UI receives a data chunk with type `data-approval-required`. Display an approval button that triggers the webhook: +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: + + + + +### Create the Approval Component ```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 handleApprove = async () => { - await fetch(data.url, { method: 'POST' }); + 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}

- +
+

{data.message}

+ +