Documents
custom-backend
custom-backend
Type
External
Status
Published
Created
Mar 17, 2026
Updated
May 24, 2026
Updated by
Dosu Bot
Source
View

By default, useLocalRuntime manages threads and messages in memory. You can connect to your own backend in two ways depending on your needs.

Option 1: ChatModelAdapter only#

The simplest approach — keep thread management local, but send messages to your backend for inference.

import type { ChatModelAdapter } from "@assistant-ui/react-ink";

export const myChatAdapter: ChatModelAdapter = {
  async *run({ messages, abortSignal }) {
    const response = await fetch("https://my-api.com/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages }),
      signal: abortSignal,
    });

    const reader = response.body?.getReader();
    if (!reader) throw new Error("No response body");

    const decoder = new TextDecoder();
    let fullText = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      fullText += chunk;
      yield { content: [{ type: "text", text: fullText }] };
    }
  },
};
import { useLocalRuntime, AssistantRuntimeProvider } from "@assistant-ui/react-ink";
import { myChatAdapter } from "./adapters/my-chat-adapter.js";

export function App() {
  const runtime = useLocalRuntime(myChatAdapter);
  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your chat UI */}
    </AssistantRuntimeProvider>
  );
}

This gives you:

  • Streaming chat responses from your API
  • In-memory thread list (lost on process exit)
  • Multi-thread support

Option 2: Local file persistence#

When you want threads and messages to survive across sessions without running a backend, use createFileStorageAdapter. It writes each thread to a JSON file on disk and plugs into useRemoteThreadListRuntime.

import { join } from "node:path";
import { homedir } from "node:os";
import {
  useLocalRuntime,
  useRemoteThreadListRuntime,
  createFileStorageAdapter,
  AssistantRuntimeProvider,
} from "@assistant-ui/react-ink";
import { myChatAdapter } from "./adapters/my-chat-adapter.js";

const threadListAdapter = createFileStorageAdapter({
  dir: join(homedir(), ".my-cli", "threads"),
});

function useAppRuntime() {
  return useRemoteThreadListRuntime({
    runtimeHook: () => useLocalRuntime(myChatAdapter),
    adapter: threadListAdapter,
  });
}

export function App() {
  const runtime = useAppRuntime();
  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your chat UI */}
    </AssistantRuntimeProvider>
  );
}

Writes are atomic (temp file + rename), so a crash mid-write cannot leave a partial JSON file. The directory is created lazily on first write.

Options#

OptionDescription
dirDirectory where thread files are stored. Created if missing.
prefixKey prefix for stored files. Defaults to @assistant-ui:. Useful when two apps share a directory.
titleGeneratorOptional TitleGenerationAdapter that auto-generates thread titles from the first messages. Pass createSimpleTitleAdapter() for the built-in implementation.

When this fits#

Designed for single-process terminal apps where one user has one CLI running at a time. The wrapped read-modify-write on the thread list isn't lock-safe, so two CLI processes pointed at the same directory can lose updates to thread metadata (rename, archive). If that's your scenario, use Option 3 instead.

Option 3: Full backend thread management#

When you want your backend to own thread state (e.g. for persistence across sessions, team sharing, or server-side history), implement a RemoteThreadListAdapter.

Implement the adapter#

import type { RemoteThreadListAdapter } from "@assistant-ui/react-ink";
import { createAssistantStream } from "assistant-stream";

const API_BASE = "https://my-api.com";

export const myThreadListAdapter: RemoteThreadListAdapter = {
  async list() {
    const res = await fetch(`${API_BASE}/threads`);
    const threads = await res.json();
    return {
      threads: threads.map((t: any) => ({
        remoteId: t.id,
        status: t.archived ? "archived" : "regular",
        title: t.title,
      })),
    };
  },

  async initialize(localId) {
    const res = await fetch(`${API_BASE}/threads`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ localId }),
    });
    const { id } = await res.json();
    return { remoteId: id, externalId: undefined };
  },

  async rename(remoteId, title) {
    await fetch(`${API_BASE}/threads/${remoteId}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title }),
    });
  },

  async archive(remoteId) {
    await fetch(`${API_BASE}/threads/${remoteId}/archive`, {
      method: "POST",
    });
  },

  async unarchive(remoteId) {
    await fetch(`${API_BASE}/threads/${remoteId}/unarchive`, {
      method: "POST",
    });
  },

  async delete(remoteId) {
    await fetch(`${API_BASE}/threads/${remoteId}`, { method: "DELETE" });
  },

  async fetch(remoteId) {
    const res = await fetch(`${API_BASE}/threads/${remoteId}`);
    const t = await res.json();
    return {
      remoteId: t.id,
      status: t.archived ? "archived" : "regular",
      title: t.title,
    };
  },

  async generateTitle(remoteId, unstable_messages) {
    return createAssistantStream(async (controller) => {
      const res = await fetch(`${API_BASE}/threads/${remoteId}/title`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: unstable_messages }),
      });
      const { title } = await res.json();
      controller.appendText(title);
    });
  },
};

Compose the runtime#

import {
  useLocalRuntime,
  useRemoteThreadListRuntime,
  AssistantRuntimeProvider,
} from "@assistant-ui/react-ink";
import { myChatAdapter } from "./adapters/my-chat-adapter.js";
import { myThreadListAdapter } from "./adapters/my-thread-list-adapter.js";

function useAppRuntime() {
  return useRemoteThreadListRuntime({
    runtimeHook: () => useLocalRuntime(myChatAdapter),
    adapter: myThreadListAdapter,
  });
}

export function App() {
  const runtime = useAppRuntime();
  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your chat UI */}
    </AssistantRuntimeProvider>
  );
}

Adapter methods#

MethodDescription
list()Return all threads on mount
initialize(localId)Create a thread server-side, return { remoteId }
rename(remoteId, title)Persist title changes
archive(remoteId)Mark thread as archived
unarchive(remoteId)Restore archived thread
delete(remoteId)Permanently remove thread
fetch(remoteId)Fetch single thread metadata
generateTitle(remoteId, unstable_messages)Return an AssistantStream with the generated title

Which option to choose?#

Option 1: ChatModelAdapterOption 2: createFileStorageAdapterOption 3: RemoteThreadListAdapter
Thread storageIn-memory (process lifetime)Local diskYour backend
Message storageIn-memoryLocal diskIn-memory (can add history adapter for server-side)
Cross-session persistenceNoYesYes
Multi-process safeN/ANoDepends on backend
Setup complexityMinimalMinimalModerate
Best forDemos, prototypesLocal CLI toolsProduction apps with sync / team sharing