Chat - Core adapters
Connect your backend to the chat runtime by implementing the adapter interface.
The ChatAdapter interface is the transport boundary between the core runtime and your backend.
Only one method is required: sendMessage().
Everything else is optional and incrementally adopted.
import type { ChatAdapter } from '@mui/x-chat/headless';
The following demo shows a minimal adapter in action:
Minimal headless chat
Send the first message to start the thread.
Minimal adapter
A minimal adapter returns a ReadableStream of chunks from sendMessage():
const adapter: ChatAdapter = {
async sendMessage({ message }) {
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: 'response-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: 'response-1' });
controller.close();
},
});
},
};
ChatProvider then handles streaming, message normalization, and state updates.
Adapter interface reference
interface ChatAdapter<Cursor = string> {
// Required
sendMessage(
input: ChatSendMessageInput,
): Promise<ReadableStream<ChatMessageChunk | ChatStreamEnvelope>>;
// Optional
regenerate?(
input: ChatRegenerateInput,
): Promise<ReadableStream<ChatMessageChunk | ChatStreamEnvelope>>;
listConversations?(
input?: ChatListConversationsInput<Cursor>,
): Promise<ChatListConversationsResult<Cursor>>;
listMessages?(
input: ChatListMessagesInput<Cursor>,
): Promise<ChatListMessagesResult<Cursor>>;
reconnectToStream?(
input: ChatReconnectToStreamInput,
): Promise<ReadableStream<ChatMessageChunk | ChatStreamEnvelope> | null>;
setTyping?(input: ChatSetTypingInput): Promise<void>;
markRead?(input: ChatMarkReadInput): Promise<void>;
subscribe?(
input: ChatSubscribeInput,
): Promise<ChatSubscriptionCleanup> | ChatSubscriptionCleanup;
loadMore?(cursor?: Cursor): Promise<ChatLoadMoreResult<Cursor>>;
addToolApprovalResponse?(input: ChatAddToolApproveResponseInput): Promise<void>;
stop?(): void;
}
Adapter methods
Sending messages
Sends a user message and returns a readable stream of response chunks.
interface ChatSendMessageInput {
conversationId?: string; // target conversation
message: ChatMessage; // the user message being sent
messages: ChatMessage[]; // full thread context
attachments?: ChatDraftAttachment[]; // file attachments
metadata?: Record<string, unknown>; // extra context
signal: AbortSignal; // cancellation signal
}
The signal is connected to stopStreaming()—when the user cancels, the signal aborts, giving the adapter a chance to clean up.
Regenerating responses
Regenerates the assistant reply identified by messageId, returning a fresh response stream (same shape as sendMessage).
interface ChatRegenerateInput {
conversationId?: string;
messageId: string; // the assistant message being regenerated (already removed)
message: ChatMessage; // the user message that prompted the reply
messages: ChatMessage[]; // thread context up to and including `message`
signal: AbortSignal;
}
regenerate is optional: regeneration works without implementing it. When the adapter omits regenerate, the runtime re-sends the anchoring user message through sendMessage. Implement it when your backend distinguishes regeneration from a fresh send—for example, the Vercel AI SDK uses trigger: 'regenerate-message'.
The UI is driven by the runtime, not by calling the adapter directly: trigger regeneration with chat.regenerate(message.id). If the adapter call rejects before any output streams, the runtime restores the removed reply (no data is lost). Once the stream starts, partial output is kept with the stream-reported status.
Listing conversations
Loads the conversation list, typically called on mount.
interface ChatListConversationsInput<Cursor> {
cursor?: Cursor; // pagination cursor
query?: string; // search filter
}
interface ChatListConversationsResult<Cursor> {
conversations: ChatConversation[];
cursor?: Cursor; // next page cursor
hasMore?: boolean; // whether more pages exist
}
Loading message history
Loads messages for a conversation, called when activeConversationId changes.
interface ChatListMessagesInput<Cursor> {
conversationId: string;
cursor?: Cursor;
direction?: PaginationDirection; // 'forward' | 'backward'
}
interface ChatListMessagesResult<Cursor> {
messages: ChatMessage[];
cursor?: Cursor;
hasMore?: boolean;
}
Reconnecting to an interrupted stream
Resumes an interrupted stream, for example after an SSE disconnect.
interface ChatReconnectToStreamInput {
conversationId?: string;
messageId?: string;
signal: AbortSignal;
}
Returns null if reconnection is not possible.
Sending typing indicators
Sends a typing indicator to the backend.
When features.typingSignal is enabled, the runtime calls this automatically—see Real-time adapters.
interface ChatSetTypingInput {
conversationId: string;
isTyping: boolean;
}
Marking messages as read
Marks a conversation or specific message as read.
interface ChatMarkReadInput {
conversationId: string;
messageId?: string; // mark up to this message
}
Receiving real-time events
Starts a realtime subscription. Called on mount, returns a cleanup function called on unmount.
interface ChatSubscribeInput {
onEvent: (event: ChatRealtimeEvent) => void;
}
type ChatSubscriptionCleanup = () => void;
Loading older messages
Loads older messages for the current conversation using a pagination cursor.
interface ChatLoadMoreResult<Cursor> {
messages: ChatMessage[];
cursor?: Cursor;
hasMore?: boolean;
}
Sending tool approval responses
Sends a tool approval decision back to the backend.
interface ChatAddToolApproveResponseInput {
id: string; // the approval request ID
approved: boolean; // whether the tool call is approved
reason?: string; // optional reason for denial
}
Stopping an in-flight request
Called alongside the abort signal when the user stops streaming. Use this for server-side cleanup that goes beyond closing the stream.
Cursor generics
ChatAdapter<Cursor> is generic over the pagination cursor type.
The default is string, but you can use any type your backend requires:
interface MyCursor {
page: number;
token: string;
}
const adapter: ChatAdapter<MyCursor> = {
async sendMessage(input) {
/* ... */
},
async listMessages({ cursor }) {
// cursor is typed as MyCursor | undefined
const page = cursor?.page ?? 1;
// ...
},
};
The cursor type flows through ChatProvider, store, hooks, and all adapter input/output types.
Writing your first adapter
Step 1: Echo adapter
Start with a basic adapter that echoes user messages:
const adapter: ChatAdapter = {
async sendMessage({ message }) {
const text = message.parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('');
return new ReadableStream({
start(controller) {
const id = `response-${message.id}`;
controller.enqueue({ type: 'start', messageId: id });
controller.enqueue({ type: 'text-start', id: 'text-1' });
controller.enqueue({
type: 'text-delta',
id: 'text-1',
delta: `You said: "${text}"`,
});
controller.enqueue({ type: 'text-end', id: 'text-1' });
controller.enqueue({ type: 'finish', messageId: id });
controller.close();
},
});
},
};
Step 2: Add history
Implement listConversations() and listMessages() to load initial data:
const adapter: ChatAdapter = {
async sendMessage(input) {
/* ... */
},
async listConversations() {
const res = await fetch('/api/conversations');
const conversations = await res.json();
return { conversations };
},
async listMessages({ conversationId, cursor }) {
const res = await fetch(
`/api/conversations/${conversationId}/messages?cursor=${cursor ?? ''}`,
);
const { messages, nextCursor, hasMore } = await res.json();
return { messages, cursor: nextCursor, hasMore };
},
};
Step 3: Add realtime
Implement subscribe() for push updates:
const adapter: ChatAdapter = {
async sendMessage(input) {
/* ... */
},
subscribe({ onEvent }) {
const ws = new WebSocket('/api/ws');
ws.onmessage = (e) => onEvent(JSON.parse(e.data));
return () => ws.close();
},
};
Step 4: Add cancellation
Implement stop() for server-side cleanup:
const adapter: ChatAdapter = {
async sendMessage({ signal }) {
// signal.aborted is checked by the runtime
// ...
},
stop() {
fetch('/api/cancel', { method: 'POST' });
},
};
Error handling
When an adapter method throws, the runtime:
- Records a
ChatErrorwith the appropriatesourcefield ('send','history', or'adapter'). - Surfaces the error through
useChat().error,useChatStatus().error, and theonErrorcallback. - Sets
recoverablebased on the error type—stream disconnects are typically recoverable, while send failures may be retryable.
See also
- Streaming for the full stream chunk protocol.
- Realtime for the event types used by
subscribe(). - Hooks for the runtime actions that trigger adapter methods.
- Minimal core chat for the smallest working adapter.
- Conversation history for adapter-driven history loading.