Custom Backend

Connect your terminal app to your own backend API.

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.

adapters/my-chat-adapter.ts
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 }] };
    }
  },
};
app.tsx
import { useLocalRuntime, AssistantProvider } from "@assistant-ui/react-ink";
import { myChatAdapter } from "./adapters/my-chat-adapter.js";

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

This gives you:

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

Option 2: 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

adapters/my-thread-list-adapter.ts
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, 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 }),
      });
      const { title } = await res.json();
      controller.appendText(title);
    });
  },
};

Compose the runtime

app.tsx
import {
  useLocalRuntime,
  useRemoteThreadListRuntime,
  AssistantProvider,
} 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 (
    <AssistantProvider runtime={runtime}>
      {/* your chat UI */}
    </AssistantProvider>
  );
}

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, messages)Return an AssistantStream with the generated title

Which option to choose?

Option 1: ChatModelAdapterOption 2: RemoteThreadListAdapter
Thread storageIn-memory (process lifetime)Your backend
Message storageIn-memoryOn-device (can add history adapter for server-side)
Cross-session persistenceNoYes
Setup complexityMinimalModerate
Best forCLI tools, demos, prototypesProduction apps with persistence