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.
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
| Option | Description |
|---|---|
dir | Directory where thread files are stored. Created if missing. |
prefix | Key prefix for stored files. Defaults to @assistant-ui:. Useful when two apps share a directory. |
titleGenerator | Optional 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
| Method | Description |
|---|---|
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: ChatModelAdapter | Option 2: createFileStorageAdapter | Option 3: RemoteThreadListAdapter | |
|---|---|---|---|
| Thread storage | In-memory (process lifetime) | Local disk | Your backend |
| Message storage | In-memory | Local disk | In-memory (can add history adapter for server-side) |
| Cross-session persistence | No | Yes | Yes |
| Multi-process safe | N/A | No | Depends on backend |
| Setup complexity | Minimal | Minimal | Moderate |
| Best for | Demos, prototypes | Local CLI tools | Production apps with sync / team sharing |