Chat - Streaming
Stream assistant responses chunk-by-chunk from the adapter into the Chat UI for live, token-by-token updates.
Streaming makes responses feel immediate: instead of waiting for the full completion, the user sees text appear as the model produces it. This page is for adapter authors — it specifies the chunk protocol the runtime consumes and how each chunk becomes UI state.
The Chat component streams assistant responses token-by-token.
The adapter's sendMessage() method returns a ReadableStream<ChatMessageChunk | ChatStreamEnvelope>.
The runtime reads this stream, processes each chunk, and updates the normalized store so that UI components re-render incrementally.
The demo below streams a canned response token-by-token so you can watch chunks land and tune the flush interval.
Stream lifecycle
Every stream must follow this lifecycle:
start— Begin a new assistant message. The runtime creates the message shell and sets its status to'streaming'.- Content chunks — Text, reasoning, tool, source, file, data, or metadata chunks populate the message.
finishorabort— Terminal chunk.finishmarks the message as'sent';abortmarks it as'cancelled'.
If the stream closes without a terminal chunk, the runtime treats it as a disconnect (see Handling errors and disconnects below).
Building a stream
When writing an adapter, construct a ReadableStream from an array of chunks:
const adapter: ChatAdapter = {
async sendMessage({ message }) {
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: 'msg-1' });
controller.enqueue({ type: 'text-start', id: 'text-1' });
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: 'Hello!' });
controller.enqueue({ type: 'text-end', id: 'text-1' });
controller.enqueue({ type: 'finish', messageId: 'msg-1' });
controller.close();
},
});
},
};
Or convert a server-sent event stream into chunks:
async function fromSSE(
url: string,
signal: AbortSignal,
): Promise<ReadableStream<ChatMessageChunk>> {
const response = await fetch(url, { signal });
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
return new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data && data !== '[DONE]') {
controller.enqueue(JSON.parse(data));
}
}
}
},
});
}
Deduplicating and ordering chunks
For deduplication and ordering, wrap chunks in a ChatStreamEnvelope:
interface ChatStreamEnvelope {
eventId?: string; // unique event identifier for deduplication
sequence?: number; // monotonic ordering number
chunk: ChatMessageChunk;
}
The runtime accepts both raw ChatMessageChunk objects and enveloped chunks in the same stream.
Envelopes are useful for SSE-based transports where chunks might arrive out of order or be replayed.
Controlling the flush interval
The runtime batches rapid text and reasoning deltas before applying them to the store.
The streamFlushInterval prop on ChatBox (or ChatProvider) controls the batching window.
The default is 16 ms (approximately one frame at 60 fps).
Override the default by passing a custom interval to the streamFlushInterval prop:
<ChatBox adapter={adapter} streamFlushInterval={32} />
Streaming indicator
While a response is in flight, the Chat component shows animated dots so the user knows the assistant is working. The indicator covers two phases:
- Waiting — after the user sends a message and before the
startchunk arrives, the indicator renders as an incoming assistant row below the last message: the assistant's avatar (when resolvable frommembersor the conversation'sparticipants) next to a typing bubble holding the dots. - Streaming — once the assistant message exists with
status: 'streaming', the dots move inside the real assistant bubble, below the streamed content, and disappear when the stream finishes.
Enabling and disabling
The indicator is controlled by the streamingIndicator feature flag, which defaults to 'auto':
'auto'(default) — shown only in assistant-backed conversations. A conversation counts as assistant-backed when an assistant member or participant is configured (aChatUserwithrole: 'assistant'inmembersor the conversation'sparticipants), or when the conversation already contains an assistant message. Human-to-human chats never show it.true— always shown while a response is in flight, regardless of detection.false— never shown.
<ChatBox adapter={adapter} features={{ streamingIndicator: false }} />
Customization
Use the streamingIndicator slot to replace the rendered component, or pass null to remove it while keeping the feature semantics for a custom replacement elsewhere:
<ChatBox
adapter={adapter}
slots={{ streamingIndicator: MyIndicator }}
slotProps={{ streamingIndicator: { className: 'my-indicator' } }}
/>
A custom slot component receives the surrounding message when rendered inside a streaming assistant bubble, and the resolved mode plus the row contract (messageId, index, items) when rendered as the trailing row.
Reuse the built-in gating with the useStreamingIndicatorVisibility() hook from @mui/x-chat/headless:
import { useStreamingIndicatorVisibility } from '@mui/x-chat/headless';
function MyIndicator({ mode = 'auto', message }) {
const { waiting } = useStreamingIndicatorVisibility(mode);
const streaming = message?.role === 'assistant' && message?.status === 'streaming';
if (!waiting && !streaming) {
return null;
}
return <LinearProgress sx={{ maxWidth: 120 }} />;
}
The streaming indicator reflects the assistant's response generation. For showing when human participants are composing a message, see Typing indicators.
Stopping and cancelling streams
The sendMessage input includes an AbortSignal that fires when the user clicks the stop button.
Pass it to the fetch call so the runtime cancels the HTTP request automatically:
async sendMessage({ message, signal }) {
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message }),
signal, // browser cancels the request when the user stops
});
return res.body!;
},
If your backend requires explicit cancellation (for example, sending a separate cancel request), implement the optional stop() method on the adapter alongside the signal:
stop() {
fetch('/api/chat/cancel', { method: 'POST' });
},
You can also stop streaming programmatically using the useChat() hook:
const { stopStreaming } = useChat();
// Cancel the active stream
stopStreaming();
Reconnecting to streams
Implement the adapter's reconnectToStream() method to resume an interrupted stream — for example, when an SSE connection drops mid-response:
async reconnectToStream({ conversationId, messageId, signal }) {
const res = await fetch('/api/chat/reconnect', {
method: 'POST',
body: JSON.stringify({ conversationId, messageId }),
signal,
});
if (res.status === 404) return null; // message no longer resumable
return res.body!;
},
Return null if the interrupted message cannot be resumed.
The runtime calls reconnectToStream() automatically after detecting a disconnected stream.
It makes one reconnect attempt for the interrupted assistant messageId; if you return null, the message stays in 'error' status and the runtime calls onError with the recoverable stream error.
Handling errors and disconnects
If the stream closes without a terminal chunk (finish or abort), the runtime:
- Records a recoverable stream error.
- Sets the message status to
'error'. - Calls
onFinishwithisDisconnect: true. - If
reconnectToStream()is implemented, makes one attempt to resume. - Calls
onErroronly when the disconnect remains unrecovered.
If the adapter's sendMessage() throws, the runtime records a send error and surfaces it through the error model.
See Error handling for more details.
Chunk type reference
Full reference of every chunk type the runtime accepts, grouped by family. Skip to the table you need: lifecycle, text, reasoning, tool, source, file, and data, step boundaries, metadata.
Lifecycle chunks
| Chunk type | Fields | Description |
|---|---|---|
start |
messageId, author? |
Begin a new assistant message; author sets the assistant message author |
finish |
messageId, finishReason? |
Complete the assistant message |
abort |
messageId |
Cancel the assistant message |
The optional finishReason is an adapter-defined string (for example 'stop' or 'length').
The runtime doesn't interpret or display it — it forwards the value verbatim to the
onFinish callback so your application can react to how the response ended.
Text chunks
| Chunk type | Fields | Description |
|---|---|---|
text-start |
id |
Begin a new text part |
text-delta |
id, delta |
Append text content |
text-end |
id |
Finalize the text part |
Text chunks create a ChatTextMessagePart in the message.
Multiple text-delta chunks are batched according to streamFlushInterval before being applied to the store.
Reasoning chunks
| Chunk type | Fields | Description |
|---|---|---|
reasoning-start |
id |
Begin a reasoning part |
reasoning-delta |
id, delta |
Append reasoning content |
reasoning-end |
id |
Finalize the reasoning part |
Reasoning chunks create a ChatReasoningMessagePart, useful for chain-of-thought or thinking trace displays.
Tool chunks
| Chunk type | Fields | Description |
|---|---|---|
tool-input-start |
toolCallId, toolName, dynamic? |
Begin a tool invocation |
tool-input-delta |
toolCallId, inputTextDelta |
Stream tool input JSON |
tool-input-available |
toolCallId, toolName, input, dynamic? |
Tool input is fully available |
tool-input-error |
toolCallId, errorText |
Tool input parsing failed |
tool-approval-request |
toolCallId, toolName, input, approvalId?, dynamic? |
Request user approval |
tool-output-available |
toolCallId, output, preliminary? |
Tool output is available |
tool-output-error |
toolCallId, errorText |
Tool execution failed |
tool-output-denied |
toolCallId, reason? |
User denied the tool call |
The toolInvocation.state tracks the tool lifecycle: 'input-streaming' → 'input-available' → 'approval-requested' → 'output-available' (or 'output-error' / 'output-denied').
Source, file, and data chunks
| Chunk type | Fields | Description |
|---|---|---|
source-url |
sourceId, url, title? |
URL-based source |
source-document |
sourceId, title?, text? |
Document-based source |
file |
mediaType, url, filename?, id? |
Inline file |
data-* |
type, data, id?, transient? |
Custom data payload |
Data chunks use a type that matches the data-* pattern (for example, data-citations or data-summary).
They create ChatDataMessagePart entries and also fire the onData callback.
If transient is true, the part is not persisted in the message.
Step boundary chunks
| Chunk type | Description |
|---|---|
start-step |
Begin a new processing step |
finish-step |
End the current step |
Step chunks create ChatStepStartMessagePart entries, useful for showing multi-step agentic processing in the UI.
Metadata chunks
| Chunk type | Fields | Description |
|---|---|---|
message-metadata |
metadata |
Merge partial metadata into the assistant message |
Metadata chunks do not create a message part — the partial metadata object is shallow-merged into ChatMessage.metadata.
Type the payload by augmenting the ChatMessageMetadata registry interface (see Type augmentation).
Mapping chunks to message parts
The stream processor maps chunks to ChatMessagePart entries:
text-startcreates a newChatTextMessagePartwithstate: 'streaming'.text-deltaappends to the existing text part.text-endsetsstate: 'done'.- Tool chunks update
toolInvocation.statethrough each phase. While input streams,tool-input-deltakeeps the invocation in'input-streaming'— the runtime doesn't assemble a partialinputfrom the deltas; the complete, parsedinputarrives withtool-input-available. - Source, file, and data chunks each create their corresponding part type immediately.
start-stepcreates aChatStepStartMessagePartseparator.message-metadatamerges into the message'smetadatafield without creating a part.
The message's status field also updates through the stream:
'sending'— set when the user message is optimistically added'streaming'— set whenstartarrives'sent'— set whenfinisharrives'cancelled'— set whenabortarrives'error'— set when the stream fails
See also
- Building an adapter for a step-by-step guide to producing these streams.
- Adapter for details on the interface that produces streams.
- Error handling for details on the error model and recovery.
- Scrolling for details on how auto-scroll follows streaming content.
API
See the documentation below for a complete reference to all of the props and classes available to the components mentioned here.