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...
}