Documents
custom-thread-list
custom-thread-list
Type
External
Status
Published
Created
Mar 17, 2026
Updated
Mar 17, 2026

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.

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,
  useRemoteThreadListRuntime,
  type 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, unstable_messages) {
    return createAssistantStream(async (controller) => {
      const response = await fetch(`/api/threads/${remoteId}/title`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: unstable_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 (
    <AssistantRuntimeProvider runtime={runtime}>
      {children}
    </AssistantRuntimeProvider>
  );
}
```

Adapter Responsibilities#

<ParametersTable
type="RemoteThreadListAdapter"
parameters={[
{
name: "list",
type: "() => 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: "fetch",
type: "(threadId: string) => Promise",
description:
"Fetch metadata for a specific thread. Used when switching to a thread not in the initial list.",
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 aui.threadListItem().initialize() to ensure the thread is fully initialized before saving:

import { useAui } from "@assistant-ui/store";

// Inside your unstable_Provider component
const aui = useAui();

const history = useMemo<ThreadHistoryAdapter>(
  () => ({
    async append(message) {
      // Wait for initialization to complete and get the remoteId
      const { remoteId } = await aui.threadListItem().initialize();

      // Now safe to save the message using the remoteId
      await saveMessageToDatabase(remoteId, message);
    },
    // ...
  }),
  [aui],
);

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.