Workflow Development Kit Setup
Install and configure the Workflow Development Kit for resumable, durable AI agent workflows with step-level persistence, stream resumption, and agent orchestration.
Step 1: Install the packages
bun add workflow @workflow/aibun add workflow @workflow/aiStep 2: Create the workflows folder
Create the src/workflows/ folder structure:
src/workflows/
steps/ # Shared step functions (reusable across workflows)
chat/
index.ts # Workflow orchestration function ("use workflow")
steps/ # Workflow-specific steps ("use step")
history.ts
logger.ts
name-chat.ts
types.ts # Workflow-specific typessrc/workflows/
steps/ # Shared step functions (reusable across workflows)
chat/
index.ts # Workflow orchestration function ("use workflow")
steps/ # Workflow-specific steps ("use step")
history.ts
logger.ts
name-chat.ts
types.ts # Workflow-specific typesworkflows/steps/- Shared step functions reusable across workflows.workflows/chat/- A specific workflow with its own orchestration and steps.
Step 3: Update Next.js config
Update the Next.js configuration:
// next.config.ts
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default withWorkflow(nextConfig);// next.config.ts
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default withWorkflow(nextConfig);Step 4: Add stream step utilities
Install via shadcn registry
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/workflow-stream.jsonWhen streaming UIMessageChunk responses (like chat messages), you must signal the start and end of the stream. This is required for proper stream framing with WorkflowChatTransport.
The Chat Workflow
Create the main workflow that processes user messages and generates AI responses:
import { getWorkflowMetadata, getWritable } from "workflow";
import type { ChatAgentUIMessage } from "./types";
import {
persistUserMessage,
createAssistantMessage,
getMessageHistory,
removeRunId,
persistMessageParts,
} from "./steps/history";
import { startStream, finishStream } from "../steps/stream";
import { log } from "./steps/logger";
import { nameChatStep } from "./steps/name-chat";
import { chatAgent } from "@/lib/ai/chat-agent";
/**
* Main chat workflow that processes user messages and generates AI responses.
* Uses runId for stream resumability on client reconnection.
*/
export async function chatWorkflow({
chatId,
userMessage,
}: {
chatId: string;
userMessage: ChatAgentUIMessage;
}) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
await log("info", "Starting chat workflow", { chatId, runId: workflowRunId });
// Persist the user message
await persistUserMessage({ chatId, message: userMessage });
// Create a placeholder assistant message with runId for resumability
const messageId = await createAssistantMessage({
chatId,
runId: workflowRunId,
});
// Get full message history
const history = await getMessageHistory(chatId);
// Start the UI message stream
await startStream(messageId);
// Run the agent with streaming
const { parts } = await chatAgent.run(history, {
maxSteps: 10,
writable: getWritable(),
});
// Persist the assistant message parts
await persistMessageParts({ chatId, messageId, parts });
// Finish the UI message stream
await finishStream();
// Clear the runId to mark the message as complete
await removeRunId(messageId);
// Generate a chat title if this is the first message
await nameChatStep(chatId, userMessage);
await log("info", "Chat workflow completed", {
chatId,
runId: workflowRunId,
partsCount: parts.length,
});
}import { getWorkflowMetadata, getWritable } from "workflow";
import type { ChatAgentUIMessage } from "./types";
import {
persistUserMessage,
createAssistantMessage,
getMessageHistory,
removeRunId,
persistMessageParts,
} from "./steps/history";
import { startStream, finishStream } from "../steps/stream";
import { log } from "./steps/logger";
import { nameChatStep } from "./steps/name-chat";
import { chatAgent } from "@/lib/ai/chat-agent";
/**
* Main chat workflow that processes user messages and generates AI responses.
* Uses runId for stream resumability on client reconnection.
*/
export async function chatWorkflow({
chatId,
userMessage,
}: {
chatId: string;
userMessage: ChatAgentUIMessage;
}) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
await log("info", "Starting chat workflow", { chatId, runId: workflowRunId });
// Persist the user message
await persistUserMessage({ chatId, message: userMessage });
// Create a placeholder assistant message with runId for resumability
const messageId = await createAssistantMessage({
chatId,
runId: workflowRunId,
});
// Get full message history
const history = await getMessageHistory(chatId);
// Start the UI message stream
await startStream(messageId);
// Run the agent with streaming
const { parts } = await chatAgent.run(history, {
maxSteps: 10,
writable: getWritable(),
});
// Persist the assistant message parts
await persistMessageParts({ chatId, messageId, parts });
// Finish the UI message stream
await finishStream();
// Clear the runId to mark the message as complete
await removeRunId(messageId);
// Generate a chat title if this is the first message
await nameChatStep(chatId, userMessage);
await log("info", "Chat workflow completed", {
chatId,
runId: workflowRunId,
partsCount: parts.length,
});
}History Steps
Create step functions for message persistence:
import type { UIMessage } from "ai";
import { db } from "@/lib/db/client";
import { messages, chats } from "@/lib/chat/schema";
import {
persistMessage,
insertMessageParts,
getChatMessages,
convertDbMessagesToUIMessages,
clearMessageRunId,
} from "@/lib/chat/queries";
import { eq } from "drizzle-orm";
import { assertChatAgentParts, type ChatAgentUIMessage } from "../types";
import { v7 as uuidv7 } from "uuid";
/**
* Persist a user message to the database.
*/
export async function persistUserMessage({
chatId,
message,
}: {
chatId: string;
message: ChatAgentUIMessage;
}): Promise<void> {
"use step";
await persistMessage({ chatId, message });
// Update chat timestamp
await db
.update(chats)
.set({ updatedAt: new Date() })
.where(eq(chats.id, chatId));
}
/**
* Create a placeholder assistant message with a runId for stream resumption.
* Parts will be added later when streaming completes.
*/
export async function createAssistantMessage({
chatId,
runId,
}: {
chatId: string;
runId: string;
}): Promise<string> {
"use step";
const [{ messageId }] = await db
.insert(messages)
.values({
id: uuidv7(),
chatId,
role: "assistant",
runId,
})
.returning({ messageId: messages.id });
return messageId;
}
/**
* Persist message parts after streaming completes.
* Validates and narrows generic UIMessage parts to ChatAgentUIMessage parts.
*/
export async function persistMessageParts({
chatId,
messageId,
parts,
}: {
chatId: string;
messageId: string;
parts: UIMessage["parts"];
}): Promise<void> {
"use step";
assertChatAgentParts(parts);
await insertMessageParts(chatId, messageId, parts);
// Update chat timestamp
await db
.update(chats)
.set({ updatedAt: new Date() })
.where(eq(chats.id, chatId));
}
/**
* Get message history for a chat, converted to UI message format.
*/
export async function getMessageHistory(
chatId: string,
): Promise<ChatAgentUIMessage[]> {
"use step";
const dbMessages = await getChatMessages(chatId);
return convertDbMessagesToUIMessages(dbMessages);
}
/**
* Clear the runId from a message after streaming is complete.
* This marks the message as finalized.
*/
export async function removeRunId(messageId: string): Promise<void> {
"use step";
await clearMessageRunId(messageId);
}import type { UIMessage } from "ai";
import { db } from "@/lib/db/client";
import { messages, chats } from "@/lib/chat/schema";
import {
persistMessage,
insertMessageParts,
getChatMessages,
convertDbMessagesToUIMessages,
clearMessageRunId,
} from "@/lib/chat/queries";
import { eq } from "drizzle-orm";
import { assertChatAgentParts, type ChatAgentUIMessage } from "../types";
import { v7 as uuidv7 } from "uuid";
/**
* Persist a user message to the database.
*/
export async function persistUserMessage({
chatId,
message,
}: {
chatId: string;
message: ChatAgentUIMessage;
}): Promise<void> {
"use step";
await persistMessage({ chatId, message });
// Update chat timestamp
await db
.update(chats)
.set({ updatedAt: new Date() })
.where(eq(chats.id, chatId));
}
/**
* Create a placeholder assistant message with a runId for stream resumption.
* Parts will be added later when streaming completes.
*/
export async function createAssistantMessage({
chatId,
runId,
}: {
chatId: string;
runId: string;
}): Promise<string> {
"use step";
const [{ messageId }] = await db
.insert(messages)
.values({
id: uuidv7(),
chatId,
role: "assistant",
runId,
})
.returning({ messageId: messages.id });
return messageId;
}
/**
* Persist message parts after streaming completes.
* Validates and narrows generic UIMessage parts to ChatAgentUIMessage parts.
*/
export async function persistMessageParts({
chatId,
messageId,
parts,
}: {
chatId: string;
messageId: string;
parts: UIMessage["parts"];
}): Promise<void> {
"use step";
assertChatAgentParts(parts);
await insertMessageParts(chatId, messageId, parts);
// Update chat timestamp
await db
.update(chats)
.set({ updatedAt: new Date() })
.where(eq(chats.id, chatId));
}
/**
* Get message history for a chat, converted to UI message format.
*/
export async function getMessageHistory(
chatId: string,
): Promise<ChatAgentUIMessage[]> {
"use step";
const dbMessages = await getChatMessages(chatId);
return convertDbMessagesToUIMessages(dbMessages);
}
/**
* Clear the runId from a message after streaming is complete.
* This marks the message as finalized.
*/
export async function removeRunId(messageId: string): Promise<void> {
"use step";
await clearMessageRunId(messageId);
}Logging in Workflows
Workflow functions run in a restricted environment that doesn't support Node.js modules like fs, events, or worker_threads. Since pino uses these modules, you cannot import the logger directly in workflow functions.
Instead, wrap logger calls in a step function:
import { logger } from "@/lib/logging/logger";
type LogLevel = "info" | "warn" | "error" | "debug";
/**
* Workflow-safe logger step.
* Wraps pino logger calls in a step function to avoid bundling
* Node.js modules (fs, events, worker_threads) into workflow functions.
*/
export async function log(
level: LogLevel,
message: string,
data?: Record<string, unknown>,
): Promise<void> {
"use step";
if (data) {
logger[level](data, message);
} else {
logger[level](message);
}
}import { logger } from "@/lib/logging/logger";
type LogLevel = "info" | "warn" | "error" | "debug";
/**
* Workflow-safe logger step.
* Wraps pino logger calls in a step function to avoid bundling
* Node.js modules (fs, events, worker_threads) into workflow functions.
*/
export async function log(
level: LogLevel,
message: string,
data?: Record<string, unknown>,
): Promise<void> {
"use step";
if (data) {
logger[level](data, message);
} else {
logger[level](message);
}
}This pattern applies to any library that uses Node.js modules. Move the import and usage into a step function to isolate it from the workflow runtime.
References
Resumable AI Response Streams
Add automatic stream recovery to AI chat with WorkflowChatTransport, start/resume API endpoints, and the useResumableChat hook.
Start Workflow Endpoint
Create the endpoint to start workflow runs:
import { headers } from "next/headers";
import { ensureChatExists } from "@/lib/chat/queries";
import { auth } from "@/lib/auth/server";
import { chatWorkflow } from "@/workflows/chat";
import { start } from "workflow/api";
import { createUIMessageStreamResponse } from "ai";
import type { ChatAgentUIMessage } from "@/workflows/chat/types";
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const { chatId, message } = (await request.json()) as {
chatId: string;
message: ChatAgentUIMessage;
};
if (!chatId || !message) {
return new Response("Missing chatId or message", { status: 400 });
}
// Ensure chat exists (create if needed) and verify ownership
const isAuthorized = await ensureChatExists(chatId, session.user.id);
if (!isAuthorized) {
return new Response("Forbidden", { status: 403 });
}
// Start workflow with user message
const run = await start(chatWorkflow, [
{
chatId,
userMessage: message,
},
]);
// Return stream with runId for resumability
return createUIMessageStreamResponse({
stream: run.readable,
headers: {
"x-workflow-run-id": run.runId,
},
});
}import { headers } from "next/headers";
import { ensureChatExists } from "@/lib/chat/queries";
import { auth } from "@/lib/auth/server";
import { chatWorkflow } from "@/workflows/chat";
import { start } from "workflow/api";
import { createUIMessageStreamResponse } from "ai";
import type { ChatAgentUIMessage } from "@/workflows/chat/types";
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const { chatId, message } = (await request.json()) as {
chatId: string;
message: ChatAgentUIMessage;
};
if (!chatId || !message) {
return new Response("Missing chatId or message", { status: 400 });
}
// Ensure chat exists (create if needed) and verify ownership
const isAuthorized = await ensureChatExists(chatId, session.user.id);
if (!isAuthorized) {
return new Response("Forbidden", { status: 403 });
}
// Start workflow with user message
const run = await start(chatWorkflow, [
{
chatId,
userMessage: message,
},
]);
// Return stream with runId for resumability
return createUIMessageStreamResponse({
stream: run.readable,
headers: {
"x-workflow-run-id": run.runId,
},
});
}Resume Stream Endpoint
Create the endpoint to resume workflow streams:
import { headers } from "next/headers";
import { getRun } from "workflow/api";
import { createUIMessageStreamResponse } from "ai";
import { auth } from "@/lib/auth/server";
import { verifyChatOwnership } from "@/lib/chat/queries";
/**
* GET /api/chats/:chatId/messages/:runId/stream
* Resume endpoint for workflow streams
*
* Query params:
* - startIndex: optional chunk index to resume from
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ chatId: string; runId: string }> },
) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const { chatId, runId } = await params;
if (!runId) {
return new Response("Missing runId parameter", { status: 400 });
}
const isAuthorized = await verifyChatOwnership(chatId, session.user.id);
if (!isAuthorized) {
return new Response("Forbidden", { status: 403 });
}
const { searchParams } = new URL(request.url);
const startIndexParam = searchParams.get("startIndex");
const startIndex =
startIndexParam !== null ? parseInt(startIndexParam, 10) : undefined;
const run = await getRun(runId);
const readable = await run.getReadable({ startIndex });
return createUIMessageStreamResponse({
stream: readable,
});
}import { headers } from "next/headers";
import { getRun } from "workflow/api";
import { createUIMessageStreamResponse } from "ai";
import { auth } from "@/lib/auth/server";
import { verifyChatOwnership } from "@/lib/chat/queries";
/**
* GET /api/chats/:chatId/messages/:runId/stream
* Resume endpoint for workflow streams
*
* Query params:
* - startIndex: optional chunk index to resume from
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ chatId: string; runId: string }> },
) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
const { chatId, runId } = await params;
if (!runId) {
return new Response("Missing runId parameter", { status: 400 });
}
const isAuthorized = await verifyChatOwnership(chatId, session.user.id);
if (!isAuthorized) {
return new Response("Forbidden", { status: 403 });
}
const { searchParams } = new URL(request.url);
const startIndexParam = searchParams.get("startIndex");
const startIndex =
startIndexParam !== null ? parseInt(startIndexParam, 10) : undefined;
const run = await getRun(runId);
const readable = await run.getReadable({ startIndex });
return createUIMessageStreamResponse({
stream: readable,
});
}Key Workflow API Functions
start(workflow, args)
Starts a new workflow run:
- Returns
{ runId, readable } runIduniquely identifies this run for resumptionreadableis aReadableStreamof UI message chunks
getRun(runId)
Gets an existing workflow run:
- Returns a run object with
getReadable({ startIndex? }) startIndexallows resuming from a specific chunk
Response Headers
The x-workflow-run-id header is critical for resumability:
return createUIMessageStreamResponse({
stream: run.readable,
headers: {
"x-workflow-run-id": run.runId,
},
});return createUIMessageStreamResponse({
stream: run.readable,
headers: {
"x-workflow-run-id": run.runId,
},
});The client captures this header and uses it for reconnection.
Authorization
Both endpoints verify:
- User is authenticated (session exists)
- User owns the chat (
ensureChatExists/verifyChatOwnership)
This prevents unauthorized access to other users' workflow streams.
Workflow Client Integration
The client uses WorkflowChatTransport for automatic stream resumption.
Resumable Chat Hook
Install via shadcn registry
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/use-resumable-chat.jsonThe hook requires a ChatAgentUIMessage type definition. Update the import path to match your project structure.
Chat Page with Resumption Detection
Create or update the chat page with resumption detection:
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Chat } from "@/components/chat/chat";
import {
convertDbMessagesToUIMessages,
ensureChatExists,
getChatMessages,
} from "@/lib/chat/queries";
import { auth } from "@/lib/auth/server";
import { UserMenu } from "@/components/auth/user-menu";
import { ThemeSelector } from "@/components/themes/selector";
export const metadata: Metadata = {
title: "Chat",
description: "Continue your AI-powered conversation.",
};
interface PageProps {
params: Promise<{
chatId: string;
}>;
}
export default async function ChatPage({ params }: PageProps) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
const { chatId } = await params;
const userId = session.user.id;
const isAuthorized = await ensureChatExists(chatId, userId);
if (!isAuthorized) {
redirect("/");
}
// Fetch all messages for this chat
const persistedMessages = await getChatMessages(chatId);
// Check if the last message is an incomplete assistant message (has runId but no parts)
// This happens when a workflow was interrupted mid-stream
const lastMessage = persistedMessages.at(-1);
const isIncompleteMessage =
lastMessage?.role === "assistant" &&
lastMessage?.runId &&
lastMessage?.parts.length === 0;
// If incomplete, extract the runId for resumption and remove the empty message from history
const initialRunId = isIncompleteMessage ? lastMessage.runId : undefined;
const messagesToConvert = isIncompleteMessage
? persistedMessages.slice(0, -1)
: persistedMessages;
const history = convertDbMessagesToUIMessages(messagesToConvert);
return (
<div className="h-dvh bg-gradient-to-b from-background via-background to-muted/20 grid grid-rows-[auto_1fr]">
<header className="z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="container mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-4">
<Link
href="/chats"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only sm:not-sr-only text-sm font-medium">
Back to chats
</span>
</Link>
<span className="text-border hidden sm:inline">|</span>
<span className="hidden sm:block font-mono text-lg font-semibold tracking-tight">
AI Chat
</span>
</div>
<div className="flex items-center gap-2">
<ThemeSelector />
<UserMenu />
</div>
</div>
</header>
<main className="min-h-0 overflow-hidden">
<Chat
messageHistory={history}
chatId={chatId}
initialRunId={initialRunId ?? undefined}
/>
</main>
</div>
);
}import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Chat } from "@/components/chat/chat";
import {
convertDbMessagesToUIMessages,
ensureChatExists,
getChatMessages,
} from "@/lib/chat/queries";
import { auth } from "@/lib/auth/server";
import { UserMenu } from "@/components/auth/user-menu";
import { ThemeSelector } from "@/components/themes/selector";
export const metadata: Metadata = {
title: "Chat",
description: "Continue your AI-powered conversation.",
};
interface PageProps {
params: Promise<{
chatId: string;
}>;
}
export default async function ChatPage({ params }: PageProps) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
const { chatId } = await params;
const userId = session.user.id;
const isAuthorized = await ensureChatExists(chatId, userId);
if (!isAuthorized) {
redirect("/");
}
// Fetch all messages for this chat
const persistedMessages = await getChatMessages(chatId);
// Check if the last message is an incomplete assistant message (has runId but no parts)
// This happens when a workflow was interrupted mid-stream
const lastMessage = persistedMessages.at(-1);
const isIncompleteMessage =
lastMessage?.role === "assistant" &&
lastMessage?.runId &&
lastMessage?.parts.length === 0;
// If incomplete, extract the runId for resumption and remove the empty message from history
const initialRunId = isIncompleteMessage ? lastMessage.runId : undefined;
const messagesToConvert = isIncompleteMessage
? persistedMessages.slice(0, -1)
: persistedMessages;
const history = convertDbMessagesToUIMessages(messagesToConvert);
return (
<div className="h-dvh bg-gradient-to-b from-background via-background to-muted/20 grid grid-rows-[auto_1fr]">
<header className="z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="container mx-auto px-4 h-14 flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-4">
<Link
href="/chats"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only sm:not-sr-only text-sm font-medium">
Back to chats
</span>
</Link>
<span className="text-border hidden sm:inline">|</span>
<span className="hidden sm:block font-mono text-lg font-semibold tracking-tight">
AI Chat
</span>
</div>
<div className="flex items-center gap-2">
<ThemeSelector />
<UserMenu />
</div>
</div>
</header>
<main className="min-h-0 overflow-hidden">
<Chat
messageHistory={history}
chatId={chatId}
initialRunId={initialRunId ?? undefined}
/>
</main>
</div>
);
}How Resumption Detection Works
- On page load, fetch all messages for the chat
- Check last message - if it's an assistant message with
runIdbut no parts, it's incomplete - Extract
runId- pass to client for resumption - Remove empty message - don't show the incomplete message in UI
- Client resumes -
WorkflowChatTransportreconnects using therunId
WorkflowChatTransport Options
| Option | Description |
|---|---|
prepareSendMessagesRequest | Configure the initial message send request |
onChatSendMessage | Callback when message is sent (capture runId) |
prepareReconnectToStreamRequest | Configure reconnection request URL |
onChatEnd | Callback when stream completes |
maxConsecutiveErrors | Number of reconnection retries |
Using the Hook in Components
"use client";
import { useResumableChat } from "@/hooks/use-resumable-chat";
export function Chat({ chatId, messageHistory, initialRunId }) {
const { messages, sendMessage, status } = useResumableChat({
chatId,
messageHistory,
initialRunId,
});
// Render messages and input...
}"use client";
import { useResumableChat } from "@/hooks/use-resumable-chat";
export function Chat({ chatId, messageHistory, initialRunId }) {
const { messages, sendMessage, status } = useResumableChat({
chatId,
messageHistory,
initialRunId,
});
// Render messages and input...
}Custom Durable Agent
Build a custom durable AI agent with full control over streamText options, provider configs, and tool loops. Compatible with the Workflow Development Kit.
Why a Custom Agent?
The built-in DurableAgent from @workflow/ai/agent covers most use cases. This custom agent is needed when:
- Streaming reasoning/sources -
DurableAgentdoesn't exposesendReasoningorsendSourcesoptions - UIMessage persistence -
DurableAgent.onFinishprovidesModelMessage[], but this agent providesUIMessagewith itspartsarray directly viatoUIMessageStream().onFinish
How It Works
The Agent class wraps the AI SDK's streamText with a tool loop. It uses toUIMessageStream() internally to capture responseMessage: UIMessage in each step.
Key design decisions:
- Serializable config - Tool functions are referenced by key and resolved inside the step executor
- Standalone step executor - The
"use step"directive only works in standalone functions, not class methods
Defining an Agent
Install via shadcn registry
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/durable-agent.jsonThe agent class uses a toolSets object to resolve tool references by key. Update the import to match your project's tool configuration.
Creating Agent Instances
Create specialized agents with different configurations:
import { Agent } from "./agent";
export const chatAgent = new Agent({
stepOptions: {
model: "gpt-4o",
system: `You are a helpful AI assistant. You can help users with a variety of tasks including research and drafting content.
When users ask you to research something, use your available tools to search for information.
When users ask you to draft content, use your drafting tools to save documents.
Be concise but thorough in your responses.`,
tools: "research",
},
streamOptions: {
sendReasoning: false,
sendSources: false,
},
});import { Agent } from "./agent";
export const chatAgent = new Agent({
stepOptions: {
model: "gpt-4o",
system: `You are a helpful AI assistant. You can help users with a variety of tasks including research and drafting content.
When users ask you to research something, use your available tools to search for information.
When users ask you to draft content, use your drafting tools to save documents.
Be concise but thorough in your responses.`,
tools: "research",
},
streamOptions: {
sendReasoning: false,
sendSources: false,
},
});For specialized agents with provider options:
import { Agent } from "./agent";
export const researchAgent = new Agent({
stepOptions: {
model: "google/gemini-3-pro-preview",
system: "You are a research agent...",
tools: "research",
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: 8000,
includeThoughts: true,
},
},
},
},
streamOptions: {
sendReasoning: true,
sendSources: true,
},
});import { Agent } from "./agent";
export const researchAgent = new Agent({
stepOptions: {
model: "google/gemini-3-pro-preview",
system: "You are a research agent...",
tools: "research",
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: 8000,
includeThoughts: true,
},
},
},
},
streamOptions: {
sendReasoning: true,
sendSources: true,
},
});Running in a Workflow
import { getWorkflowMetadata, getWritable } from "workflow";
import { chatAgent } from "@/lib/ai/chat-agent";
export async function chatWorkflow({ chatId, userMessage }) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
// Persist user message, create assistant placeholder
await persistUserMessage({ chatId, message: userMessage });
const messageId = await createAssistantMessage({
chatId,
runId: workflowRunId,
});
// Get full message history
const history = await getMessageHistory(chatId);
// Run the agent with streaming
const { parts } = await chatAgent.run(history, {
maxSteps: 10,
writable: getWritable(),
});
// Persist the assistant message parts
await persistMessageParts({ chatId, messageId, parts });
// Clear the runId to mark the message as complete
await removeRunId(messageId);
}import { getWorkflowMetadata, getWritable } from "workflow";
import { chatAgent } from "@/lib/ai/chat-agent";
export async function chatWorkflow({ chatId, userMessage }) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
// Persist user message, create assistant placeholder
await persistUserMessage({ chatId, message: userMessage });
const messageId = await createAssistantMessage({
chatId,
runId: workflowRunId,
});
// Get full message history
const history = await getMessageHistory(chatId);
// Run the agent with streaming
const { parts } = await chatAgent.run(history, {
maxSteps: 10,
writable: getWritable(),
});
// Persist the assistant message parts
await persistMessageParts({ chatId, messageId, parts });
// Clear the runId to mark the message as complete
await removeRunId(messageId);
}Running Outside a Workflow
The agent works outside workflows too - useful for testing or non-durable contexts:
import { chatAgent } from "@/lib/ai/chat-agent";
// Option 1: Just get the parts (no streaming)
const { parts, stepCount } = await chatAgent.run(history);
console.log(`Completed in ${stepCount} steps`);
// Option 2: Stream to a custom writable
const chunks: UIMessageChunk[] = [];
const writable = new WritableStream({
write(chunk) {
chunks.push(chunk);
if (chunk.type === "text-delta") {
process.stdout.write(chunk.textDelta);
}
},
});
await chatAgent.run(history, { writable });import { chatAgent } from "@/lib/ai/chat-agent";
// Option 1: Just get the parts (no streaming)
const { parts, stepCount } = await chatAgent.run(history);
console.log(`Completed in ${stepCount} steps`);
// Option 2: Stream to a custom writable
const chunks: UIMessageChunk[] = [];
const writable = new WritableStream({
write(chunk) {
chunks.push(chunk);
if (chunk.type === "text-delta") {
process.stdout.write(chunk.textDelta);
}
},
});
await chatAgent.run(history, { writable });When to Use Each
Use DurableAgent for most use cases - it's simpler and provides inline tool definitions, callbacks, and prepareStep.
Use this custom agent when you need sendReasoning/sendSources streaming or direct UIMessage format for persistence.
Key Implementation Details
Why Tools Are Referenced by Key
Workflow runtimes serialize step inputs/outputs. Function references can't be serialized, so tools are stored in a toolSets object and looked up by key inside the step executor:
const toolSets = {
research: researchTools,
drafting: draftingTools,
} as const;
// Inside executeAgentStep:
const tools = toolSets[config.stepOptions.tools];const toolSets = {
research: researchTools,
drafting: draftingTools,
} as const;
// Inside executeAgentStep:
const tools = toolSets[config.stepOptions.tools];Why the Step Executor Is Separate
The "use step" directive only works in standalone functions, not class methods. The step executor is extracted from the class:
// This works:
async function executeAgentStep(...) {
"use step";
// ...
}
// This does NOT work:
class Agent {
async executeStep(...) {
"use step"; // Error: directive not supported in methods
}
}// This works:
async function executeAgentStep(...) {
"use step";
// ...
}
// This does NOT work:
class Agent {
async executeStep(...) {
"use step"; // Error: directive not supported in methods
}
}Tool Loop Logic
The agent continues executing steps until the model stops making tool calls:
while (shouldContinue && stepCount < maxSteps) {
const result = await executeAgentStep(modelMessages, stepConfig);
// ...
shouldContinue = result.finishReason === "tool-calls";
stepCount++;
}while (shouldContinue && stepCount < maxSteps) {
const result = await executeAgentStep(modelMessages, stepConfig);
// ...
shouldContinue = result.finishReason === "tool-calls";
stepCount++;
}See Also
Resumable AI Streams - Full recipe using this agent
DurableAgent Documentation - Built-in alternative
AI SDK streamText - Underlying streaming API
Workflow Best Practices
Create and run durable workflows with steps, streaming, and agent execution. Covers starting, resuming, and persisting workflow results.
Install this skill so your agent retains these patterns for day-to-day work:
bunx skills add andrelandgraf/fullstackrecipes/skills -s workflow-best-practicesbunx skills add andrelandgraf/fullstackrecipes/skills -s workflow-best-practices