Use AI SDK's streamText directly inside durable workflows when you need the raw AI SDK API or a per-turn durability boundary.
AI SDK 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 the multi-turn loop durable: the conversation state, hooks, and per-turn responses survive restarts and timeouts. Note that in this pattern the durability boundary is the entire turn — individual tool calls inside a turn are not durable on their own (see Pitfalls below).
For the full AI SDK reference (providers, streamText, generateObject, useChat, tool calling, etc.) see the AI SDK docs. This page covers the Workflow-specific integration points.
For most agent use cases, prefer DurableAgent, which implements the same agent loop as streamText, manages tool calling automatically, and runs tools at workflow scope — each tool can be marked "use step" for per-call durability and retries, or stay at workflow level to use primitives like sleep() and hooks. Use this page's raw streamText() pattern when you want the exact AI SDK API (for example toUIMessageStream(), onChunk, or generateText), or when the durability boundary should be an entire user turn in one step — accepting that tool calls inside that turn are not individually durable.
When to use streamText directly
Use streamText() instead of DurableAgent when you need:
- The raw AI SDK API —
streamText().toUIMessageStream(),onChunk,smoothStream, or other options that map directly to thestreamTextreturn value rather thanDurableAgent.stream() - Per-turn durability — wrap the entire agent response (model + tools) in a single
"use step"function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together - Custom multi-turn orchestration — manual hook loops, per-turn stream slicing (
sliceUntilFinish), or other workflow patterns shown below that don't map cleanly toDurableAgent
DurableAgent already supports stopWhen, prepareStep, onStepFinish, structured output (experimental_output), per-step model switching, and provider options. See the DurableAgent reference.
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.
Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see Versioning for the child-run continuation pattern.
- One workflow = one conversation. The workflow loops on a hook, keeping
allMessages, tool history, and state alive across turns. runTurnis the durability boundary. Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the wholerunTurnretries — individual tool calls are not separately durable. See Pitfalls.- Hook is created once.
turnHook.create({ token: workflowRunId })outside the loop — calling it twice with the same token throwsHookConflictError. preventClose: trueonpipeTokeeps the durable writable open so the next turn can write to it.sliceUntilFinishin the API reads chunks untiltype === "finish", then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing.startIndex: tailIndex + 1gives each follow-up response only the new chunks, avoiding replay of previous turns./doneresumes the hook so the workflow exits cleanly, then returns a syntheticstart+finishsouseChattransitions out of "streaming".
Non-obvious correctness details worth knowing before adapting this pattern.
Tools are not individually durable
streamText() is invoked from inside runTurn (a "use step" function), and the AI SDK calls each tool by directly invoking its execute function in that same step. Even if a tool body has its own "use step" directive, that directive is a no-op when called from another step — the function just runs inline.
The consequences:
- The atomic retry unit is the entire
runTurn, not the individual tool call. - If
processRefundsucceeds and then the model call (or a later tool) throws, the whole turn retries, andprocessRefundwill run again. - Tool calls do not appear as separate entries in the event log or observability dashboard.
Mitigations:
- Make side-effectful tool implementations idempotent — dedupe server-side on a stable key (e.g.
orderId, anIdempotency-Keyheader, etc.). - Or use
DurableAgent, which runs tools at workflow scope — each tool can be marked"use step"to become its own durable, retryable step, or stay at workflow level to use primitives likesleep()and hooks.
Snapshot tailIndex before resuming the hook
const tailIndex = await probe.getTailIndex(); // FIRST
await probe.cancel();
await turnHook.resume(runId, { message: text }); // THEN
const stream = run.getReadable({ startIndex: tailIndex + 1 });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.
Don't call writable.close() inside a workflow function
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.
Don't use TransformStream.terminate() to slice the stream
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.
Release the source reader, don't cancel it
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.
Handle stale runId gracefully
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.
streamText vs DurableAgent
streamText() (this pattern) | DurableAgent | |
|---|---|---|
| Tool loop | AI SDK handles via stopWhen | Handles internally (AI SDK–compatible options) |
| LLM call durability | Re-executes with the parent turn | Each LLM call is a durable step |
| Tool call durability | Not individually durable — re-executes with the parent turn | Per tool — mark "use step" for a durable, retryable step, or keep at workflow level for sleep() / hooks |
| Stop conditions | stopWhen, prepareStep | stopWhen, prepareStep |
| Structured output | Output.object(), Output.array() | experimental_output (Output.object(), Output.text()) |
| Step callbacks | onStepFinish, onChunk, etc. | onStepFinish, onFinish, onError, onAbort (onChunk not available) |
| Setup | Manual stream piping and turn slicing | Automatic |
Use DurableAgent for most agent use cases. Use streamText when you need the raw AI SDK surface or a per-turn durability boundary.
AI SDK (docs)
streamText()— core streaming function;toUIMessageStream()pipes into the durable writabletool()/ tool calling — tools are plain async functions invoked bystreamTextinside the turn step; they are not individually durable in this pattern (see Pitfalls)stepCountIs()/stopWhen— bound the agent loop inside each turnconvertToModelMessages()/createUIMessageStreamResponse()— UI ↔ model message conversion at the API boundaryuseChat()— React hook that consumes the UI message stream on the client
Workflow SDK
"use step"— applied torunTurnto make each turn a durable, retryable unitdefineHook()— suspension point for follow-up messagesgetWritable()— resumable stream outputgetRun()—run.getReadable({ startIndex })for slicing per-turn streamsWorkflowChatTransport— passesrunIdbetween turns










