Reusable extension points for attachments, speech, feedback, history, and suggestions.
Adapters are how assistant-ui adds capabilities like file uploads or message persistence to a runtime without coupling the runtime to a specific backend. You implement a small interface, plug it into the runtime's adapters option, and the matching UI surfaces (paperclip button, audio button, history reload) light up.
Every adapter on this page works the same way in every runtime that supports it. You provide it via that runtime's adapters option:
const runtime = useLocalRuntime(modelAdapter, {
adapters: { attachments, history, speech, feedback, suggestion },
});Framework adapters take the same shape:
const runtime = useChatRuntime({ adapters: { attachments, history } });Support matrix
| Adapter | LocalRuntime | ExternalStoreRuntime | DataStream | AssistantTransport | ai-sdk | react-langgraph | react-langchain | react-google-adk | react-a2a | react-ag-ui | react-opencode | eve |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Attachments | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Speech | Yes | Yes | Yes | (no) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Dictation | Yes | Yes | Yes | (no) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Feedback | Yes | Yes | Yes | (no) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| History | Yes | (use your store) | Yes | (use thread converter) | Yes | (via load) | (via thread state) | (via load) | Yes | Yes | (server-managed) | (server-managed) |
| Suggestion | Yes | (no) | Yes | (no) | Yes | (no) | (no) | (no) | (no) | (no) | (no) | (no) |
| threadList | Yes (RemoteThreadListAdapter) | Yes (ExternalStoreThreadListAdapter) | Yes (RemoteThreadListAdapter) | Yes | Yes | Yes | Yes | Yes | Yes | Yes (experimental) | Built-in (sessions) | Yes |
(no) means the adapter slot is not exposed by that runtime today. You would need to drop down a layer to use it.
Attachment adapter
Handles file and image uploads. When present, the composer renders a paperclip button.
type AttachmentAdapter = {
accept: string;
add: (input: {
file: File;
}) => Promise<PendingAttachment> | AsyncGenerator<PendingAttachment, void>;
send: (
attachment: PendingAttachment,
options?: { signal?: AbortSignal },
) => Promise<CompleteAttachment>;
remove: (attachment: Attachment) => Promise<void>;
};Three lifecycle methods:
addruns when the user picks a file. Upload it, return a record with statusrequires-actionso the composer holds the file before sending.sendruns when the user submits the message. Finalize the upload, attach acontentpayload, and mark statuscomplete.removeruns when the user removes the attachment before sending.
Minimal upload-and-send example:
const attachmentAdapter: AttachmentAdapter = {
accept: "image/*,application/pdf",
async add({ file }) {
const form = new FormData();
form.append("file", file);
const { id, url } = await fetch("/api/upload", {
method: "POST",
body: form,
}).then((r) => r.json());
return {
id,
type: file.type.startsWith("image/") ? "image" : "document",
name: file.name,
contentType: file.type,
file,
url,
status: { type: "requires-action", reason: "composer-send" },
};
},
async send(attachment) {
return {
...attachment,
status: { type: "complete" },
content: [
attachment.type === "image"
? { type: "image", image: attachment.url! }
: { type: "text", text: `[${attachment.name}](${attachment.url})` },
],
};
},
async remove() {
// Delete the uploaded file here if your backend keeps one.
},
};For multiple file types use CompositeAttachmentAdapter:
import {
CompositeAttachmentAdapter,
SimpleImageAttachmentAdapter,
SimpleTextAttachmentAdapter,
} from "@assistant-ui/react";
const attachmentAdapter = new CompositeAttachmentAdapter([
new SimpleImageAttachmentAdapter(),
new SimpleTextAttachmentAdapter(),
]);Speech adapter
Text-to-speech for assistant messages. When present, message bubbles render an audio button.
type SpeechSynthesisAdapter = {
speak: (text: string) => Utterance;
};speak returns an Utterance with cancel(), a status field, and subscribe(callback). Browser-native example:
const speechAdapter: SpeechSynthesisAdapter = {
speak(text) {
const utterance = new SpeechSynthesisUtterance(text);
const subscribers = new Set<() => void>();
const result: SpeechSynthesisAdapter.Utterance = {
status: { type: "running" },
cancel: () => {
speechSynthesis.cancel();
result.status = { type: "ended", reason: "cancelled" };
subscribers.forEach((cb) => cb());
},
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
};
utterance.addEventListener("end", () => {
result.status = { type: "ended", reason: "finished" };
subscribers.forEach((cb) => cb());
});
speechSynthesis.speak(utterance);
return result;
},
};Dictation adapter
Speech-to-text input for the composer. When present, the composer renders a microphone button.
type DictationAdapter = {
listen: () => DictationAdapter.Session;
disableInputDuringDictation?: boolean;
};listen returns a session with stop(), cancel(), a status field, and the onSpeechStart, onSpeech, and onSpeechEnd subscriptions.
Feedback adapter
Thumbs up / thumbs down on assistant messages. When present, message bubbles render feedback buttons.
type FeedbackAdapter = {
submit: (feedback: {
type: "positive" | "negative";
message: ThreadMessage;
comment?: string;
}) => void;
};comment is set when the caller passes one to submitFeedback({ type, comment }), which a form such as FeedbackDialog passes after a thumbs down. The runtime trims it and omits an empty one.
const feedbackAdapter: FeedbackAdapter = {
async submit({ type, message, comment }) {
await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messageId: message.id, rating: type, comment }),
});
},
};History adapter
Per-thread message persistence. Used by LocalRuntime, useDataStreamRuntime, and the framework adapters that accept adapters.history (ai-sdk, react-a2a, react-ag-ui).
ExternalStoreRuntime does not use a history adapter directly, since you already own the message array. Persist via your store instead. react-langgraph and react-google-adk read server-side thread state through their load callback, and react-langchain reads it through useStream.
type ThreadHistoryAdapter = {
scopeId?: string;
load: () => Promise<{
headId?: string | null;
messages: { parentId: string | null; message: ThreadMessage }[];
}>;
append: (item: {
parentId: string | null;
message: ThreadMessage;
}) => Promise<void>;
update?: (item: {
parentId: string | null;
message: ThreadMessage;
}) => Promise<void>;
delete?: (
items: { parentId: string | null; message: ThreadMessage }[],
) => Promise<void>;
resume?: (
options: ChatModelRunOptions,
) => AsyncGenerator<ChatModelRunResult, void, unknown>;
withFormat?: <TMessage, TStorageFormat extends Record<string, unknown>>(
formatAdapter: MessageFormatAdapter<TMessage, TStorageFormat>,
) => GenericThreadHistoryAdapter<TMessage>;
};load runs when a thread opens. append runs after each message completes, or earlier when a later turn follows a message whose run is still open. update rewrites a stored message in place, which lets the runtime store a run paused for tool approval and a tool result that arrives after its message settled; without it, a paused run is stored once it ends or once a later turn follows it, and anything the run adds after that append is not stored.
When LocalRuntime can switch accounts, workspaces, or another backing store,
set scopeId to a stable identifier for that storage scope. Keep it unchanged
when recreating the adapter for the same scope, and change it before loading
another scope. A scope change stops work owned by the previous scope, clears its
in-memory history and its composer draft, discards any pending result, and loads
the current adapter. Adapters that omit scopeId are treated as sharing one
scope.
scopeId is currently specific to LocalRuntime. AI SDK, AG-UI, A2A, and other
external-history runtimes load history once per mounted runtime, so remount
those runtimes when their storage scope changes.
If a local runtime history write fails, the thread emits historyWriteError with the attempted operation (append, update, or delete), affected messageIds, a readable message, and the original error. The store forwards thread.historyWriteError with threadId, operation, messageIds, and message. Listeners receive failures while bound to the affected thread, so a write that fails after you switch away from its thread reaches no listener; the runtime logs every failed write with console.error either way. Writes returned to callers still reject. The external-store history copy keeps its warning on failure and does not emit this event.
Info
ai-sdk requires withFormat so messages round-trip as AI SDK UIMessage objects. An adapter without withFormat throws at runtime in the AI SDK path. See the AI SDK history docs for the full pattern.
Suggestion adapter
Proposes follow-up prompts after each assistant message. When present, suggestion chips render under the latest assistant message.
type SuggestionAdapter = {
generate: (input: {
messages: readonly ThreadMessage[];
signal?: AbortSignal;
}) =>
| Promise<readonly ThreadSuggestion[]>
| AsyncGenerator<readonly ThreadSuggestion[], void>;
};const suggestionAdapter: SuggestionAdapter = {
async *generate({ messages }) {
const last = messages.at(-1);
if (!last) return;
const response = await fetch("/api/suggestions", {
method: "POST",
body: JSON.stringify(last),
});
yield (await response.json()).suggestions;
},
};Thread list adapter
Multi-thread support is documented separately, since the contract differs by runtime. See threads.
Composing adapters
Provide as many or as few adapters as you need. UI surfaces enable based on which slots are filled.
const runtime = useLocalRuntime(modelAdapter, {
adapters: {
attachments: myAttachmentAdapter,
history: myHistoryAdapter,
speech: mySpeechAdapter,
feedback: myFeedbackAdapter,
},
});