workflow 5.0.0-beta.50 → 5.0.0-beta.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  title: Chat Session Modeling
3
- description: Model chat sessions at different architectural layers to control state ownership and handle interruptions.
3
+ description: Model WorkflowAgent chat sessions at different architectural layers to control state ownership and handle interruptions.
4
4
  type: guide
5
- summary: Choose between single-turn and multi-turn workflow patterns for managing chat session state.
5
+ summary: Choose between single-turn and multi-turn WorkflowAgent patterns for managing chat session state.
6
6
  prerequisites:
7
7
  - /docs/ai
8
8
  - /docs/foundations/workflows-and-steps
@@ -10,528 +10,278 @@ related:
10
10
  - /docs/ai/message-queueing
11
11
  - /docs/ai/resumable-streams
12
12
  - /docs/foundations/hooks
13
- - /docs/api-reference/workflow-ai/durable-agent
14
13
  - /docs/api-reference/workflow/define-hook
15
14
  ---
16
15
 
17
- <Callout type="warn">
18
- The examples below use the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The session-modeling patterns here (single- vs multi-turn, hooks, stream reconnection) apply to either API.
19
- </Callout>
20
-
21
- Chat sessions in AI agents can be modeled at different layers of your architecture. The choice affects state ownership and how you handle interruptions and reconnections.
16
+ Chat sessions can be modeled at different layers of your architecture. The choice determines who owns message history, how long a workflow run stays active, and how clients reconnect after an interruption.
22
17
 
23
- While there are many ways to model chat sessions, the two most common categories are single-turn and multi-turn.
18
+ Workflow 5 applications should use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for both patterns.
24
19
 
25
20
  ## Single-turn workflows
26
21
 
27
- Each user message triggers a new workflow run. The client or API route owns the conversation history and sends the full message array with each request.
22
+ Each user turn starts a new workflow run. The client or API owns conversation history and sends the complete `UIMessage[]` array with every request.
28
23
 
29
- <Tabs items={['Workflow', 'API Route', 'Client']}>
24
+ ### Workflow
30
25
 
31
- <Tab value="Workflow">
26
+ Convert UI messages to model messages inside the workflow. `WorkflowAgent` writes durable `ModelCallStreamPart` values to the run stream.
32
27
 
33
- ```typescript title="workflows/chat/index.ts" lineNumbers
34
- import { DurableAgent } from "@workflow/ai/agent";
28
+ ```typescript title="workflows/chat.ts" lineNumbers
29
+ import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
30
+ import { convertToModelMessages, type UIMessage } from "ai";
35
31
  import { getWritable } from "workflow";
36
- import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./steps/tools";
37
- import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
32
+ import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools";
38
33
 
39
34
  export async function chat(messages: UIMessage[]) {
40
35
  "use workflow";
41
36
 
42
- const writable = getWritable<UIMessageChunk>();
43
-
44
- const agent = new DurableAgent({
45
- model: "bedrock/claude-haiku-4-5-20251001-v1",
37
+ const agent = new WorkflowAgent({
38
+ model: "spacexai/grok-4.6",
46
39
  instructions: FLIGHT_ASSISTANT_PROMPT,
47
40
  tools: flightBookingTools,
48
41
  });
49
42
 
50
- await agent.stream({
51
- messages: await convertToModelMessages(messages), // [!code highlight] Full history from client
52
- writable,
43
+ return agent.stream({
44
+ messages: await convertToModelMessages(messages),
45
+ writable: getWritable<ModelCallStreamPart>(),
53
46
  });
54
47
  }
55
48
  ```
56
49
 
57
- </Tab>
50
+ ### API route
58
51
 
59
- <Tab value="API Route">
52
+ Convert the durable model-call stream to AI SDK UI chunks at the response boundary:
60
53
 
61
54
  ```typescript title="app/api/chat/route.ts" lineNumbers
55
+ import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
62
56
  import { createUIMessageStreamResponse, type UIMessage } from "ai";
63
57
  import { start } from "workflow/api";
64
58
  import { chat } from "@/workflows/chat";
65
59
 
66
- export async function POST(req: Request) {
67
- const { messages }: { messages: UIMessage[] } = await req.json();
68
-
69
- const run = await start(chat, [messages]); // [!code highlight]
60
+ export async function POST(request: Request) {
61
+ const { messages }: { messages: UIMessage[] } = await request.json();
62
+ const run = await start(chat, [messages]);
70
63
 
71
64
  return createUIMessageStreamResponse({
72
- stream: run.readable,
73
- headers: {
74
- "x-workflow-run-id": run.runId, // [!code highlight] For stream reconnection
75
- },
65
+ stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()),
66
+ headers: { "x-workflow-run-id": run.runId },
76
67
  });
77
68
  }
78
69
  ```
79
70
 
80
- </Tab>
81
-
82
- <Tab value="Client">
71
+ ### Client
83
72
 
84
- Chat messages need to be stored somewhere, typically a database. In this example, we assume a route like `/chats/:id` passes the session ID, allowing us to fetch existing messages and persist new ones.
73
+ `WorkflowChatTransport` reconnects when the HTTP connection ends before the workflow stream finishes:
85
74
 
86
- ```typescript title="app/chats/[id]/page.tsx" lineNumbers
75
+ ```tsx title="app/chat.tsx" lineNumbers
87
76
  "use client";
88
77
 
89
78
  import { useChat } from "@ai-sdk/react";
90
- import { WorkflowChatTransport } from "@ai-sdk/workflow"; // [!code highlight]
91
- import { useParams } from "next/navigation";
79
+ import { WorkflowChatTransport } from "@ai-sdk/workflow";
92
80
  import { useMemo } from "react";
93
81
 
94
- // Fetch existing messages from your backend
95
- async function getMessages(sessionId: string) { // [!code highlight]
96
- const res = await fetch(`/api/chats/${sessionId}/messages`); // [!code highlight]
97
- return res.json(); // [!code highlight]
98
- } // [!code highlight]
99
-
100
82
  export function Chat({ initialMessages }) {
101
- const { id: sessionId } = useParams<{ id: string }>();
102
-
103
- const transport = useMemo( // [!code highlight]
104
- () => // [!code highlight]
105
- new WorkflowChatTransport({ // [!code highlight]
106
- api: "/api/chat", // [!code highlight]
107
- onChatEnd: async () => { // [!code highlight]
108
- // Persist the updated messages to the chat session // [!code highlight]
109
- await fetch(`/api/chats/${sessionId}/messages`, { // [!code highlight]
110
- method: "PUT", // [!code highlight]
111
- headers: { "Content-Type": "application/json" }, // [!code highlight]
112
- body: JSON.stringify({ messages }), // [!code highlight]
113
- }); // [!code highlight]
114
- }, // [!code highlight]
115
- }), // [!code highlight]
116
- [sessionId] // [!code highlight]
117
- ); // [!code highlight]
118
-
119
- const { messages, input, handleInputChange, handleSubmit } = useChat({
120
- initialMessages, // [!code highlight] Loaded via getMessages(sessionId)
121
- transport, // [!code highlight]
83
+ const transport = useMemo(
84
+ () => new WorkflowChatTransport({ api: "/api/chat" }),
85
+ []
86
+ );
87
+ const { messages, sendMessage } = useChat({
88
+ messages: initialMessages,
89
+ transport,
122
90
  });
123
91
 
124
- return (
125
- <form onSubmit={handleSubmit}>
126
- {/* ... render messages ... */}
127
- <input value={input} onChange={handleInputChange} />
128
- </form>
129
- );
92
+ // Render messages and call sendMessage({ text }) from your form.
130
93
  }
131
94
  ```
132
95
 
133
- </Tab>
134
-
135
- </Tabs>
136
-
137
- This is the pattern used in the [Building Durable AI Agents](/docs/ai) guide.
138
-
139
- In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's `useChat`, and past turns persisted to a user-managed database.
96
+ Persist `UIMessage[]` in your application database. `WorkflowAgent.stream()` returns `ModelMessage[]`, but there is no general conversion from model messages back to UI messages with all UI metadata intact.
140
97
 
141
- Persist the turn through one of these methods:
98
+ Use the single-turn pattern when:
142
99
 
143
- - Run a workflow step after `agent.stream()` that takes the message history from the agent return value (either `messages: ModelMessage[]` or `uiMessages: UIMessage[]`).
144
- - Use a `useChat` client hook that calls an API to persist state, such as on every new message or in `onFinish`.
145
- - Use the resumable stream attached to the workflow (see [Resumable streams](/docs/ai/resumable-streams)). User messages are not persisted to the stream by default, so persist them separately.
100
+ - Your application already owns chat history
101
+ - Each turn should run on the latest deployment
102
+ - You want a simple request-to-run mapping
103
+ - Approval responses or client-side tool results arrive as another message turn
146
104
 
147
105
  ## Multi-turn workflows
148
106
 
149
- A single workflow handles the entire conversation session across multiple turns, and owns the current conversation state. The clients/API routes inject new messages via hooks. The workflow run ID serves as the session identifier.
107
+ A single workflow run can own the model-message history for the whole session. It waits on a Hook between turns, and external callers resume the Hook with the next message. The workflow run ID becomes the session identifier.
150
108
 
151
- For a full example of an agent using multi-turn workflows, check out the Flight Booking App example in the [Workflow Examples](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) repository.
152
-
153
- A key challenge in multi-turn workflows is ensuring user messages appear in the correct order when replaying the stream (e.g., after a page refresh). Since the stream primarily contains AI responses, user messages must be explicitly marked in the stream so the client can reconstruct the full conversation.
154
-
155
- <Tabs items={['Workflow', 'API Routes', 'Hook Definition', 'Client Hook']}>
156
-
157
- <Tab value="Workflow">
109
+ ```typescript title="workflows/chat-session.ts" lineNumbers
110
+ import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow";
111
+ import { type ModelMessage } from "ai";
112
+ import { defineHook, getWorkflowMetadata, getWritable } from "workflow";
113
+ import { z } from "zod";
114
+ import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools";
158
115
 
159
- ```typescript title="workflows/chat/index.ts" lineNumbers
160
- import {
161
- convertToModelMessages,
162
- type UIMessageChunk,
163
- type UIMessage,
164
- type ModelMessage,
165
- } from "ai";
166
- import { DurableAgent } from "@workflow/ai/agent";
167
- import { getWritable, getWorkflowMetadata } from "workflow";
168
- import { chatMessageHook } from "./hooks/chat-message";
169
- import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./steps/tools";
170
- import { writeUserMessageMarker, writeStreamClose } from "./steps/writer"; // [!code highlight]
116
+ export const chatMessageHook = defineHook({
117
+ schema: z.object({ message: z.string() }),
118
+ });
171
119
 
172
- export async function chat(initialMessages: UIMessage[]) {
120
+ export async function chatSession(initialMessages: ModelMessage[]) {
173
121
  "use workflow";
174
122
 
175
- const { workflowRunId: runId } = getWorkflowMetadata();
176
- const writable = getWritable<UIMessageChunk>();
177
- const messages: ModelMessage[] = await convertToModelMessages(initialMessages);
178
-
179
- // Write markers for initial user messages (for replay) // [!code highlight]
180
- for (const msg of initialMessages) { // [!code highlight]
181
- if (msg.role === "user") { // [!code highlight]
182
- const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(""); // [!code highlight]
183
- if (text) await writeUserMessageMarker(writable, text, msg.id); // [!code highlight]
184
- } // [!code highlight]
185
- } // [!code highlight]
123
+ const { workflowRunId } = getWorkflowMetadata();
124
+ const hook = chatMessageHook.create({ token: workflowRunId });
125
+ const writable = getWritable<ModelCallStreamPart>();
126
+ let messages = [...initialMessages];
186
127
 
187
- const agent = new DurableAgent({
188
- model: "bedrock/claude-haiku-4-5-20251001-v1",
128
+ const agent = new WorkflowAgent({
129
+ model: "spacexai/grok-4.6",
189
130
  instructions: FLIGHT_ASSISTANT_PROMPT,
190
131
  tools: flightBookingTools,
191
132
  });
192
133
 
193
- // Use run ID as the hook token for resumption
194
- const hook = chatMessageHook.create({ token: runId });
195
- let turnNumber = 0;
196
-
197
- while (true) {
198
- turnNumber++;
134
+ const maxTurns = 100;
135
+ for (let turn = 0; turn < maxTurns; turn++) {
199
136
  const result = await agent.stream({
200
137
  messages,
201
138
  writable,
202
- preventClose: true, // [!code highlight] Keep stream open for follow-ups
203
- sendStart: turnNumber === 1,
139
+ preventClose: true,
204
140
  sendFinish: false,
205
141
  });
206
- messages.push(...result.messages.slice(messages.length));
142
+ messages = result.messages;
207
143
 
208
- // Wait for next user message via hook
209
- const { message: followUp } = await hook;
210
- if (followUp === "/done") break;
144
+ // Do not accept a follow-up that this run has no remaining turn to process.
145
+ if (turn === maxTurns - 1) break;
211
146
 
212
- // Write marker and add to messages // [!code highlight]
213
- const followUpId = `user-${runId}-${turnNumber}`; // [!code highlight]
214
- await writeUserMessageMarker(writable, followUp, followUpId); // [!code highlight]
215
- messages.push({ role: "user", content: followUp });
147
+ const { message } = await hook;
148
+ if (message === "/done") break;
149
+ messages = [...messages, { role: "user", content: message }];
216
150
  }
217
151
 
218
- await writeStreamClose(writable); // [!code highlight]
219
152
  return { messages };
220
153
  }
221
154
  ```
222
155
 
223
- The `writeUserMessageMarker` helper writes a `data-workflow` chunk to mark user turns:
224
-
225
- ```typescript title="workflows/chat/steps/writer.ts" lineNumbers
226
- import type { UIMessageChunk } from "ai";
227
-
228
- export async function writeUserMessageMarker( // [!code highlight]
229
- writable: WritableStream<UIMessageChunk>,
230
- content: string,
231
- messageId: string
232
- ) {
233
- "use step"; // [!code highlight]
234
- const writer = writable.getWriter();
235
- try {
236
- await writer.write({
237
- type: "data-workflow", // [!code highlight]
238
- data: { type: "user-message", id: messageId, content, timestamp: Date.now() }, // [!code highlight]
239
- } as UIMessageChunk);
240
- } finally {
241
- writer.releaseLock();
242
- }
243
- }
244
-
245
- export async function writeStreamClose(writable: WritableStream<UIMessageChunk>) {
246
- const writer = writable.getWriter();
247
- await writer.write({ type: "finish" });
248
- await writer.close();
249
- }
250
- ```
251
-
252
- </Tab>
156
+ Create the Hook once, outside the loop. Recreating the same token on every turn causes a Hook conflict. Intermediate turns use `preventClose: true` with `sendFinish: false`; the workflow closes the durable stream and emits the final UI `finish` when the run returns.
253
157
 
254
- <Tab value="API Routes">
158
+ ### Start and resume the session
255
159
 
256
- Use three endpoints to start a session, send follow-up messages, and reconnect to the stream.
160
+ Use one route to create the run and a second route to deliver follow-up messages:
257
161
 
258
162
  ```typescript title="app/api/chat/route.ts" lineNumbers
259
- import { createUIMessageStreamResponse, type UIMessage } from "ai";
163
+ import { convertToModelMessages, type UIMessage } from "ai";
260
164
  import { start } from "workflow/api";
261
- import { chat } from "@/workflows/chat";
262
-
263
- export async function POST(req: Request) {
264
- const { initialMessage }: { initialMessage: UIMessage } = await req.json();
265
-
266
- const run = await start(chat, [[initialMessage]]); // [!code highlight]
165
+ import { chatSession } from "@/workflows/chat-session";
267
166
 
268
- return createUIMessageStreamResponse({
269
- stream: run.readable,
270
- headers: {
271
- "x-workflow-run-id": run.runId, // [!code highlight] For follow-ups and reconnection
272
- },
273
- });
167
+ export async function POST(request: Request) {
168
+ const { messages }: { messages: UIMessage[] } = await request.json();
169
+ const run = await start(chatSession, [await convertToModelMessages(messages)]);
170
+ return Response.json({ runId: run.runId });
274
171
  }
275
172
  ```
276
173
 
277
- ```typescript title="app/api/chat/[id]/route.ts" lineNumbers
278
- import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";
174
+ ```typescript title="app/api/chat/[runId]/message/route.ts" lineNumbers
175
+ import { chatMessageHook } from "@/workflows/chat-session";
279
176
 
280
177
  export async function POST(
281
- req: Request,
282
- { params }: { params: Promise<{ id: string }> }
178
+ request: Request,
179
+ { params }: { params: Promise<{ runId: string }> }
283
180
  ) {
284
- const { id: runId } = await params;
285
- const { message } = await req.json();
286
-
287
- // Resume the hook using the workflow run ID // [!code highlight]
288
- await chatMessageHook.resume(runId, { message }); // [!code highlight]
289
-
181
+ const { runId } = await params;
182
+ const { message }: { message: string } = await request.json();
183
+ await chatMessageHook.resume(runId, { message });
290
184
  return Response.json({ success: true });
291
185
  }
292
186
  ```
293
187
 
294
- ```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers
188
+ ### Reconnect to the stream
189
+
190
+ `WorkflowChatTransport` counts transformed UI chunks, while the durable stream stores raw `ModelCallStreamPart` values. Always replay the raw stream from index `0` and apply the UI cursor in `createModelCallToUIChunkTransform()`:
191
+
192
+ {/* @skip-typecheck: requires AI SDK 7 and @ai-sdk/workflow */}
193
+ ```typescript title="app/api/chat/[runId]/stream/route.ts" lineNumbers
194
+ import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow";
295
195
  import { createUIMessageStreamResponse } from "ai";
296
196
  import { getRun } from "workflow/api";
297
197
 
298
198
  export async function GET(
299
199
  request: Request,
300
- { params }: { params: Promise<{ id: string }> }
200
+ { params }: { params: Promise<{ runId: string }> }
301
201
  ) {
302
- const { id } = await params;
303
- const { searchParams } = new URL(request.url);
304
- const startIndex = searchParams.get("startIndex");
305
-
306
- const run = getRun(id); // [!code highlight]
307
- const stream = run.getReadable({ // [!code highlight]
308
- startIndex: startIndex ? parseInt(startIndex, 10) : undefined, // [!code highlight]
309
- }); // [!code highlight]
310
-
311
- return createUIMessageStreamResponse({ stream });
312
- }
313
- ```
314
-
315
- </Tab>
316
-
317
- <Tab value="Hook Definition">
318
-
319
- ```typescript title="workflows/chat/hooks/chat-message.ts" lineNumbers
320
- import { defineHook } from "workflow";
321
- import { z } from "zod";
322
-
323
- export const chatMessageHook = defineHook({
324
- schema: z.object({
325
- message: z.string(),
326
- }),
327
- });
328
- ```
329
-
330
- </Tab>
331
-
332
- <Tab value="Client Hook">
333
-
334
- A custom hook wraps `useChat` to manage the multi-turn session. It handles:
335
-
336
- - Routing between the initial message endpoint and follow-up endpoint
337
- - Reconstructing user messages from stream markers for correct ordering on replay
338
-
339
- ```typescript title="hooks/use-multi-turn-chat.ts" lineNumbers
340
- "use client";
202
+ const { runId } = await params;
203
+ const startIndex = Number(
204
+ new URL(request.url).searchParams.get("startIndex") ?? "0"
205
+ );
341
206
 
342
- import type { UIMessage, UIDataTypes, ChatStatus } from "ai";
343
- import { useChat } from "@ai-sdk/react";
344
- import { WorkflowChatTransport } from "@ai-sdk/workflow";
345
- import { useState, useCallback, useMemo, useEffect, useRef } from "react";
207
+ if (!Number.isSafeInteger(startIndex) || startIndex < 0) {
208
+ return Response.json(
209
+ { error: "startIndex must be a non-negative safe integer" },
210
+ { status: 400 }
211
+ );
212
+ }
346
213
 
347
- const STORAGE_KEY = "workflow-run-id";
214
+ const run = getRun(runId);
215
+ const stream = run
216
+ .getReadable({ startIndex: 0 })
217
+ .pipeThrough(createModelCallToUIChunkTransform({ uiStartIndex: startIndex }));
348
218
 
349
- interface UserMessageData {
350
- type: "user-message";
351
- id: string;
352
- content: string;
353
- timestamp: number;
219
+ return createUIMessageStreamResponse({
220
+ stream,
221
+ headers: { "x-workflow-run-id": runId },
222
+ });
354
223
  }
224
+ ```
355
225
 
356
- export function useMultiTurnChat() {
357
- const [runId, setRunId] = useState<string | null>(null);
358
- const [shouldResume, setShouldResume] = useState(false);
359
- const userMessagesRef = useRef<Map<string, UIMessage>>(new Map());
360
-
361
- // Check for existing session on mount // [!code highlight]
362
- useEffect(() => {
363
- const storedRunId = localStorage.getItem(STORAGE_KEY);
364
- if (storedRunId) {
365
- setRunId(storedRunId);
366
- setShouldResume(true);
367
- }
368
- }, []);
369
-
370
- const transport = useMemo(
371
- () =>
372
- new WorkflowChatTransport({
373
- api: "/api/chat",
374
- onChatSendMessage: (response) => {
375
- const workflowRunId = response.headers.get("x-workflow-run-id");
376
- if (workflowRunId) {
377
- setRunId(workflowRunId);
378
- localStorage.setItem(STORAGE_KEY, workflowRunId);
379
- }
380
- },
381
- onChatEnd: () => {
382
- setRunId(null);
383
- localStorage.removeItem(STORAGE_KEY);
384
- userMessagesRef.current.clear();
385
- },
386
- prepareReconnectToStreamRequest: ({ api, ...rest }) => {
387
- const storedRunId = localStorage.getItem(STORAGE_KEY);
388
- if (!storedRunId) throw new Error("No active session");
389
- return { ...rest, api: `/api/chat/${storedRunId}/stream` };
390
- },
391
- }),
392
- []
393
- );
226
+ Persist `UIMessage[]` separately if the client must reconstruct user messages and UI metadata after a refresh. The workflow-owned `ModelMessage[]` history is the model's durable context, not a replacement for an application chat table.
394
227
 
395
- const { messages: rawMessages, sendMessage: baseSendMessage, status, stop, setMessages } =
396
- useChat({ resume: shouldResume, transport });
397
-
398
- // Reconstruct conversation order from stream markers // [!code highlight]
399
- const messages = useMemo(() => { // [!code highlight]
400
- const result: UIMessage[] = []; // [!code highlight]
401
- const seenContent = new Set<string>(); // [!code highlight]
402
- // [!code highlight]
403
- // Collect content from optimistic user messages // [!code highlight]
404
- for (const msg of rawMessages) { // [!code highlight]
405
- if (msg.role === "user") { // [!code highlight]
406
- const text = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(""); // [!code highlight]
407
- if (text) seenContent.add(text); // [!code highlight]
408
- } // [!code highlight]
409
- } // [!code highlight]
410
- // [!code highlight]
411
- for (const msg of rawMessages) { // [!code highlight]
412
- if (msg.role === "user") { // [!code highlight]
413
- result.push(msg); // [!code highlight]
414
- continue; // [!code highlight]
415
- } // [!code highlight]
416
- // [!code highlight]
417
- if (msg.role === "assistant") { // [!code highlight]
418
- // Process parts in order, splitting on user-message markers // [!code highlight]
419
- let currentParts: typeof msg.parts = []; // [!code highlight]
420
- let partIndex = 0; // [!code highlight]
421
- // [!code highlight]
422
- for (const part of msg.parts) { // [!code highlight]
423
- if (part.type === "data-workflow" && "data" in part) { // [!code highlight]
424
- const data = part.data as UserMessageData; // [!code highlight]
425
- if (data?.type === "user-message") { // [!code highlight]
426
- // Flush accumulated assistant parts // [!code highlight]
427
- if (currentParts.length > 0) { // [!code highlight]
428
- result.push({ ...msg, id: `${msg.id}-${partIndex++}`, parts: currentParts }); // [!code highlight]
429
- currentParts = []; // [!code highlight]
430
- } // [!code highlight]
431
- // Add user message if not duplicate // [!code highlight]
432
- if (!seenContent.has(data.content)) { // [!code highlight]
433
- seenContent.add(data.content); // [!code highlight]
434
- result.push({ id: data.id, role: "user", parts: [{ type: "text", text: data.content }] }); // [!code highlight]
435
- } // [!code highlight]
436
- continue; // [!code highlight]
437
- } // [!code highlight]
438
- } // [!code highlight]
439
- currentParts.push(part); // [!code highlight]
440
- } // [!code highlight]
441
- // [!code highlight]
442
- if (currentParts.length > 0) { // [!code highlight]
443
- result.push({ ...msg, id: partIndex > 0 ? `${msg.id}-${partIndex}` : msg.id, parts: currentParts }); // [!code highlight]
444
- } // [!code highlight]
445
- } // [!code highlight]
446
- } // [!code highlight]
447
- return result; // [!code highlight]
448
- }, [rawMessages]); // [!code highlight]
449
-
450
- // Route messages to appropriate endpoint
451
- const sendMessage = useCallback(
452
- async (text: string) => {
453
- if (runId) {
454
- // Follow-up: send via hook resumption // [!code highlight]
455
- await fetch(`/api/chat/${runId}`, {
456
- method: "POST",
457
- headers: { "Content-Type": "application/json" },
458
- body: JSON.stringify({ message: text }),
459
- });
460
- } else {
461
- // First message: start new workflow
462
- await baseSendMessage({ text, metadata: { createdAt: Date.now() } });
463
- }
464
- },
465
- [runId, baseSendMessage]
466
- );
228
+ ### Persist and display the full session
467
229
 
468
- const endSession = useCallback(async () => {
469
- if (runId) {
470
- await fetch(`/api/chat/${runId}`, {
471
- method: "POST",
472
- headers: { "Content-Type": "application/json" },
473
- body: JSON.stringify({ message: "/done" }),
474
- });
475
- }
476
- setRunId(null);
477
- setShouldResume(false);
478
- localStorage.removeItem(STORAGE_KEY);
479
- userMessagesRef.current.clear();
480
- setMessages([]);
481
- }, [runId, setMessages]);
482
-
483
- return { messages, status, runId, sendMessage, endSession, stop };
484
- }
485
- ```
230
+ A multi-turn chat has three related records with different responsibilities:
486
231
 
487
- </Tab>
232
+ 1. **Workflow model history**: `ModelMessage[]` is the durable context sent back to the model on each turn.
233
+ 2. **Workflow run stream**: `ModelCallStreamPart` values contain durable model and tool output for live delivery and reconnection.
234
+ 3. **Application chat history**: `UIMessage[]` preserves user messages, display metadata, attachments, and application-specific parts.
488
235
 
489
- </Tabs>
236
+ When the user sends the first message, start the run, store its run ID with the application chat record, and connect to the run stream. For each follow-up, optimistically add and persist the user `UIMessage`, then resume `chatMessageHook` with the corresponding text. After a refresh, load the persisted UI messages and reconnect through the stream route above using the last persisted UI chunk cursor. If you persist only user messages, replay model output from the beginning and merge by stable message IDs.
490
237
 
491
- In this pattern, the workflow owns the entire conversation session. All messages are persisted in the workflow, and follow-up messages are injected via hooks. The workflow writes **user message markers** to the stream using `data-workflow` chunks, which allows the client to reconstruct the full conversation in the correct order when replaying the stream (e.g., after a page refresh).
238
+ Do not treat the run stream as the only chat database. `WorkflowAgent` deliberately stores raw model-call parts, and user messages resumed through a Hook are not automatically written to that output stream. Keeping the application history separate avoids synthetic stream markers and preserves UI information that cannot be reconstructed from `ModelMessage[]`.
492
239
 
493
- The client hook processes these markers by:
240
+ <Callout type="info">
241
+ The reconnect route replays raw `ModelCallStreamPart` values from index `0` because raw parts and transformed UI chunks do not have matching indexes. `uiStartIndex` prevents already-delivered UI chunks from being sent to the client again, but the server still transforms the earlier raw history. For very long streams, split conversations into bounded runs until WorkflowAgent exposes a persisted raw-to-UI cursor mapping.
242
+ </Callout>
494
243
 
495
- 1. Iterate through message parts in order.
496
- 2. When a `user-message` marker is found, flush any accumulated assistant content and insert the user message.
497
- 3. Deduplicate against optimistic sends from the initial message.
244
+ Use the multi-turn pattern when:
498
245
 
499
- This ensures the conversation displays as User AI → User → AI regardless of whether viewing live or replaying from the stream.
246
+ - One workflow should own the session's model context
247
+ - Backend events or other users need to inject messages through Hooks
248
+ - Full-session tracing is more important than running every turn on the newest deployment
249
+ - The application is prepared to manage a long-lived run and stream cursor
500
250
 
501
251
  ## Choosing a pattern
502
252
 
503
- | Consideration | Single-Turn | Multi-Turn |
253
+ | Consideration | Single-turn | Multi-turn |
504
254
  |--------------|-------------|------------|
505
- | State ownership | Client or API route | Workflow |
506
- | Message injection from backend | Requires stitching together runs | Native via hooks |
255
+ | State ownership | Client or application database | Workflow for model context; application database for UI history |
256
+ | Deployment version | Latest deployment per turn | Deployment that started the run |
257
+ | Message injection | Start another run | Resume a Hook |
507
258
  | Workflow complexity | Lower | Higher |
508
- | Workflow time horizon | Minutes | Hours to indefinitely |
509
- | Observability scope | Per-turn traces | Full session traces |
259
+ | Workflow time horizon | One model turn | Hours or longer |
260
+ | Observability scope | Per turn | Full session |
510
261
 
511
- **Multi-turn is recommended for most production use cases.** For new applications, use multi-turn workflows. The workflow's built-in persistence maintains the chat history and supports native message injection and full-session observability.
262
+ **Multi-turn works well for new durable sessions.** The workflow owns model context, accepts messages from users and backend systems through the same Hook, and provides one full-session trace.
512
263
 
513
- **Single-turn works well when adapting existing architectures.** If you already have a system for managing message state and want to adopt durable agents incrementally, single-turn workflows require fewer changes. Each turn maps to an independent workflow run.
264
+ **Single-turn works well when adapting an existing architecture.** If the application already manages message state and you want to adopt durable agents incrementally, one workflow run per turn requires fewer lifecycle changes and always uses the latest deployment.
514
265
 
515
266
  ## Multiplayer chat sessions
516
267
 
517
- The multi-turn pattern also enables multiplayer chat sessions. Messages can come from system events, external services, and other users. A `hook` can inject messages into a workflow at any point, while clients reconnect to one stream containing the entire history.
268
+ The multi-turn pattern also supports messages from system events, external services, and multiple users. Every source resumes the same Hook; the workflow queues those messages and processes them between model turns.
518
269
 
519
- <Tabs items={['System Event', 'External Service', 'Multiple Users']}>
270
+ <Tabs items={['System event', 'External service', 'Multiple users']}>
520
271
 
521
- <Tab value="System Event">
272
+ <Tab value="System event">
522
273
 
523
- Internal system events like scheduled tasks, background jobs, or database triggers can inject updates into an active conversation.
274
+ Scheduled tasks, background jobs, or database triggers can inject updates into an active conversation:
524
275
 
525
276
  ```typescript title="app/api/internal/flight-update/route.ts" lineNumbers
526
- import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";
277
+ import { chatMessageHook } from "@/workflows/chat-session";
527
278
 
528
- // Called by your flight status monitoring system
529
- export async function POST(req: Request) {
530
- const { runId, flightNumber, newStatus } = await req.json();
279
+ export async function POST(request: Request) {
280
+ const { runId, flightNumber, newStatus } = await request.json();
531
281
 
532
- await chatMessageHook.resume(runId, { // [!code highlight]
533
- message: `[System] Flight ${flightNumber} status updated: ${newStatus}`, // [!code highlight]
534
- }); // [!code highlight]
282
+ await chatMessageHook.resume(runId, {
283
+ message: `[System] Flight ${flightNumber} status updated: ${newStatus}`,
284
+ });
535
285
 
536
286
  return Response.json({ success: true });
537
287
  }
@@ -539,20 +289,20 @@ export async function POST(req: Request) {
539
289
 
540
290
  </Tab>
541
291
 
542
- <Tab value="External Service">
292
+ <Tab value="External service">
543
293
 
544
- External webhooks from third-party services, such as Stripe and Twilio, can notify the conversation of events.
294
+ A third-party webhook can notify the conversation about an external event:
545
295
 
546
296
  ```typescript title="app/api/webhooks/payment/route.ts" lineNumbers
547
- import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";
297
+ import { chatMessageHook } from "@/workflows/chat-session";
548
298
 
549
- export async function POST(req: Request) {
550
- const { runId, paymentStatus, amount } = await req.json();
299
+ export async function POST(request: Request) {
300
+ const { runId, paymentStatus, amount } = await request.json();
551
301
 
552
302
  if (paymentStatus === "succeeded") {
553
- await chatMessageHook.resume(runId, { // [!code highlight]
554
- message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed!`, // [!code highlight]
555
- }); // [!code highlight]
303
+ await chatMessageHook.resume(runId, {
304
+ message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed.`,
305
+ });
556
306
  }
557
307
 
558
308
  return Response.json({ received: true });
@@ -561,38 +311,39 @@ export async function POST(req: Request) {
561
311
 
562
312
  </Tab>
563
313
 
564
- <Tab value="Multiple Users">
314
+ <Tab value="Multiple users">
565
315
 
566
- Multiple human users can participate in the same conversation. Each user's client connects to the same workflow stream.
316
+ Multiple authenticated users can participate in the same workflow-owned session. Include attribution when resuming the Hook:
567
317
 
568
- ```typescript title="app/api/chat/[id]/route.ts" lineNumbers
569
- import { chatMessageHook } from "@/workflows/chat/hooks/chat-message";
318
+ ```typescript title="app/api/chat/[runId]/message/route.ts" lineNumbers
319
+ import { chatMessageHook } from "@/workflows/chat-session";
570
320
  import { getUser } from "@/lib/auth";
571
321
 
572
322
  export async function POST(
573
- req: Request,
574
- { params }: { params: Promise<{ id: string }> }
323
+ request: Request,
324
+ { params }: { params: Promise<{ runId: string }> }
575
325
  ) {
576
- const { id: runId } = await params;
577
- const { message } = await req.json();
578
- const user = await getUser(req); // [!code highlight]
326
+ const { runId } = await params;
327
+ const { message } = await request.json();
328
+ const user = await getUser(request);
579
329
 
580
- // Inject message with user attribution // [!code highlight]
581
- await chatMessageHook.resume(runId, { // [!code highlight]
582
- message: `[${user.name}] ${message}`, // [!code highlight]
583
- }); // [!code highlight]
330
+ await chatMessageHook.resume(runId, {
331
+ message: `[${user.name}] ${message}`,
332
+ });
584
333
 
585
334
  return Response.json({ success: true });
586
335
  }
587
336
  ```
588
337
 
338
+ To preserve structured attribution across refreshes, persist the corresponding `UIMessage` using the application-history approach described above.
339
+
589
340
  </Tab>
590
341
 
591
342
  </Tabs>
592
343
 
593
344
  ## Related documentation
594
345
 
595
- - [Building Durable AI Agents](/docs/ai): Foundation guide for durable agents
596
- - [Message Queueing](/docs/ai/message-queueing): Queueing messages during tool execution
597
- - [`defineHook()` API reference](/docs/api-reference/workflow/define-hook): Hook configuration options
346
+ - [Building Durable AI Agents](/docs/ai): Foundation guide for WorkflowAgent
347
+ - [Message Queueing](/docs/ai/message-queueing): Inject messages between model-call steps
348
+ - [Resumable Streams](/docs/ai/resumable-streams): Reconnect to durable output
598
349
  - [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK API for durable, resumable agents