UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

649 lines (476 loc) • 23.9 kB
--- title: Streaming description: Stream data in real time to clients for progress updates and incremental content delivery. type: conceptual summary: Stream data to clients in real time without waiting for workflow completion. prerequisites: - /docs/foundations/workflows-and-steps related: - /docs/api-reference/workflow/get-writable - /docs/ai/resumable-streams --- Workflows can stream data to clients in real time without waiting for the entire workflow to complete. Clients can receive progress updates, AI-generated content, log messages, and other incremental data as workflows execute. ## Getting started with `getWritable()` Every workflow run has a default writable stream that steps can write to using [`getWritable()`](/docs/api-reference/workflow/get-writable). Data written to this stream becomes immediately available to clients consuming the workflow's output. ```typescript title="workflows/simple-streaming.ts" lineNumbers import { getWritable } from "workflow"; async function writeProgress(message: string) { "use step"; // Steps can write to the run's default stream const writable = getWritable<string>(); // [!code highlight] const writer = writable.getWriter(); await writer.write(message); writer.releaseLock(); } export async function simpleStreamingWorkflow() { "use workflow"; await writeProgress("Starting task..."); await writeProgress("Processing data..."); await writeProgress("Task complete!"); } ``` ### Consuming the stream Use the `Run` object's `readable` property to consume the stream from your API route: ```typescript title="app/api/stream/route.ts" lineNumbers import { start } from "workflow/api"; import { simpleStreamingWorkflow } from "./workflows/simple-streaming"; export async function POST() { const run = await start(simpleStreamingWorkflow); // Return the readable stream to the client return new Response(run.readable, { headers: { "Content-Type": "text/plain" } }); } ``` When a client makes a request to this endpoint, they'll receive each message as it's written, without waiting for the workflow to complete. ### Avoiding function timeouts after client disconnects On Vercel, `run.readable` and `run.getReadable()` reconnect to Workflow's stream storage while the workflow is still running. By default, a client disconnect does not terminate the Vercel Function serving the stream. If a user closes the page or stops the request, the function can therefore keep reconnecting until it reaches its maximum duration and fails with `FUNCTION_INVOCATION_TIMEOUT`. For streaming routes using the Node.js runtime, opt in to [request cancellation](https://vercel.com/docs/functions/functions-api-reference#enable-cancellation) in `vercel.json`: ```json filename="vercel.json" { "functions": { "app/api/stream/route.ts": { "supportsCancellation": true } } } ``` Replace the function path with the path or glob for your streaming route. When the downstream client disconnects, Vercel terminates the matching function invocation instead of leaving its stream reader running. The workflow run and its durable stream continue independently, so the client can reconnect through another route invocation later. <Callout type="warn"> Cancellation applies to every function matching the configured path or glob, even if the route does not listen to `request.signal`. Any other work in that invocation which is not wrapped in [`waitUntil`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#waituntil) or [`after`](https://nextjs.org/docs/app/api-reference/functions/after) can be lost. Only enable it for routes that are safe to terminate when their client disconnects. </Callout> This setting prevents abandoned stream readers from consuming the rest of a function invocation. It does not extend the function's maximum duration. An actively connected streaming response can still reach the configured limit, at which point the client should reconnect to the durable stream. ### Resuming streams from a specific point Use `run.getReadable({ startIndex })` to resume a stream from a specific position. This is useful for reconnecting after timeouts or network interruptions: ```typescript title="app/api/resume-stream/[runId]/route.ts" lineNumbers import { getRun } from "workflow/api"; export async function GET( request: Request, { params }: { params: Promise<{ runId: string }> } ) { const { runId } = await params; const { searchParams } = new URL(request.url); // Client provides the last chunk index they received const startIndexParam = searchParams.get("startIndex"); // [!code highlight] const startIndex = startIndexParam ? parseInt(startIndexParam, 10) : undefined; // [!code highlight] const run = getRun(runId); const stream = run.getReadable({ startIndex }); // [!code highlight] return new Response(stream, { headers: { "Content-Type": "text/plain" } }); } ``` Clients can reconnect and continue receiving data from where they left off instead of restarting from the beginning. `startIndex` also supports **negative values** to read relative to the end of the stream. For example, `startIndex: -5` starts 5 chunks before the current end. This is useful when you want to show the most recent output without reading the entire stream history. On an active (not-yet-closed) stream, the negative index resolves relative to the chunk count at connection time; any chunks written afterward are still delivered normally. {/* @skip-typecheck: incomplete code sample */} ```typescript // Read only the last 10 chunks const stream = run.getReadable({ startIndex: -10 }); ``` If the absolute value exceeds the total number of chunks, reading starts from the beginning (the value is clamped to 0). <Callout type="warn"> Because streams are live and continue receiving chunks, negative `startIndex` values resolve to different absolute positions on each call. Accurate pagination over a live stream requires cursor-based access, which is not yet supported. Keep this in mind when building clients that paginate over stream data. </Callout> ## Streams as data types [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) and [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) are standard Web Streams API types that Workflow SDK makes serializable. Workflow SDK lets you pass these standard types between functions while maintaining their streaming capabilities. Unlike regular values that are fully serialized to the [event log](/docs/how-it-works/event-sourcing), streams maintain their streaming capabilities when passed between functions. **Key properties:** - Stream references can be passed between workflow and step functions - Stream data flows directly without being stored in the event log - Streams preserve their state across workflow suspension points <Callout type="info"> **How streams persist across workflow suspensions** Persistent, resumable storage from the World implementation lets Workflow SDK streams maintain their state when workflows suspend and resume: - **Vercel deployments**: Streams are backed by a performant Redis-based stream - **Local development**: Stream chunks are stored in the filesystem </Callout> ### Passing streams as arguments Because streams are serializable data types, you don't need to use [`getWritable()`](/docs/api-reference/workflow/get-writable). You can pass your own streams as arguments from outside a workflow into steps. The following example passes a request body stream through a workflow to a step that processes it: ```typescript title="app/api/upload/route.ts" lineNumbers import { start } from "workflow/api"; import { streamProcessingWorkflow } from "./workflows/streaming"; export async function POST(request: Request) { // Streams can be passed as workflow arguments const run = await start(streamProcessingWorkflow, [request.body]); // [!code highlight] await run.returnValue; return Response.json({ status: "complete" }); } ``` ```typescript title="workflows/streaming.ts" lineNumbers export async function streamProcessingWorkflow( inputStream: ReadableStream<Uint8Array> // [!code highlight] ) { "use workflow"; // Workflow passes stream to step for processing const result = await processInputStream(inputStream); // [!code highlight] return { length: result.length }; } async function processInputStream(input: ReadableStream<Uint8Array>) { "use step"; // Step reads from the stream const chunks: Uint8Array[] = []; for await (const chunk of input) { chunks.push(chunk); } return Buffer.concat(chunks).toString("utf8"); } ``` ## Important limitation <Callout type="info"> **Streams cannot be used directly in workflow context** You cannot read from or write to streams directly within a workflow function. All stream operations must happen in step functions. </Callout> Workflow functions must be deterministic to support replay. Streams bypass the [event log](/docs/how-it-works/event-sourcing) for performance, so reading stream data in a workflow would break determinism because each replay could see different data. Requiring all stream operations to happen in steps ensures consistent behavior. For more on determinism and replay, see [Workflows and Steps](/docs/foundations/workflows-and-steps). ```typescript title="workflows/bad-example.ts" lineNumbers import { getWritable } from "workflow"; export async function badWorkflow() { "use workflow"; const writable = getWritable<string>(); // Cannot read/write streams in workflow context const writer = writable.getWriter(); // [!code highlight] await writer.write("data"); // [!code highlight] } ``` ```typescript title="workflows/good-example.ts" lineNumbers import { getWritable } from "workflow"; export async function goodWorkflow() { "use workflow"; // Delegate stream operations to steps await writeToStream("data"); } async function writeToStream(data: string) { "use step"; // Stream operations must happen in steps const writable = getWritable<string>(); const writer = writable.getWriter(); await writer.write(data); writer.releaseLock(); } ``` ## Namespaced streams Use `getWritable({ namespace: 'name' })` to create multiple independent streams for different types of data. This is useful when you want to separate logs, metrics, data outputs, or other distinct channels. ```typescript title="workflows/multi-stream.ts" lineNumbers import { getWritable } from "workflow"; type LogEntry = { level: string; message: string }; type MetricEntry = { cpu: number; memory: number }; async function writeLogs() { "use step"; const logs = getWritable<LogEntry>({ namespace: "logs" }); // [!code highlight] const writer = logs.getWriter(); await writer.write({ level: "info", message: "Task started" }); await writer.write({ level: "info", message: "Processing..." }); writer.releaseLock(); } async function writeMetrics() { "use step"; const metrics = getWritable<MetricEntry>({ namespace: "metrics" }); // [!code highlight] const writer = metrics.getWriter(); await writer.write({ cpu: 45, memory: 512 }); await writer.write({ cpu: 52, memory: 520 }); writer.releaseLock(); } async function closeStreams() { "use step"; await getWritable({ namespace: "logs" }).close(); await getWritable({ namespace: "metrics" }).close(); } export async function multiStreamWorkflow() { "use workflow"; await writeLogs(); await writeMetrics(); await closeStreams(); } ``` ### Consuming namespaced streams Use `run.getReadable({ namespace: 'name' })` to access specific streams: ```typescript title="app/api/multi-stream/route.ts" lineNumbers import { start } from "workflow/api"; import { multiStreamWorkflow } from "./workflows/multi"; type LogEntry = { level: string; message: string }; type MetricEntry = { cpu: number; memory: number }; export async function POST(request: Request) { const run = await start(multiStreamWorkflow); // Access specific named streams // [!code highlight] const logs = run.getReadable<LogEntry>({ namespace: "logs" }); // [!code highlight] const metrics = run.getReadable<MetricEntry>({ namespace: "metrics" }); // [!code highlight] // Return the logs stream to the client return new Response(logs, { headers: { "Content-Type": "application/json" } }); } ``` ## Writing to another run's stream `getRun(runId).getWritable()` appends to a stream owned by another run. This lets short-lived runs contribute to a long-lived holder run's stream using only its ID. ```typescript title="workflows/turn.ts" lineNumbers import { getRun } from "workflow/api"; type SessionEvent = { turn: number; text: string }; async function runTurn(holderRunId: string, turn: number) { "use step"; const writable = getRun(holderRunId).getWritable<SessionEvent>(); // [!code highlight] const writer = writable.getWriter(); await writer.write({ turn, text: "done" }); writer.releaseLock(); // [!code highlight] } ``` Pass `{ namespace: "name" }` to target a [namespaced stream](#namespaced-streams). The writable can also be forwarded through `start()` and into steps. <Callout type="warn"> Contributors should call `releaseLock()`, which flushes pending writes. Calling `close()` closes the shared stream for every writer. Calling `abort()` keeps the chunks already written and leaves the shared stream open. </Callout> The API grants append access, not read access or additional authorization. The owning run controls the stream's lifecycle, and writes to an unknown run fail. ## Common patterns ### Progress updates for long-running tasks Send incremental progress updates to keep users informed during lengthy workflows: ```typescript title="workflows/batch-processing.ts" lineNumbers import { getWritable, sleep } from "workflow"; type ProgressUpdate = { item: string; progress: number; status: string; }; async function processItem( item: string, current: number, total: number ) { "use step"; const writable = getWritable<ProgressUpdate>(); // [!code highlight] const writer = writable.getWriter(); // Simulate processing await new Promise(resolve => setTimeout(resolve, 1000)); // Send progress update // [!code highlight] await writer.write({ // [!code highlight] item, // [!code highlight] progress: Math.round((current / total) * 100), // [!code highlight] status: "processing" // [!code highlight] }); // [!code highlight] writer.releaseLock(); } async function finalizeProgress() { "use step"; await getWritable().close(); } export async function batchProcessingWorkflow(items: string[]) { "use workflow"; for (let i = 0; i < items.length; i++) { await processItem(items[i], i + 1, items.length); await sleep("1s"); } await finalizeProgress(); } ``` ### Streaming AI responses with `WorkflowAgent` Stream AI-generated content using AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) from `@ai-sdk/workflow`. The agent writes `ModelCallStreamPart` chunks to the workflow stream, and route handlers convert them to UI message chunks with `createModelCallToUIChunkTransform()` before returning the response: ```typescript title="workflows/ai-assistant.ts" lineNumbers import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow"; import { tool } from "ai"; import { getWritable } from "workflow"; import { z } from "zod"; async function searchFlights({ query }: { query: string }) { "use step"; // ... search logic ... return { flights: [/* results */] }; } export async function aiAssistantWorkflow(userMessage: string) { "use workflow"; const agent = new WorkflowAgent({ model: "spacexai/grok-4.6", instructions: "You are a helpful flight assistant.", tools: { searchFlights: tool({ description: "Search for flights", inputSchema: z.object({ query: z.string() }), execute: searchFlights, }), }, }); // LLM response will be streamed to the run's writable await agent.stream({ messages: [{ role: "user", content: userMessage }], writable: getWritable<ModelCallStreamPart>(), // [!code highlight] }); } ``` ```typescript title="app/api/ai-assistant/route.ts" lineNumbers import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow"; import { createUIMessageStreamResponse } from "ai"; import { start } from "workflow/api"; import { aiAssistantWorkflow } from "./workflows/ai"; export async function POST(request: Request) { const { message } = await request.json(); const run = await start(aiAssistantWorkflow, [message]); return createUIMessageStreamResponse({ stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), // [!code highlight] }); } ``` <Callout type="info"> For the full agent API and migration notes, see the [`WorkflowAgent` documentation](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). </Callout> ### Streaming between steps One step produces a stream and another step consumes it: ```typescript title="workflows/stream-pipeline.ts" lineNumbers export async function streamPipelineWorkflow() { "use workflow"; // Streams can be passed between steps const stream = await generateData(); // [!code highlight] const results = await consumeData(stream); // [!code highlight] return { count: results.length }; } async function generateData(): Promise<ReadableStream<number>> { "use step"; // Producer step creates a stream return new ReadableStream<number>({ start(controller) { for (let i = 0; i < 10; i++) { controller.enqueue(i); } controller.close(); } }); } async function consumeData(readable: ReadableStream<number>) { "use step"; // Consumer step reads from the stream const values: number[] = []; for await (const value of readable) { values.push(value); } return values; } ``` ### Processing large files without memory overhead Process large files by streaming chunks through transformation steps: ```typescript title="workflows/file-processing.ts" lineNumbers export async function fileProcessingWorkflow(fileUrl: string) { "use workflow"; // Chain streams through multiple processing steps const rawStream = await downloadFile(fileUrl); // [!code highlight] const processedStream = await transformData(rawStream); // [!code highlight] await uploadResult(processedStream); // [!code highlight] } async function downloadFile(url: string): Promise<ReadableStream<Uint8Array>> { "use step"; const response = await fetch(url); return response.body!; } async function transformData(input: ReadableStream<Uint8Array>): Promise<ReadableStream<Uint8Array>> { "use step"; // Transform stream chunks without loading entire file into memory return input.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({ transform(chunk, controller) { // Process each chunk individually controller.enqueue(chunk); } })); } async function uploadResult(stream: ReadableStream<Uint8Array>) { "use step"; await fetch("https://storage.example.com/upload", { method: "POST", body: stream, }); } ``` ## Best practices **Batching and first-chunk latency:** Writes are flushed immediately by default. The leading chunk of an idle stream dispatches as soon as it is written, and chunks arriving while a flush is in flight coalesce into the next batch. If you write bursts of many tiny chunks and prefer fewer round trips over first-chunk latency, set a group-commit window with the World's `streamFlushIntervalMs` option or the `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable. See [Worlds configuration](/docs/configuration/worlds#streamflushintervalms). **Release locks properly:** ```typescript lineNumbers const writer = writable.getWriter(); try { await writer.write(data); } finally { writer.releaseLock(); // Always release } ``` <Callout type="info"> Stream locks acquired in a step only apply within that step, not across other steps. This enables multiple writers to write to the same stream concurrently. </Callout> <Callout type="warn"> If a lock is not released, the invocation that ran the step cannot terminate. Even though the step returns and the workflow continues, the underlying request remains active until it times out and wastes compute resources. </Callout> **Close streams when done:** ```typescript lineNumbers import { getWritable } from "workflow"; async function finalizeStream() { "use step"; await getWritable().close(); // Signal completion } ``` Streams are automatically closed when the workflow run completes, but explicitly closing them signals completion to consumers earlier. **Use typed streams for type safety:** {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers const writable = getWritable<MyDataType>(); const writer = writable.getWriter(); await writer.write({ /* typed data */ }); ``` ## Stream failures When a step returns a stream, the step is considered successful once it returns, even if the stream later encounters an error. The workflow won't automatically retry the step. The consumer of the stream must handle errors gracefully. For more on retry behavior, see [Errors and Retries](/docs/foundations/errors-and-retries). ```typescript title="workflows/stream-error-handling.ts" lineNumbers import { FatalError } from "workflow"; async function produceStream(): Promise<ReadableStream<number>> { "use step"; // Step succeeds once it returns the stream return new ReadableStream<number>({ start(controller) { controller.enqueue(1); controller.enqueue(2); // Error occurs after step has completed // [!code highlight] controller.error(new Error("Stream failed")); // [!code highlight] } }); } async function consumeStream(stream: ReadableStream<number>) { "use step"; try { // [!code highlight] for await (const value of stream) { console.log(value); } } catch (error) { // [!code highlight] // Retrying won't help since the stream is already errored // [!code highlight] throw new FatalError("Stream failed"); // [!code highlight] } // [!code highlight] } export async function streamErrorWorkflow() { "use workflow"; const stream = await produceStream(); // Step succeeds // [!code highlight] await consumeStream(stream); // Consumer handles errors // [!code highlight] } ``` <Callout type="info"> Stream errors don't trigger automatic retries for the producer step. Design your stream consumers to handle errors. Because the stream is already in an errored state, retrying the consumer won't help. Use `FatalError` to fail the workflow immediately. </Callout> ## Related documentation - [`getWritable()` API reference](/docs/api-reference/workflow/get-writable): Get the workflow's writable stream - [`sleep()` API reference](/docs/api-reference/workflow/sleep): Pause workflow execution for a duration - [`start()` API reference](/docs/api-reference/workflow-api/start): Start workflows and access the `Run` object - [`getRun()` API reference](/docs/api-reference/workflow-api/get-run): Retrieve runs and their streams later - [`world.streams`](/docs/api-reference/workflow-runtime/world/streams): Use low-level stream read, write, and close operations through the World SDK - [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Build AI agents with durable, resumable streaming support - [Errors and retries](/docs/foundations/errors-and-retries): Understand error handling and retry behavior - [Serialization](/docs/foundations/serialization): Understand which data types you can pass in workflows - [Workflows and steps](/docs/foundations/workflows-and-steps): Learn the core concepts of workflow execution