Skip to main content
There are three abstraction levels for a chat backend. All three speak the same wire protocol, so the frontend transport works unchanged whichever you pick. The raw-primitives column assumes chat.customAgent() as the wrapper, which is what makes the task visible to the agent dashboard. Start with chat.agent(). Drop to chat.createSession() when you want to own the per-turn code (model routing, persistence, custom telemetry) without rebuilding the turn loop. Drop to raw primitives only when you need full control over stream conversion or a custom protocol.

chat.agent()

The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.

Simple: return a StreamTextResult

Return the streamText result from run and it’s automatically piped to the frontend:
The streamText destructured from run’s argument is the SDK’s, not the one imported from ai. It carries the agent’s managed options, so nothing has to be spread in. The managed streamText covers what those options are and what happens when yours collide with them.

The managed streamText

run() is handed a streamText that already carries everything the spread provides, so the managed state cannot be lost by leaving the spread out:
Note the destructured streamText: it shadows the one imported from ai inside run, so the managed options apply without a spread. Spreading chat.toStreamTextOptions() into the imported streamText is still supported and equivalent. It differs from the spread in three ways, all of them about what happens when your options collide with the managed ones: system throws rather than merging because there is no shape that combines two system values on every supported AI SDK version: v5 rejects an array of blocks, and a structured block carries the provider options that make prompt caching work, so concatenating discards the cache entry. Set a static prompt with chat.prompt.set() and add per-turn context with chat.inject(). If the managed prompt names a model, pass a registry on the agent so the runtime can resolve it: chat.agent({ registry, run }).

Using chat.pipe() for complex flows

For complex agent flows where streamText is called deep inside your code, use chat.pipe(). It works from anywhere inside a task — even nested function calls.
trigger/agent-chat.ts

Custom data parts

Add custom data-* parts to the assistant’s response message via chat.response.write() (from run()) or the writer parameter in lifecycle hooks. Non-transient data-* chunks are automatically added to responseMessage.parts and surface in onTurnComplete for persistence:
Add transient: true to data chunks that should stream to the frontend but NOT persist in the response message. Use this for progress indicators, loading states, and other temporary UI:
This matches the AI SDK’s semantics: data-* chunks persist to message.parts by default. Only transient: true chunks are ephemeral. Non-data chunks (text-delta, tool-*, etc.) are handled by streamText and captured via onFinish — they don’t need chat.response.
chat.response and the writer accumulation behavior work with chat.agent and chat.createSession. If you’re using chat.customAgent, you own the accumulator — see the raw-task example for the manual pattern.

Raw streaming with chat.stream

For low-level stream access (piping from subtasks, reading streams by run ID), use chat.stream. Chunks written via chat.stream go directly to the realtime output — they are NOT accumulated into the response message regardless of the transient flag.
Use data-* chunk types (e.g. data-status, data-progress) for custom data. The AI SDK processes these into DataUIPart objects in message.parts on the frontend. Writing the same type + id again updates the existing part instead of creating a new one — useful for live progress.
chat.stream exposes the full stream API: For piping streams from subtasks to the parent chat (via target: "root"), see the Sub-agents pattern.

Backed by a Session

Every chat.agent conversation is backed by a durable Session: externalId is your chatId, type is "chat.agent", and taskIdentifier is the agent’s task ID. The session is the run manager. It owns the chat’s runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, chat.requestUpgrade). You rarely touch it directly, since chat.stream, chat.messages, and chat.stopSignal wrap everything, but payload.sessionId is there when you need to reach in, e.g. sessions.open(payload.sessionId) to write from a sub-agent or from outside the turn loop.

Tools

Declare your tools on the agent config, then read them back (typed) from the run() payload. Declaring them on the config, not just on streamText, is what lets the SDK re-apply each tool’s toModelOutput when it re-converts history on later turns.
See Tools for toModelOutput across turns, per-turn dynamic tools, the typed run payload, and how config tools relate to skills.

Lifecycle hooks

chat.agent({ ... }) accepts hooks that fire in a fixed order around each turn, plus dedicated suspend/resume hooks. The full reference lives on its own page:
  • Lifecycle hooksonPreload, onChatStart, onValidateMessages, hydrateMessages, onTurnStart, onBeforeTurnComplete, onTurnComplete, onChatSuspend / onChatResume, exitAfterPreloadIdle, plus how ctx plumbs through every callback.
Per-turn order: onValidateMessageshydrateMessagesonChatStart (chat’s first message only) → onTurnStartrun()onBeforeTurnCompleteonTurnComplete.

Using prompts

Use AI Prompts to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with chat.prompt.set(). The streamText from run’s argument picks it up: system prompt, model, config and telemetry.
The managed streamText carries the stored prompt’s system, model (resolved through the agent’s registry), sampling config, and experimental_telemetry. Options you pass at the call site win, apart from system, which throws when the prompt already set one. chat.toStreamTextOptions() remains available for the same job, and is the only option in a custom agent, which has no run argument to take a bound streamText from. A chat.headStart route does get one, and there it also owns messages, stopWhen and abortSignal, since the handover depends on them. Pass { registry } when a prompt names a provider-prefixed model, and { tools } when you want HITL tool approvals, so the SDK knows which calls pause on needsApproval.
See Prompts for the full guide — defining templates, variable schemas, dashboard overrides, and the management SDK.

Stop generation

How stop works

Calling stop() from useChat sends a stop signal to the running task via input streams. The task’s streamText call aborts (if you passed signal or stopSignal), but the run stays alive and waits for the next message. The partial response is captured and accumulated normally.

Abort signals

The run function receives three abort signals:
Use signal (the combined signal) in most cases. The separate stopSignal and cancelSignal are only needed if you want different behavior for stop vs cancel.

Detecting stop in callbacks

The onTurnComplete event includes a stopped boolean that indicates whether the user stopped generation during that turn:
You can also check stop status from anywhere during a turn using chat.isStopped(). This is useful inside streamText’s onFinish callback where the AI SDK’s isAborted flag can be unreliable (e.g. when using createUIMessageStream + writer.merge()):

Cleaning up aborted messages

When stop happens mid-stream, the captured response message can contain parts in an incomplete state — tool calls stuck in partial-call, reasoning blocks still marked as streaming, etc. These can cause UI issues like permanent spinners. chat.agent automatically cleans up the responseMessage when stop is detected before passing it to onTurnComplete. If you use chat.pipe() manually and capture response messages yourself, use chat.cleanupAbortedParts():
This removes tool invocation parts stuck in partial-call state and marks any streaming text or reasoning parts as done.
Stop signal delivery is best-effort. There is a small race window where the model may finish before the stop signal arrives, in which case the turn completes normally with stopped: false. This is expected and does not require special handling.

Tool approvals

Tools with needsApproval: true pause execution until the user approves or denies via the frontend. Define the tool as normal and pass it to streamTextchat.agent handles the rest:
When the model calls an approval-required tool, the turn completes with the tool in approval-requested state. After the user approves on the frontend, the updated message is sent back and chat.agent replaces it in the conversation accumulator by matching the message ID. streamText then executes the approved tool and continues. See Tool approvals in the frontend docs for the UI setup.

Persistence

To build a chat app that survives page refreshes you persist two things, both server-side from inside the agent:
  1. Conversation state. Full UIMessage[] keyed by chatId. Written from onTurnStart (so the user message is durable before streaming begins) and onTurnComplete (so the assistant reply lands).
  2. Session state. The transport’s reconnect metadata: publicAccessToken and lastEventId. Written alongside the messages from the same hooks.
Sessions let the transport reconnect to an existing run after a page refresh. Without them, every page load would start a new run, losing the conversation context that was accumulated in the previous run.
For the full per-hook breakdown, race-condition warnings (atomic lastEventId writes, why not to use chat.defer in onTurnStart), token renewal via the accessToken callback, and an end-to-end three-file example, see Database persistence.

Pending messages (steering)

Users can send messages while the agent is executing tool calls. With pendingMessages, these messages are injected between tool-call steps, steering the agent mid-execution:
On the frontend, the usePendingMessages hook handles sending, tracking, and rendering injection points.
See Pending Messages for the full guide — backend configuration, frontend hook, queuing vs steering, and how injection works with all three chat variants.

Background injection

Inject context from background work into the conversation using chat.inject(). Combine with chat.defer() to run analysis between turns and inject results before the next response — self-review, RAG augmentation, safety checks, etc.
See Background Injection for the full guide — timing, self-review example, and how it differs from pending messages.

Actions

Custom actions let the frontend send structured commands (undo, rollback, edit, regenerate) that modify the conversation state. Actions are not turns: they fire hydrateMessages (if set) and onAction only. The full surface (defining actionSchema, returning a model response from onAction, gating against pending HITL tool calls, and sending actions from the frontend) lives on its own page. See Actions.

Chat history

Imperative API for reading and modifying the accumulated message history. Works from any hook (onAction, onTurnStart, onBeforeTurnComplete, onTurnComplete, hydrateMessages) or from run() and AI SDK tools.
The agent’s accumulator — not session.out — is the source of truth for the full conversation. The .out stream is a bounded sliding window (roughly one turn at steady state, see Records on session.out); the durable history lives in the agent’s accumulator and is persisted to S3 between turns for fast next-run boots. chat.history reads and mutates that accumulator directly.
Reads. Synchronous against the current accumulator state. Each pending and resolved entry is shaped { toolCallId, toolName, messageId }. Each new-result entry is { toolCallId, toolName, output, errorText? }, where errorText is set only for output-error parts. Mutations. Applied at lifecycle checkpoints (after hooks return). Multiple mutations in the same hook compose correctly.
The HITL reads let an action or hook decide what to do without walking the accumulator manually:
extractNewToolResults compares against the current chain. Inside onTurnComplete, the chain already contains the just-finished responseMessage, so it returns []. Use it where the message is from outside the accumulator: hydrateMessages (incoming wire), onAction if the action carries a message, or any custom pre-merge code path.

prepareMessages

Transform model messages before they’re used anywhere — in run(), in compaction rebuilds, and in compaction results. Define once, applied everywhere. Use this for Anthropic cache breaks, injecting system context, stripping PII, etc.
The reason field tells you why messages are being prepared:

Version upgrades

Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the old code. Call chat.requestUpgrade() in onTurnStart to skip run() and exit immediately — the transport re-triggers the same message on the latest version. See the Version Upgrades pattern for the full guide.

Ending a run on your terms

By default, a chat agent stays idle after each turn waiting for the next user message. Call chat.endRun() from run(), chat.defer(), onBeforeTurnComplete, or onTurnComplete to exit the loop once the current turn finishes — no upgrade signal, no idle wait.
The current turn streams through normally, onBeforeTurnComplete / onTurnComplete fire, the turn-complete chunk is written, and the run exits instead of suspending. The next user message on the same chatId starts a fresh run via the standard continuation flow. Use this when the agent knows its work is done (budget exhausted, goal achieved, one-shot response) rather than relying on the idle timeout. Unlike chat.requestUpgrade(), no upgrade-required signal is sent to the client, so there’s no version-migration semantics.
If you persist lastEventId to your own storage for cross-page-load resume, don’t clear it on chat.endRun(). The cursor is sessionId-keyed and stays valid across Run boundaries — clearing it forces the next sendMessages to subscribe from seq_num=0, where it may hit the prior turn’s stale turn-complete record and close the stream empty before the new Run’s chunks arrive.

Ending the conversation

chat.close() ends the whole conversation. The session row is marked closed, further appends are refused with HTTP 409, and no continuation run is scheduled. Call it from run(), prepareStep, or onBeforeTurnComplete.
The current turn finishes and streams through normally. Called mid-step, chat.close() aborts the in-flight streamText the same way the stop signal does, so the partial response is still captured and delivered. The transport then flips to a closed state, carrying reason so you can render why the conversation ended.
Decide before the turn ends, not in onTurnComplete. The closed state reaches the browser on the turn’s final turn-complete record. onTurnComplete runs after that record is written and after the response stream has closed, so a close decided there does not reach a reader that is already done with the turn: the user sees a normal answer and learns the conversation ended only when their next message is refused. Use onBeforeTurnComplete for the same information one step earlier, and the user sees the closed state as soon as the answer finishes.
Closing is one-way. A closed session cannot be reopened, and its transcript stays readable. Start a new conversation under a different chatId to continue. On the client, read the closed state off the transport:
app/components/Chat.tsx

Three levels of stopping

chat.agent stops at three levels, each with a different “what happens next”. Reach for the narrowest level that does the job: a turn budget is a stopWhen, a finished one-shot answer is chat.endRun(), and an exhausted account or a signed-out user is chat.close(). chat.close() is available to custom agents too, where the close is performed when your run() returns. A session can also be closed from outside the run: sessions.close(chatId) from your backend, or the Close action in the dashboard. A live run is told either way: the close lands on the session’s input channel, so an idle or suspended agent leaves its loop on the next wake instead of waiting out its idle timeout.

Runtime configuration

chat.setTurnTimeout()

Override how long the run stays suspended waiting for the next message. Call from inside run():

chat.setIdleTimeoutInSeconds()

Override how long the run stays idle (active, using compute) after each turn:
Longer idle timeout means faster responses but more compute usage. Set to 0 to suspend immediately after each turn (minimum latency cost, slight delay on next message).

Stream options

Control how streamText results are converted to the frontend stream via toUIMessageStream(). Set static defaults on the task, or override per-turn.
Error handling with onError
When streamText encounters an error mid-stream (rate limits, API failures, network errors), the onError callback converts it to a string that’s sent to the frontend as an { type: "error", errorText } chunk. The AI SDK’s useChat receives this via its onError callback. By default, the raw error message is sent to the frontend. Use onError to sanitize errors and avoid leaking internal details:
onError is also called for tool execution errors, so a single handler covers both LLM errors and tool failures. On the frontend, handle the error in useChat:
Reasoning and sources
Control which AI SDK features are forwarded to the frontend:
Custom message IDs
By default, response message IDs are generated using the AI SDK’s built-in generateId. Pass a custom generateMessageId function to use your own ID format (e.g. UUID-v7):
With the .withUIMessage() builder, set it under streamOptions:
The generated ID is sent to the frontend in the stream’s start chunk, so frontend and backend always reference the same ID for each message. This is important for features like tool approvals, where the frontend resends an assistant message and the backend needs to match it by ID in the conversation accumulator.
Per-turn overrides
Override per-turn with chat.setUIMessageStreamOptions() — per-turn values merge with the static config (per-turn wins on conflicts). The override is cleared automatically after each turn.
chat.setUIMessageStreamOptions() works across all abstraction levels — chat.agent(), chat.createSession() / turn.complete(), and chat.pipeAndCapture(). See ChatUIMessageStreamOptions for the full reference.
onFinish is managed internally for response capture and cannot be overridden here. Use streamText’s onFinish callback for custom finish handling, or use raw task mode for full control over toUIMessageStream().

Manual mode with task()

If you need full control over task options, use the standard task() with ChatTaskPayload and chat.pipe():
Manual mode does not get automatic message accumulation or the onTurnComplete/onChatStart lifecycle hooks. The responseMessage field in onTurnComplete will be undefined when using chat.pipe() directly. Use chat.agent() for the full multi-turn experience.

Custom agents

Both lower levels — chat.createSession() (managed turn iterator, your turn body) and chat.customAgent() with raw primitives (hand-rolled loop, full stream-conversion control) — are covered together on the Custom agents page, including the ChatTurn surface, the continuation-seeding pattern, and the hand-rolled-loop checklist:

Custom agents

Build agents without the managed lifecycle — createSession or raw primitives.