AI SDK Integration (useChatRuntime + useChat)#
useChatRuntime is the primary public bridge between assistant-ui and Vercel AI SDK's useChat. Internally it delegates to useRemoteThreadListRuntime, creating a per-thread AssistantRuntime for each thread via useChatThreadRuntime, which calls useChatThread → useAISDKRuntime to produce the runtime object.
Key source files:
| File | Role |
|---|---|
useChatRuntime.ts | Public API — cloud adapter, onThreadIdChange, useRemoteThreadListRuntime wiring |
useChatThread.ts | Option splitting, transport selection, Chat construction, resumable stream handling |
useAISDKRuntime.ts | Core runtime adapter — message conversion, send/edit flows |
convertMessage.ts | AISDKMessageConverter — UIMessage → ThreadMessage normalization |
toCreateMessage.ts | AppendMessage → CreateUIMessage conversion for outbound sends |
AssistantChatTransport.ts | Default transport — runtime context injection, resumable streams |
Parameter Passing#
ChatThreadOptions is a union of AI SDK's ChatInit, ExternalStoreSharedOptions, and several assistant-ui-specific fields (adapters, toCreateMessage, onResume, onResumeToolCall, joinStrategy, etc.). UseChatRuntimeOptions extends this with cloud and onThreadIdChange.
Inside splitChatThreadOptions, the options are partitioned:
- AI SDK
ChatInitkeys (everything not explicitly destructured) flow intochatInitand are forwarded directly to theChatconstructor . - Assistant-ui keys (
adapters,toCreateMessage,onResume,onResumeToolCall,onRespondToToolApproval,joinStrategy,messageRepository, etc.) are forwarded touseAISDKRuntime. - Shared options (
isDisabled,isSendDisabled,unstable_capabilities,suggestions) are peeled off and passed viapickExternalStoreSharedOptions.
A compile-time exhaustiveness guard (true satisfies keyof typeof chatInit & keyof ExternalStoreSharedOptions extends never) ensures no ExternalStoreSharedOptions key leaks into the AI SDK ChatInit .
The thread id comes from the AUI store's threadListItem.id (via useAuiState), not from caller options .
Transport Handling#
If no transport is supplied, useChatThread instantiates a new AssistantChatTransport() (targeting /api/chat by default). If an AssistantChatTransport instance is passed by the caller and no external Chat is provided, it is cloned via __internal_clone() so each thread gets an isolated transport instance .
The resolved transport is wrapped in useDynamicChatTransport — a stable Proxy over a ref — so the transport reference can change across renders without invalidating the underlying Chat instance.
After the runtime is created, sourceTransport.setRuntime(runtime) and __internal_setGetThreadListItem(getThreadListItem) are called in render (not in an effect) to ensure a descendant's mount effect sees an already-wired transport .
AssistantChatTransport extends AI SDK's DefaultChatTransport and injects runtime context (system prompt, tools serialized to JSON Schema, call settings, model config) into every request via prepareSendMessagesRequest. Custom transports implementing the AI SDK ChatTransport<UIMessage> interface are also accepted .
Message Conversion Pipeline#
Messages flow in two directions:
Inbound (AI SDK → assistant-ui)#
AISDKMessageConverter converts UIMessage[] from useChat to ThreadMessage[]. Role-specific handling:
- User messages: Extracts
id,role, metadata; mapsfileparts to typed attachments . - Assistant/system messages: Preserves
idandrole; mergesmessageTimingfrom metadata; marks the currently-streaming message asisOptimistic. - Individual parts are converted by
convertParts():text/reasoningparts get a streaming status (runningorcomplete) .tool-callparts carrytoolCallId, args (with partial-JSON key-order caching duringinput-streaming), result/error, approval state, and MCP App metadata .source-url,source-document,file, anddata-*parts are mapped to theirThreadMessageequivalents .- Duplicate
toolCallIds are filtered from the output .
Outbound (assistant-ui → AI SDK)#
toCreateMessage converts an AppendMessage to a CreateUIMessage, merging content and attachments into parts with type-specific conversion (text, image→file, file, audio→file, data→data-{name}). No explicit id is emitted — AI SDK's idGenerator assigns the ID .
The toCreateMessage option on ChatThreadOptions accepts a (message: AppendMessage) => CreateUIMessage function to customize outbound message formatting (e.g., adding custom metadata). When provided it replaces the default converter in both onNew and onEdit paths .
MCP App Metadata#
During tool-call conversion, extractMcpAppMetadata reads MCP App data from two locations: the callProviderMetadata.mcp.app field (set by the AI SDK transport layer) and the tool result's output._meta.ui object (canonical MCP-UI spec field), with a fallback to the deprecated flat "ui/resourceUri" key. Extracted metadata is cached in an LRU Map bounded to 100 entries . The resulting mcp.app field on ToolCallMessagePart is what the McpAppRenderer uses to decide whether to render an MCP App widget .
Initialize Event Timing#
The initialize event on the thread runtime is a latched, one-shot event emitted at construction. A race condition existed where initialize could fire before downstream React effects (e.g., title generation) had subscribed — particularly for threads seeded with non-empty messages .
Fix (PR #4072): unstable_on("initialize", ...) now replays the latched event to late subscribers via queueMicrotask, re-checking subscription presence before delivery. runStart/runEnd/modelContextUpdate are unaffected. Consumers of initialize must be idempotent because React StrictMode re-subscribes also trigger a replay. The title binder in RemoteThreadListHookInstanceManager was updated with an hasInitializedRef idempotency guard and a check for state.status === "new" .
Seeded / Initial Messages#
Initial messages can be passed via the standard AI SDK ChatInit.messages prop (since ChatThreadOptions extends ChatInit) and are handled by AI SDK's Chat directly.
For persisted history, the adapters.history (ThreadHistoryAdapter) option enables async loading. The adapter runs a useEffect that calls formatAdapter.load(), converts results to ExportedMessageRepository via toExportedMessageRepository(), imports them into the runtime thread, and syncs back to AI SDK. If no remote thread ID exists, loading is skipped and hasLoaded is immediately set to true .
Timing note: When messages is non-empty at construction, the initialize event fires before late subscribers attach. The microtask replay fix (PR #4072) ensures title generation and other initialize-dependent behaviors still fire correctly for seeded threads .