# Custom Backend
URL: /docs/ink/custom-backend

Connect your terminal app to your own backend API.

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

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`](/docs/ink/adapters#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`.

1. ### 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);
       });
     },
   };
   ```

2. ### 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            |