Adapters

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

AdapterLocalRuntimeExternalStoreRuntimeDataStreamAssistantTransportai-sdkreact-langgraphreact-langchainreact-google-adkreact-a2areact-ag-uireact-opencodeeve
AttachmentsYesYesYesYesYesYesYesYesYesYesYesYes
SpeechYesYesYes(no)YesYesYesYesYesYesYesYes
DictationYesYesYes(no)YesYesYesYesYesYesYesYes
FeedbackYesYesYes(no)YesYesYesYesYesYesYesYes
HistoryYes(use your store)Yes(use thread converter)Yes(via load)(via thread state)(via load)YesYes(server-managed)(server-managed)
SuggestionYes(no)Yes(no)Yes(no)(no)(no)(no)(no)(no)(no)
threadListYes (RemoteThreadListAdapter)Yes (ExternalStoreThreadListAdapter)Yes (RemoteThreadListAdapter)YesYesYesYesYesYesYes (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:

  • add runs when the user picks a file. Upload it, return a record with status requires-action so the composer holds the file before sending.
  • send runs when the user submits the message. Finalize the upload, attach a content payload, and mark status complete.
  • remove runs 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,
  },
});