workflow 5.0.0-beta.2 → 5.0.0-beta.4

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.
Files changed (42) hide show
  1. package/dist/api-workflow.d.ts +1 -1
  2. package/dist/api-workflow.d.ts.map +1 -1
  3. package/dist/api-workflow.js +2 -2
  4. package/docs/cookbook/{common-patterns → advanced}/child-workflows.mdx +1 -1
  5. package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
  6. package/docs/cookbook/advanced/meta.json +2 -3
  7. package/docs/cookbook/advanced/publishing-libraries.mdx +83 -26
  8. package/docs/cookbook/advanced/serializable-steps.mdx +15 -3
  9. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
  10. package/docs/cookbook/agent-patterns/durable-agent.mdx +50 -91
  11. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +148 -171
  12. package/docs/cookbook/agent-patterns/meta.json +1 -7
  13. package/docs/cookbook/common-patterns/batching.mdx +44 -118
  14. package/docs/cookbook/common-patterns/meta.json +4 -4
  15. package/docs/cookbook/common-patterns/saga.mdx +126 -31
  16. package/docs/cookbook/common-patterns/scheduling.mdx +70 -194
  17. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
  18. package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
  19. package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
  20. package/docs/cookbook/index.mdx +13 -16
  21. package/docs/cookbook/integrations/ai-sdk.mdx +296 -140
  22. package/docs/cookbook/integrations/chat-sdk.mdx +251 -151
  23. package/docs/cookbook/integrations/sandbox.mdx +469 -81
  24. package/docs/cookbook/meta.json +1 -1
  25. package/docs/foundations/index.mdx +0 -3
  26. package/docs/foundations/meta.json +0 -1
  27. package/docs/foundations/serialization.mdx +1 -1
  28. package/docs/foundations/starting-workflows.mdx +1 -1
  29. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +60 -8
  30. package/docs/migration-guides/migrating-from-inngest.mdx +38 -6
  31. package/docs/migration-guides/migrating-from-temporal.mdx +38 -4
  32. package/docs/migration-guides/migrating-from-trigger-dev.mdx +52 -11
  33. package/package.json +11 -11
  34. package/docs/cookbook/advanced/custom-serialization.mdx +0 -168
  35. package/docs/cookbook/advanced/durable-objects.mdx +0 -148
  36. package/docs/cookbook/advanced/isomorphic-packages.mdx +0 -145
  37. package/docs/cookbook/agent-patterns/stop-workflow.mdx +0 -216
  38. package/docs/cookbook/agent-patterns/tool-orchestration.mdx +0 -255
  39. package/docs/cookbook/agent-patterns/tool-streaming.mdx +0 -181
  40. package/docs/cookbook/common-patterns/content-router.mdx +0 -207
  41. package/docs/cookbook/common-patterns/fan-out.mdx +0 -208
  42. package/docs/foundations/common-patterns.mdx +0 -265
@@ -1,204 +1,360 @@
1
1
  ---
2
2
  title: AI SDK
3
- description: Use AI SDK model providers, tool calling, and streaming inside durable workflows.
3
+ description: Use AI SDK's streamText directly inside durable workflows for lower-level control over model calls and tool execution.
4
4
  type: guide
5
- summary: Turn any AI SDK model call into a retryable, observable workflow step with built-in streaming.
5
+ summary: Use streamText() inside a workflow for full control over model options, stop conditions, and output schemas — while tools remain durable steps.
6
6
  related:
7
7
  - /docs/ai
8
+ - /docs/ai/chat-session-modeling
8
9
  - /docs/ai/defining-tools
9
10
  - /docs/ai/resumable-streams
10
11
  - /docs/api-reference/workflow-ai/durable-agent
11
12
  ---
12
13
 
13
- Workflow SDK integrates with [AI SDK](https://ai-sdk.dev) through the `@workflow/ai` package. This turns your LLM calls and tool executions into durable, retryable steps with built-in streaming and observability.
14
+ [AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making those calls durable: the model request, the tool loop, and the multi-turn conversation all survive restarts and timeouts.
14
15
 
15
- ## What It Enables
16
+ For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points.
16
17
 
17
- - **Durable LLM calls** -- Model invocations become steps that survive crashes and cold starts
18
- - **Any model provider** -- Use OpenAI, Anthropic, Google, Bedrock, or any AI SDK-compatible provider through [Vercel Gateway](https://vercel.com/docs/gateway) or direct provider configuration
19
- - **Tool durability** -- Tool executions become steps with automatic retries and event logging
20
- - **Resumable streaming** -- Clients reconnect mid-stream without losing data
18
+ <Callout type="info">
19
+ For most agent use cases, prefer [`DurableAgent`](/cookbook/agent-patterns/durable-agent) which wraps [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and manages the tool loop automatically. This page covers using `streamText()` directly when you need lower-level control.
20
+ </Callout>
21
21
 
22
- ## When to Use
22
+ ## When to use streamText directly
23
23
 
24
- Use this integration when your application calls an LLM and needs:
24
+ Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `DurableAgent` when you need:
25
25
 
26
- - Reliability for long-running agent loops (multi-step tool calling)
27
- - Automatic retry on transient model API failures
28
- - Stream resumption after disconnects
29
- - Observability into each model call and tool execution
26
+ * **Custom stop conditions** [`stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions), [`prepareStep`](https://ai-sdk.dev/docs/ai-sdk-core/agents#prepare-step), or [`onStepFinish`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#on-step-finish) callbacks
27
+ * **Structured output** [`Output.object()`](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) or `Output.array()` alongside tool calling
28
+ * **Step-level callbacks** `onStepFinish` for logging, metrics, or branching logic
29
+ * **Provider options** per-step model switching, reasoning budgets, or custom [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options)
30
30
 
31
- ## DurableAgent with Model Providers
31
+ ## Multi-turn pattern
32
32
 
33
- The `DurableAgent` wraps AI SDK's streaming interface. Pass any model string supported by [Vercel Gateway](https://vercel.com/docs/gateway) or a provider-specific model ID.
33
+ One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run.
34
34
 
35
- ```typescript title="workflows/research.ts" lineNumbers
36
- import { DurableAgent } from "@workflow/ai/agent";
37
- import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
38
- import { getWritable } from "workflow";
39
- import z from "zod/v4";
35
+ <Tabs items={['Workflow', 'API Route', 'Client']}>
40
36
 
41
- async function searchWeb(input: { query: string }): Promise<{ results: string[] }> {
37
+ <Tab value="Workflow">
38
+
39
+ ```typescript title="workflows/support.ts" lineNumbers
40
+ import { streamText, stepCountIs } from "ai";
41
+ import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
42
+ import type { ModelMessage, UIMessageChunk } from "ai";
43
+ import { z } from "zod";
44
+
45
+ const MAX_TURNS = 20;
46
+
47
+ export const turnHook = defineHook({ // [!code highlight]
48
+ schema: z.object({ message: z.string() }),
49
+ });
50
+
51
+ async function lookupOrder({ orderId }: { orderId: string }) {
42
52
  "use step";
43
- const response = await fetch(
44
- `https://api.example.com/search?q=${encodeURIComponent(input.query)}`
45
- );
46
- const data = await response.json();
47
- return { results: data.items.map((item: { title: string }) => item.title) };
53
+ const res = await fetch(`https://api.store.com/orders/${orderId}`);
54
+ return res.json();
48
55
  }
49
56
 
50
- async function summarize(input: { text: string }): Promise<{ summary: string }> {
57
+ async function processRefund({ orderId, reason }: { orderId: string; reason: string }) {
51
58
  "use step";
52
- // Each step is individually retried on failure
53
- const response = await fetch("https://api.example.com/summarize", {
59
+ const res = await fetch("https://api.store.com/refunds", {
54
60
  method: "POST",
55
- body: JSON.stringify({ text: input.text }),
61
+ body: JSON.stringify({ orderId, reason }),
56
62
  });
57
- const data = await response.json();
58
- return { summary: data.summary };
63
+ return res.json();
59
64
  }
60
65
 
61
- export async function researchAgent(messages: UIMessage[]) {
66
+ const TOOLS = {
67
+ lookupOrder: {
68
+ description: "Look up an order by ID",
69
+ inputSchema: z.object({ orderId: z.string() }),
70
+ execute: lookupOrder,
71
+ },
72
+ processRefund: {
73
+ description: "Process a refund",
74
+ inputSchema: z.object({ orderId: z.string(), reason: z.string() }),
75
+ execute: processRefund,
76
+ },
77
+ };
78
+
79
+ // Per-turn step — streams one agent response to the durable writable // [!code highlight]
80
+ async function runTurn(messages: ModelMessage[]) {
81
+ "use step";
82
+
83
+ const result = streamText({
84
+ model: "anthropic/claude-haiku-4.5",
85
+ system: "You are a customer support agent.",
86
+ messages,
87
+ tools: TOOLS,
88
+ stopWhen: stepCountIs(8),
89
+ });
90
+
91
+ const writable = getWritable<UIMessageChunk>();
92
+ // preventClose keeps the durable writable open so the next turn can // write to it. Each turn still emits its own start + finish chunks.
93
+ await result.toUIMessageStream().pipeTo(writable, { preventClose: true }); // [!code highlight]
94
+
95
+ const response = await result.response;
96
+ return { responseMessages: response.messages };
97
+ }
98
+
99
+ export async function supportWorkflow(initialMessages: ModelMessage[]) {
62
100
  "use workflow";
63
101
 
64
- const agent = new DurableAgent({ // [!code highlight]
65
- model: "anthropic/claude-sonnet-4-20250514",
66
- instructions: "You are a research assistant. Search the web and summarize findings.",
67
- tools: {
68
- searchWeb: {
69
- description: "Search the web for information",
70
- inputSchema: z.object({
71
- query: z.string().describe("The search query"),
72
- }),
73
- execute: searchWeb,
74
- },
75
- summarize: {
76
- description: "Summarize a block of text",
77
- inputSchema: z.object({
78
- text: z.string().describe("The text to summarize"),
79
- }),
80
- execute: summarize,
81
- },
102
+ const { workflowRunId } = getWorkflowMetadata();
103
+ // Create the hook once, outside the loop — same token = HookConflictError // [!code highlight]
104
+ const hook = turnHook.create({ token: workflowRunId }); // [!code highlight]
105
+ let allMessages = initialMessages;
106
+
107
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
108
+ const { responseMessages } = await runTurn(allMessages);
109
+ allMessages = [...allMessages, ...responseMessages];
110
+
111
+ const { message } = await hook; // [!code highlight] suspend until next user message
112
+ if (message === "/done") break;
113
+
114
+ allMessages = [...allMessages, { role: "user", content: message }];
115
+ }
116
+
117
+ return { turns: MAX_TURNS };
118
+ }
119
+ ```
120
+
121
+ </Tab>
122
+
123
+ <Tab value="API Route">
124
+
125
+ One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
126
+
127
+ ```typescript title="app/api/support/route.ts" lineNumbers
128
+ import type { UIMessage, UIMessageChunk } from "ai";
129
+ import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
130
+ import { start, getRun } from "workflow/api";
131
+ import { supportWorkflow, turnHook } from "@/workflows/support";
132
+
133
+ // Pump the durable stream until this turn's `finish` chunk, then close // the HTTP response. The source reader is released (not cancelled) so the
134
+ // workflow's durable stream keeps flowing for the next turn.
135
+ function sliceUntilFinish( // [!code highlight]
136
+ source: ReadableStream<UIMessageChunk>
137
+ ): ReadableStream<UIMessageChunk> {
138
+ return new ReadableStream<UIMessageChunk>({
139
+ async start(controller) {
140
+ const reader = source.getReader();
141
+ try {
142
+ while (true) {
143
+ const { done, value } = await reader.read();
144
+ if (done) break;
145
+ controller.enqueue(value);
146
+ if (value.type === "finish") break; // [!code highlight]
147
+ }
148
+ controller.close();
149
+ } catch (e) {
150
+ controller.error(e);
151
+ } finally {
152
+ reader.releaseLock();
153
+ }
82
154
  },
83
155
  });
156
+ }
84
157
 
85
- const result = await agent.stream({ // [!code highlight]
86
- messages: await convertToModelMessages(messages),
87
- writable: getWritable<UIMessageChunk>(),
158
+ // `/done` exits the workflow without emitting chunks. Return a synthetic
159
+ // start+finish so useChat's lifecycle terminates cleanly.
160
+ function emptyTurnStream(): ReadableStream<UIMessageChunk> {
161
+ return new ReadableStream<UIMessageChunk>({
162
+ start(controller) {
163
+ controller.enqueue({ type: "start", messageId: crypto.randomUUID() });
164
+ controller.enqueue({ type: "finish" });
165
+ controller.close();
166
+ },
88
167
  });
168
+ }
89
169
 
90
- return { messages: result.messages };
170
+ export async function POST(req: Request) {
171
+ const { messages, runId }: { messages: UIMessage[]; runId?: string } =
172
+ await req.json();
173
+ const modelMessages = await convertToModelMessages(messages);
174
+
175
+ // Follow-up turn: resume hook, return stream starting AFTER the last turn // [!code highlight]
176
+ if (runId) {
177
+ try {
178
+ const run = getRun(runId);
179
+
180
+ // Snapshot tail before resuming so our slice only contains this turn // [!code highlight]
181
+ const probe = run.getReadable();
182
+ const tailIndex = await probe.getTailIndex();
183
+ await probe.cancel();
184
+
185
+ const lastUser = modelMessages.filter((m) => m.role === "user").at(-1);
186
+ const text =
187
+ typeof lastUser?.content === "string"
188
+ ? lastUser.content
189
+ : Array.isArray(lastUser?.content)
190
+ ? lastUser.content
191
+ .filter((p): p is { type: "text"; text: string } =>
192
+ "type" in p && p.type === "text"
193
+ )
194
+ .map((p) => p.text)
195
+ .join("")
196
+ : "";
197
+
198
+ await turnHook.resume(runId, { message: text }); // [!code highlight]
199
+
200
+ if (text === "/done") {
201
+ return createUIMessageStreamResponse({
202
+ stream: emptyTurnStream(),
203
+ headers: { "x-workflow-run-id": runId },
204
+ });
205
+ }
206
+
207
+ const stream = sliceUntilFinish(
208
+ run.getReadable({ startIndex: tailIndex + 1 }) // [!code highlight]
209
+ );
210
+
211
+ return createUIMessageStreamResponse({
212
+ stream,
213
+ headers: { "x-workflow-run-id": runId },
214
+ });
215
+ } catch (e: unknown) {
216
+ const msg = e instanceof Error ? e.message.toLowerCase() : "";
217
+ if (!msg.includes("not found") && !msg.includes("expired")) throw e;
218
+ // Stale runId — fall through to start fresh
219
+ }
220
+ }
221
+
222
+ // First turn: start a new workflow // [!code highlight]
223
+ const run = await start(supportWorkflow, [modelMessages]);
224
+ const stream = sliceUntilFinish(run.readable);
225
+
226
+ return createUIMessageStreamResponse({
227
+ stream,
228
+ headers: { "x-workflow-run-id": run.runId },
229
+ });
91
230
  }
92
231
  ```
93
232
 
94
- ### Using Different Providers
233
+ </Tab>
95
234
 
96
- #### Vercel Gateway (string model IDs)
235
+ <Tab value="Client">
97
236
 
98
- All string model IDs route through [Vercel Gateway](https://vercel.com/docs/gateway). Switch providers by changing the model string -- no other code changes required.
237
+ Store the `runId` in a ref and pass it in the body of every follow-up. `WorkflowChatTransport` forwards it for you.
99
238
 
100
- {/* @skip-typecheck - illustrative snippets with intentional redeclarations */}
101
- ```typescript
102
- // All string model IDs route through Vercel Gateway
103
- const agent = new DurableAgent({ model: "anthropic/claude-sonnet-4-20250514" });
104
- const agent = new DurableAgent({ model: "openai/gpt-4o" });
105
- const agent = new DurableAgent({ model: "google/gemini-2.5-pro" });
106
- const agent = new DurableAgent({ model: "bedrock/claude-haiku-4-5-20251001-v1" });
239
+ ```tsx title="components/support-chat.tsx" lineNumbers
240
+ "use client";
241
+
242
+ import { useChat } from "@ai-sdk/react";
243
+ import { WorkflowChatTransport } from "@workflow/ai";
244
+ import { useMemo, useRef, useState } from "react";
245
+
246
+ export function SupportChat() {
247
+ const [input, setInput] = useState("");
248
+ const runIdRef = useRef<string | null>(null); // [!code highlight]
249
+
250
+ const transport = useMemo(
251
+ () =>
252
+ new WorkflowChatTransport({
253
+ api: "/api/support",
254
+ prepareSendMessagesRequest: ({ messages, body }) => ({
255
+ body: { ...body, messages, runId: runIdRef.current }, // [!code highlight]
256
+ }),
257
+ onChatSendMessage: (response) => {
258
+ const id = response.headers.get("x-workflow-run-id");
259
+ if (id) runIdRef.current = id; // [!code highlight]
260
+ },
261
+ }),
262
+ []
263
+ );
264
+
265
+ const { messages, sendMessage, status } = useChat({ transport });
266
+ const busy = status === "streaming" || status === "submitted";
267
+
268
+ return (
269
+ <form
270
+ onSubmit={(e) => {
271
+ e.preventDefault();
272
+ if (busy || !input.trim()) return;
273
+ sendMessage({ text: input });
274
+ setInput("");
275
+ }}
276
+ >
277
+ {messages.map((m) => (
278
+ <div key={m.id}>{m.role}: {m.parts.map((p) => p.type === "text" ? p.text : "").join("")}</div>
279
+ ))}
280
+ <input value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
281
+ </form>
282
+ );
283
+ }
107
284
  ```
108
285
 
109
- #### Direct Provider Access
286
+ </Tab>
110
287
 
111
- Import from a provider package to bypass Gateway and connect to the provider directly.
288
+ </Tabs>
112
289
 
113
- ```typescript
114
- import { DurableAgent } from "@workflow/ai/agent";
115
- import { openai } from "@workflow/ai/openai";
290
+ ## How it works
116
291
 
117
- const agent = new DurableAgent({ model: openai("gpt-4o") });
118
- ```
292
+ 1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns.
293
+ 2. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`.
294
+ 3. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
295
+ 4. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing.
296
+ 5. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
297
+ 6. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".
298
+
299
+ ## Pitfalls
119
300
 
120
- ### Provider-Specific Options
301
+ Non-obvious correctness details worth knowing before adapting this pattern.
121
302
 
122
- Pass provider options for features like reasoning or extended thinking.
303
+ ### Snapshot `tailIndex` *before* resuming the hook
123
304
 
305
+ {/* @skip-typecheck - fragment referencing variables from the surrounding multi-turn pattern */}
124
306
  ```typescript
125
- const agent = new DurableAgent({
126
- model: "anthropic/claude-sonnet-4-20250514",
127
- providerOptions: {
128
- anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } },
129
- },
130
- // ...tools and instructions
131
- });
307
+ const tailIndex = await probe.getTailIndex(); // [!code highlight] FIRST
308
+ await probe.cancel();
309
+ await turnHook.resume(runId, { message: text }); // [!code highlight] THEN
310
+ const stream = run.getReadable({ startIndex: tailIndex + 1 });
132
311
  ```
133
312
 
134
- ## Tool Functions with Steps
313
+ Reversing the order races the workflow: by the time you read `tailIndex`, the next turn has already written its `start` chunk, and your `startIndex + 1` skips past it.
135
314
 
136
- Tool `execute` functions can optionally include steps by using the `"use step"` directive. When a tool is **not** a step, it runs inside the workflow context and can modify workflow state directly. When a tool **is** marked with `"use step"`, it becomes a durable step with:
315
+ ### Don't call `writable.close()` inside a workflow function
137
316
 
138
- - **Automatic retries** -- If a tool fails (network error, API timeout), the framework retries it
139
- - **Event logging** -- Inputs and outputs are recorded for observability and replay
140
- - **Idempotency** -- On replay after a crash, completed steps return their cached result
317
+ I/O operations like closing streams must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. When the workflow returns, the runtime closes the underlying writable for you.
141
318
 
142
- ```typescript
143
- async function bookFlight(input: {
144
- origin: string;
145
- destination: string;
146
- date: string;
147
- }): Promise<{ confirmationId: string }> {
148
- "use step";
149
- // This call is retried on transient failures and its result is persisted
150
- const response = await fetch("https://api.airline.com/book", {
151
- method: "POST",
152
- headers: { "Content-Type": "application/json" },
153
- body: JSON.stringify(input),
154
- });
155
- if (!response.ok) throw new Error(`Booking failed: ${response.status}`);
156
- return response.json();
157
- }
158
- ```
319
+ ### Don't use `TransformStream.terminate()` to slice the stream
159
320
 
160
- ## Resumable Streaming
321
+ A `TransformStream` with `controller.terminate()` on the `finish` chunk seems like the obvious fit for `sliceUntilFinish`, but throws `Invalid state: TransformStream has been terminated` when late-arriving chunks hit the transform callback. Manual pumping through a custom `ReadableStream` (as shown above) sidesteps the problem entirely.
161
322
 
162
- Use `WorkflowChatTransport` on the client to automatically reconnect to a workflow's stream if the connection drops.
323
+ ### Release the source reader, don't cancel it
163
324
 
164
- ```typescript title="app/api/chat/route.ts" lineNumbers
165
- import { createUIMessageStreamResponse } from "ai";
166
- import { start } from "workflow/api";
167
- import { researchAgent } from "@/workflows/research";
325
+ In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Cancelling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock just detaches our reader; the durable stream keeps flowing.
168
326
 
169
- export async function POST(request: Request) {
170
- const { messages } = await request.json();
171
- const run = await start(researchAgent, [messages]); // [!code highlight]
327
+ ### Handle stale `runId` gracefully
172
328
 
173
- return createUIMessageStreamResponse({
174
- stream: run.readable, // [!code highlight]
175
- headers: { "x-workflow-run-id": run.runId },
176
- });
177
- }
178
- ```
329
+ Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.
179
330
 
180
- ```typescript title="components/chat.tsx" lineNumbers
181
- "use client";
331
+ ## streamText vs DurableAgent
182
332
 
183
- import { useChat } from "@ai-sdk/react";
184
- import { WorkflowChatTransport } from "@workflow/ai";
333
+ | | `streamText()` | `DurableAgent` |
334
+ |---|---|---|
335
+ | **Tool loop** | AI SDK handles via `stopWhen` | DurableAgent handles internally |
336
+ | **LLM call durability** | Re-executes on replay | Each LLM call is a durable step |
337
+ | **Stop conditions** | `stopWhen`, `prepareStep` | `prepareStep` only |
338
+ | **Structured output** | `Output.object()`, `Output.array()` | Not available |
339
+ | **Step callbacks** | `onStepFinish`, `onChunk` | Not available |
340
+ | **Setup** | Manual stream piping | Automatic |
185
341
 
186
- export function Chat() {
187
- const chat = useChat({
188
- transport: new WorkflowChatTransport({ // [!code highlight]
189
- api: "/api/chat",
190
- }),
191
- });
342
+ Use `DurableAgent` for most agent use cases. Use `streamText` when you need the additional control.
192
343
 
193
- // Standard useChat usage -- reconnection is handled automatically
194
- return (
195
- <div>
196
- {chat.messages.map((m) => (
197
- <div key={m.id}>{m.content}</div>
198
- ))}
199
- </div>
200
- );
201
- }
202
- ```
344
+ ## Key APIs
345
+
346
+ **AI SDK** ([docs](https://ai-sdk.dev/docs))
347
+
348
+ * [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable
349
+ * [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools wrap `"use step"` functions so each tool call is replayed from the log, not re-executed
350
+ * [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn
351
+ * [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary
352
+ * [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client
353
+
354
+ **Workflow SDK**
203
355
 
204
- See [Resumable Streams](/docs/ai/resumable-streams) for advanced options like `startIndex` and `prepareReconnectToStreamRequest`.
356
+ * [`"use step"`](/docs/api-reference/workflow/use-step) makes tool executions durable
357
+ * [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
358
+ * [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
359
+ * [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
360
+ * [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns