# Custom Thread List URL: /docs/runtimes/custom/custom-thread-list Plug a custom thread database for multi-thread persistence. *** title: Custom Thread List description: Plug a custom thread database for multi-thread persistence. ------------------------------------------------------------------------ import { ParametersTable } from "@/components/docs/tables/ParametersTable"; ## Overview `useRemoteThreadListRuntime` lets you plug a custom thread database into assistant-ui. It keeps the UI and local runtime logic in sync while you provide persistence, archiving, and metadata for every conversation. The hook is exported as `unstable_useRemoteThreadListRuntime`; we refer to it here as **Custom Thread List**. ## When to Use Use a Custom Thread List when you need to: * Persist conversations in your own database or multitenant backend * Share threads across devices, teams, or long-lived sessions * Control thread metadata (titles, archived state, external identifiers) * Layer additional adapters (history, attachments) around each thread runtime ## How It Works Custom Thread List merges two pieces of state: 1. **Per-thread runtime** – powered by any runtime hook (for example `useLocalRuntime` or `useAssistantTransportRuntime`). 2. **Thread list adapter** – your adapter that reads and writes thread metadata in a remote store. When the hook mounts it calls `list()` on your adapter, hydrates existing threads, and uses your runtime hook to spawn a runtime whenever a thread is opened. Creating a new conversation calls `initialize(threadId)` so you can create a record server-side and return the canonical `remoteId`. The built-in Assistant Cloud runtime is implemented with the same API. Inspect `useCloudThreadListAdapter` for a production-ready reference adapter. ## Build a Custom Thread List ### Provide a runtime per thread Use any runtime hook that returns an `AssistantRuntime`. In most custom setups this is `useLocalRuntime(modelAdapter)` or `useAssistantTransportRuntime(...)`. ### Implement the adapter contract Your adapter decides how threads are stored. Implement the methods in the table below to connect to your database or API. ### Compose the provider Wrap `AssistantRuntimeProvider` with the runtime returned from the Custom Thread List hook. ```tsx twoslash title="app/CustomThreadListProvider.tsx" // @filename: app/model-adapter.ts export const myModelAdapter = {} as any; // @filename: app/CustomThreadListProvider.tsx // ---cut--- "use client"; import type { ReactNode } from "react"; import { AssistantRuntimeProvider, useLocalRuntime, unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime, type unstable_RemoteThreadListAdapter as RemoteThreadListAdapter, } from "@assistant-ui/react"; import { createAssistantStream } from "assistant-stream"; import { myModelAdapter } from "./model-adapter"; // your chat model adapter const threadListAdapter: RemoteThreadListAdapter = { async list() { const response = await fetch("/api/threads"); const threads = await response.json(); return { threads: threads.map((thread: any) => ({ remoteId: thread.id, externalId: thread.external_id ?? undefined, status: thread.is_archived ? "archived" : "regular", title: thread.title ?? undefined, })), }; }, async initialize(localId) { const response = await fetch("/api/threads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ localId }), }); const result = await response.json(); return { remoteId: result.id, externalId: result.external_id }; }, async rename(remoteId, title) { await fetch(`/api/threads/${remoteId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); }, async archive(remoteId) { await fetch(`/api/threads/${remoteId}/archive`, { method: "POST" }); }, async unarchive(remoteId) { await fetch(`/api/threads/${remoteId}/unarchive`, { method: "POST" }); }, async delete(remoteId) { await fetch(`/api/threads/${remoteId}`, { method: "DELETE" }); }, async fetch(remoteId) { const response = await fetch(`/api/threads/${remoteId}`); const thread = await response.json(); return { status: thread.is_archived ? "archived" : "regular", remoteId: thread.id, title: thread.title, }; }, async generateTitle(remoteId, messages) { return createAssistantStream(async (controller) => { const response = await fetch(`/api/threads/${remoteId}/title`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages }), }); const { title } = await response.json(); controller.appendText(title); }); }, }; export function CustomThreadListProvider({ children, }: Readonly<{ children: ReactNode }>) { const runtime = useRemoteThreadListRuntime({ runtimeHook: () => useLocalRuntime(myModelAdapter), adapter: threadListAdapter, }); return ( {children} ); } ``` ## Adapter Responsibilities Promise<{ threads: RemoteThreadMetadata[] }>", description: "Return the current threads. Each thread must include status, remoteId, and any metadata you want to show immediately.", required: true, }, { name: "initialize", type: "(localId: string) => Promise<{ remoteId: string; externalId?: string }>", description: "Create a new remote record when the user starts a conversation. Return the canonical ids so later operations target the right thread.", required: true, }, { name: "rename", type: "(remoteId: string, title: string) => Promise", description: "Persist title changes triggered from the UI.", required: true, }, { name: "archive", type: "(remoteId: string) => Promise", description: "Mark the thread as archived in your system.", required: true, }, { name: "unarchive", type: "(remoteId: string) => Promise", description: "Restore an archived thread to the active list.", required: true, }, { name: "delete", type: "(remoteId: string) => Promise", description: "Permanently remove the thread and stop rendering it.", required: true, }, { name: "generateTitle", type: "(remoteId: string, unstable_messages: readonly ThreadMessage[]) => Promise", description: "Return a streaming title generator. You can reuse your model endpoint or queue a background job.", required: true, }, { name: "unstable_Provider", type: "ComponentType", description: "Optional wrapper rendered around all thread runtimes. Use it to inject adapters such as history or attachments (see the Cloud adapter).", }, ]} /> ## Thread Lifecycle Cheatsheet * `list()` hydrates threads on mount and during refreshes. * Creating a new conversation calls `initialize()` once the user sends the first message. * `archive`, `unarchive`, and `delete` are called optimistically; throw to revert the UI. * `generateTitle()` powers the automatic title button and expects an `AssistantStream`. * Provide a `runtimeHook` that always returns a fresh runtime instance per active thread. ## Avoiding Race Conditions in History Adapters When implementing a custom history adapter, you must await thread initialization before saving messages. Failing to do so can cause the first message to be lost due to a race condition. If you're building a history adapter that persists messages to your own database, use `api.threadListItem().initialize()` to ensure the thread is fully initialized before saving: ```tsx import { useAssistantApi } from "@assistant-ui/react"; // Inside your unstable_Provider component const api = useAssistantApi(); const history = useMemo( () => ({ async append(message) { // Wait for initialization to complete and get the remoteId const { remoteId } = await api.threadListItem().initialize(); // Now safe to save the message using the remoteId await saveMessageToDatabase(remoteId, message); }, // ... }), [api], ); ``` The `initialize()` method: * Can be called multiple times safely * Always waits for the initial `initialize()` call to complete * Returns the same `remoteId` on subsequent calls See `AssistantCloudThreadHistoryAdapter` in the source code for a production-ready reference implementation. ## Optional Adapters If you need history or attachment support, expose them via `unstable_Provider`. The cloud implementation wraps each thread runtime with `RuntimeAdapterProvider` to inject: * `history` – e.g. `useAssistantCloudThreadHistoryAdapter` * `attachments` – e.g. `CloudFileAttachmentAdapter` Reuse that pattern to register any capability your runtime requires.