# LocalRuntime
URL: /docs/runtimes/custom/local

Quickest path to a working chat. Handles state while you handle the API.

***

title: LocalRuntime
description: Quickest path to a working chat. Handles state while you handle the API.
-------------------------------------------------------------------------------------

import { ParametersTable } from "@/components/docs/tables/ParametersTable";
import { InstallCommand } from "@/components/docs/fumadocs/install/install-command";

## Overview

`LocalRuntime` is the simplest way to connect your own custom backend to assistant-ui. It manages all chat state internally while providing a clean adapter interface to connect with any REST API, OpenAI, or custom language model.

`LocalRuntime` provides:

* **Built-in state management** for messages, threads, and conversation history
* **Automatic features** like message editing, reloading, and branch switching
* **Multi-thread support** through [Assistant Cloud](/docs/cloud/overview) or your own database using `useRemoteThreadListRuntime`
* **Simple adapter pattern** to connect any backend API

While LocalRuntime manages state in-memory by default, it offers multiple persistence options through adapters - use the history adapter for single-thread persistence, Assistant Cloud for managed multi-thread support, or implement your own storage with `useRemoteThreadListRuntime`.

## When to Use

Use `LocalRuntime` if you need:

* **Quick setup with minimal configuration** - Get a fully functional chat interface with just a few lines of code
* **Built-in state management** - No need to manage messages, threads, or conversation history yourself
* **Automatic features** - Branch switching, message editing, and regeneration work out of the box
* **API flexibility** - Connect to any REST endpoint, OpenAI, or custom model with a simple adapter
* **Multi-thread support** - Full thread management with Assistant Cloud or custom database
* **Thread persistence** - Via history adapter, Assistant Cloud, or custom thread list adapter

## Key Features

<Cards>
  <Card title="Built-in State Management" description="Automatic handling of messages, threads, and conversation history" />

  <Card title="Multi-Thread Support" description="Full thread management capabilities with Assistant Cloud or custom database adapter" />

  <Card title="Adapter System" description="Extend with attachments, speech, feedback, persistence, and suggestions" />

  <Card title="Tool Calling" description="Support for function calling with human-in-the-loop approval" />
</Cards>

## Getting Started

<Steps>
  <Step>
    ### Create a Next.js project

    ```sh
    npx create-next-app@latest my-app
    cd my-app
    ```
  </Step>

  <Step>
    ### Install `@assistant-ui/react`

    <InstallCommand npm={["@assistant-ui/react"]} />
  </Step>

  <Step>
    ### Add `assistant-ui` Thread component

    ```sh npm2yarn
    npx assistant-ui@latest add thread
    ```
  </Step>

  <Step>
    ### Define a `MyRuntimeProvider` component

    Update the `MyModelAdapter` below to integrate with your own custom API.
    See `LocalRuntimeOptions` [API Reference](#localruntimeoptions) for available configuration options.

    ```tsx twoslash include MyRuntimeProvider title="app/MyRuntimeProvider.tsx"
    // @filename: /app/MyRuntimeProvider.tsx

    // ---cut---
    "use client";

    import type { ReactNode } from "react";
    import {
      AssistantRuntimeProvider,
      useLocalRuntime,
      type ChatModelAdapter,
    } from "@assistant-ui/react";

    const MyModelAdapter: ChatModelAdapter = {
      async run({ messages, abortSignal }) {
        // TODO replace with your own API
        const result = await fetch("<YOUR_API_ENDPOINT>", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          // forward the messages in the chat to the API
          body: JSON.stringify({
            messages,
          }),
          // if the user hits the "cancel" button or escape keyboard key, cancel the request
          signal: abortSignal,
        });

        const data = await result.json();
        return {
          content: [
            {
              type: "text",
              text: data.text,
            },
          ],
        };
      },
    };

    export function MyRuntimeProvider({
      children,
    }: Readonly<{
      children: ReactNode;
    }>) {
      const runtime = useLocalRuntime(MyModelAdapter);

      return (
        <AssistantRuntimeProvider runtime={runtime}>
          {children}
        </AssistantRuntimeProvider>
      );
    }
    ```
  </Step>

  <Step>
    ### Wrap your app in `MyRuntimeProvider`

    ```tsx {1,11,17} twoslash title="app/layout.tsx"
    // @include: MyRuntimeProvider
    // @filename: /app/layout.tsx
    // ---cut---
    import type { ReactNode } from "react";
    import { MyRuntimeProvider } from "@/app/MyRuntimeProvider";

    export default function RootLayout({
      children,
    }: Readonly<{
      children: ReactNode;
    }>) {
      return (
        <MyRuntimeProvider>
          <html lang="en">
            <body>{children}</body>
          </html>
        </MyRuntimeProvider>
      );
    }
    ```
  </Step>

  <Step>
    ### Use the Thread component

    ```tsx title="app/page.tsx"
    import { Thread } from 'components/assistant-ui/thread.tsx'

    export default function Page() {
      return <Thread />;
    }
    ```
  </Step>
</Steps>

## Streaming Responses

Implement streaming by declaring the `run` function as an `AsyncGenerator`.

```tsx twoslash {2, 11-13} title="app/MyRuntimeProvider.tsx"
import {
  ChatModelAdapter,
  ThreadMessage,
  type ModelContext,
} from "@assistant-ui/react";
import { OpenAI } from "openai";

const openai = new OpenAI();
const backendApi = async ({
  messages,
  abortSignal,
  context,
}: {
  messages: readonly ThreadMessage[];
  abortSignal: AbortSignal;
  context: ModelContext;
}) => {
  return openai.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: "Say this is a test" }],
    stream: true,
  });
};

// ---cut---
const MyModelAdapter: ChatModelAdapter = {
  async *run({ messages, abortSignal, context }) {
    const stream = await backendApi({ messages, abortSignal, context });

    let text = "";
    for await (const part of stream) {
      text += part.choices[0]?.delta?.content || "";

      yield {
        content: [{ type: "text", text }],
      };
    }
  },
};
```

### Streaming with Tool Calls

Handle streaming responses that include function calls:

```tsx
const MyModelAdapter: ChatModelAdapter = {
  async *run({ messages, abortSignal, context }) {
    const stream = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: convertToOpenAIMessages(messages),
      tools: context.tools,
      stream: true,
      signal: abortSignal,
    });

    let content = "";
    const toolCalls: any[] = [];

    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta;

      // Handle text content
      if (delta?.content) {
        content += delta.content;
      }

      // Handle tool calls
      if (delta?.tool_calls) {
        for (const toolCall of delta.tool_calls) {
          if (!toolCalls[toolCall.index]) {
            toolCalls[toolCall.index] = {
              id: toolCall.id,
              type: "function",
              function: { name: "", arguments: "" },
            };
          }

          if (toolCall.function?.name) {
            toolCalls[toolCall.index].function.name = toolCall.function.name;
          }

          if (toolCall.function?.arguments) {
            toolCalls[toolCall.index].function.arguments +=
              toolCall.function.arguments;
          }
        }
      }

      // Yield current state
      yield {
        content: [
          ...(content ? [{ type: "text" as const, text: content }] : []),
          ...toolCalls.map((tc) => ({
            type: "tool-call" as const,
            toolCallId: tc.id,
            toolName: tc.function.name,
            args: JSON.parse(tc.function.arguments || "{}"),
          })),
        ],
      };
    }
  },
};
```

## Tool Calling

`LocalRuntime` supports OpenAI-compatible function calling with automatic or human-in-the-loop execution.

### Basic Tool Definition

Tools should be registered using the `Tools()` API with `useAui()`:

```tsx
import { useAui, Tools, type Toolkit } from "@assistant-ui/react";
import { z } from "zod";

// Define your toolkit
const myToolkit: Toolkit = {
  getWeather: {
    description: "Get the current weather in a location",
    parameters: z.object({
      location: z.string().describe("The city and state, e.g. San Francisco, CA"),
      unit: z.enum(["celsius", "fahrenheit"]).default("celsius"),
    }),
    execute: async ({ location, unit }) => {
      const weather = await fetchWeatherAPI(location, unit);
      return weather;
    },
  },
};

// Register tools in your runtime provider
function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
  const runtime = useLocalRuntime(MyModelAdapter);

  // Register all tools
  const aui = useAui({
    tools: Tools({ toolkit: myToolkit }),
  });

  return (
    <AssistantRuntimeProvider aui={aui} runtime={runtime}>
      {children}
    </AssistantRuntimeProvider>
  );
}
```

The tools will be available to your adapter via the `context` parameter in the `run` function. See the [Tools guide](/docs/guides/tools) for more details on tool registration and advanced features.

### Human-in-the-Loop Approval

Require user confirmation before executing certain tools:

```tsx
const runtime = useLocalRuntime(MyModelAdapter, {
  unstable_humanToolNames: ["delete_file", "send_email"],
});
```

### Tool Execution

Tools are executed automatically by the runtime. The model adapter receives tool results in subsequent messages:

```tsx
// Messages will include tool calls and results:
[
  { role: "user", content: "What's the weather in SF?" },
  {
    role: "assistant",
    content: [
      {
        type: "tool-call",
        toolCallId: "call_123",
        toolName: "get_weather",
        args: { location: "San Francisco, CA" },
      },
    ],
  },
  {
    role: "tool",
    content: [
      {
        type: "tool-result",
        toolCallId: "call_123",
        result: { temperature: 72, condition: "sunny" },
      },
    ],
  },
  {
    role: "assistant",
    content: "The weather in San Francisco is sunny and 72°F.",
  },
];
```

## Multi-Thread Support

`LocalRuntime` supports multiple conversation threads through two approaches:

### 1. Assistant Cloud Integration

```tsx
import { useLocalRuntime } from "@assistant-ui/react";
import { AssistantCloud } from "assistant-cloud";

const cloud = new AssistantCloud({
  apiKey: process.env.ASSISTANT_CLOUD_API_KEY,
});

const runtime = useLocalRuntime(MyModelAdapter, {
  cloud, // Enables multi-thread support
});
```

With Assistant Cloud, you get:

* Multiple conversation threads
* Thread persistence across sessions
* Thread management (create, switch, rename, archive, delete)
* Automatic synchronization across devices
* Built-in user authentication

### 2. Custom Database with useRemoteThreadListRuntime

For custom thread storage, use `useRemoteThreadListRuntime` with your own adapter:

```tsx
import {
  unstable_useRemoteThreadListRuntime as useRemoteThreadListRuntime,
  useAui,
  RuntimeAdapterProvider,
  AssistantRuntimeProvider,
  type unstable_RemoteThreadListAdapter as RemoteThreadListAdapter,
  type ThreadHistoryAdapter,
} from "@assistant-ui/react";
import { createAssistantStream } from "assistant-stream";
import { useMemo } from "react";

// Implement your custom adapter with proper message persistence
const myDatabaseAdapter: RemoteThreadListAdapter = {
  async list() {
    const threads = await db.threads.findAll();
    return {
      threads: threads.map((t) => ({
        status: t.archived ? "archived" : "regular",
        remoteId: t.id,
        title: t.title,
      })),
    };
  },

  async initialize(threadId) {
    const thread = await db.threads.create({ id: threadId });
    return { remoteId: thread.id };
  },

  async rename(remoteId, newTitle) {
    await db.threads.update(remoteId, { title: newTitle });
  },

  async archive(remoteId) {
    await db.threads.update(remoteId, { archived: true });
  },

  async unarchive(remoteId) {
    await db.threads.update(remoteId, { archived: false });
  },

  async delete(remoteId) {
    // Delete thread and its messages
    await db.messages.deleteByThreadId(remoteId);
    await db.threads.delete(remoteId);
  },

  async generateTitle(remoteId, messages) {
    // Generate title from messages using your AI
    const newTitle = await generateTitle(messages);

    // Persist the title in your DB
    await db.threads.update(remoteId, { title: newTitle });

    // IMPORTANT: Return an AssistantStream so the UI updates
    return createAssistantStream((controller) => {
      controller.appendText(newTitle);
      controller.close();
    });
  },
};

// Complete implementation with message persistence using Provider pattern
export function MyRuntimeProvider({ children }) {
  const runtime = useRemoteThreadListRuntime({
    runtimeHook: () => {
      return useLocalRuntime(MyModelAdapter);
    },
    adapter: {
      ...myDatabaseAdapter,

      // The Provider component adds thread-specific adapters
      unstable_Provider: ({ children }) => {
        // This runs in the context of each thread
        const aui = useAui();

        // Create thread-specific history adapter
        const history = useMemo<ThreadHistoryAdapter>(
          () => ({
            async load() {
              const { remoteId } = aui.threadListItem().getState();
              if (!remoteId) return { messages: [] };

              const messages = await db.messages.findByThreadId(remoteId);
              return {
                messages: messages.map((m) => ({
                  role: m.role,
                  content: m.content,
                  id: m.id,
                  createdAt: new Date(m.createdAt),
                })),
              };
            },

            async append(message) {
              // Wait for initialization to get remoteId (safe to call multiple times)
              const { remoteId } = await aui.threadListItem().initialize();

              await db.messages.create({
                threadId: remoteId,
                role: message.role,
                content: message.content,
                id: message.id,
                createdAt: message.createdAt,
              });
            },
          }),
          [aui],
        );

        const adapters = useMemo(() => ({ history }), [history]);

        return (
          <RuntimeAdapterProvider adapters={adapters}>
            {children}
          </RuntimeAdapterProvider>
        );
      },
    },
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {children}
    </AssistantRuntimeProvider>
  );
}
```

<Callout type="info" title="Returning a title from generateTitle">
  The `generateTitle` method must return an <code>AssistantStream</code>{" "}
  containing the title text. The easiest, type-safe way is to use{" "}
  <code>createAssistantStream</code> and call{" "}
  <code>controller.appendText(newTitle)</code> followed by{" "}
  <code>controller.close()</code>. Returning a raw <code>ReadableStream</code>{" "}
  won't update the thread list UI.
</Callout>

#### Understanding the Architecture

<Callout type="info">
  **Key Insight**: The `unstable_Provider` component in your adapter runs in the
  context of each thread, giving you access to thread-specific information like
  `remoteId`. This is where you add the history adapter for message persistence.
</Callout>

The complete multi-thread implementation requires:

1. **RemoteThreadListAdapter** - Manages thread metadata (list, create, rename, archive, delete)
2. **unstable\_Provider** - Component that provides thread-specific adapters (like history)
3. **ThreadHistoryAdapter** - Persists messages for each thread (load, append)
4. **runtimeHook** - Creates a basic `LocalRuntime` (adapters are added by Provider)

Without the history adapter, threads would have no message persistence, making them effectively useless. The Provider pattern allows you to add thread-specific functionality while keeping the runtime creation simple.

<Callout type="warn" title="Avoiding Race Conditions">
  When implementing a history adapter, `append()` may be called before the thread is fully initialized, causing the first message to be lost. Instead of checking `if (!remoteId)`, await initialization to ensure the `remoteId` is available:

  ```tsx
  import { useAui } from "@assistant-ui/react";

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

  const history = useMemo<ThreadHistoryAdapter>(
    () => ({
      async append(message) {
        // Wait for initialization - safe to call multiple times
        const { remoteId } = await aui.threadListItem().initialize();
        await db.messages.create({ threadId: remoteId, ...message });
      },
      // ...
    }),
    [aui],
  );
  ```

  See `AssistantCloudThreadHistoryAdapter` in the source for a production reference.
</Callout>

#### Database Schema Example

```typescript
// Example database schema for thread persistence
interface ThreadRecord {
  id: string;
  title: string;
  archived: boolean;
  createdAt: Date;
  updatedAt: Date;
}

interface MessageRecord {
  id: string;
  threadId: string;
  role: "user" | "assistant" | "system";
  content: any; // Store as JSON
  createdAt: Date;
}
```

Both approaches provide full multi-thread support. Choose Assistant Cloud for a managed solution or implement your own adapter for custom storage requirements.

## Adapters

Extend `LocalRuntime` capabilities with adapters. The runtime automatically enables/disables UI features based on which adapters are provided.

### Attachment Adapter

Enable file and image uploads:

```tsx
const attachmentAdapter: AttachmentAdapter = {
  accept: "image/*,application/pdf",
  async add(file) {
    const formData = new FormData();
    formData.append("file", file);

    const response = await fetch("/api/upload", {
      method: "POST",
      body: formData,
    });

    const { id, url } = await response.json();
    return {
      id,
      type: file.type.startsWith("image/") ? "image" : "document",
      name: file.name,
      url,
    };
  },
  async remove(attachment) {
    await fetch(`/api/upload/${attachment.id}`, {
      method: "DELETE",
    });
  },
};

const runtime = useLocalRuntime(MyModelAdapter, {
  adapters: { attachments: attachmentAdapter },
});

// For multiple file types, use CompositeAttachmentAdapter:
const runtime = useLocalRuntime(MyModelAdapter, {
  adapters: {
    attachments: new CompositeAttachmentAdapter([
      new SimpleImageAttachmentAdapter(),
      new SimpleTextAttachmentAdapter(),
      customPDFAdapter,
    ]),
  },
});
```

### Thread History Adapter

Persist and resume conversations:

```tsx
const historyAdapter: ThreadHistoryAdapter = {
  async load() {
    // Load messages from your storage
    const response = await fetch(`/api/thread/current`);
    const { messages } = await response.json();
    return { messages };
  },

  async append(message) {
    // Save new message to storage
    await fetch(`/api/thread/messages`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    });
  },

  // Optional: Resume interrupted conversations
  async resume({ messages }) {
    const lastMessage = messages[messages.length - 1];
    if (lastMessage?.role === "user") {
      // Resume generating assistant response
      const response = await fetch("/api/chat/resume", {
        method: "POST",
        body: JSON.stringify({ messages }),
      });
      return response.body; // Return stream
    }
  },
};

const runtime = useLocalRuntime(MyModelAdapter, {
  adapters: { history: historyAdapter },
});
```

<Callout type="info">
  The history adapter handles persistence for the current thread's messages. For
  multi-thread support with custom storage, use either
  `useRemoteThreadListRuntime` with `LocalRuntime` or `ExternalStoreRuntime`
  with a thread list adapter.
</Callout>

### Speech Synthesis Adapter

Add text-to-speech capabilities:

```tsx
const speechAdapter: SpeechSynthesisAdapter = {
  speak(text) {
    const utterance = new SpeechSynthesisUtterance(text);
    utterance.rate = 1.0;
    utterance.pitch = 1.0;
    speechSynthesis.speak(utterance);
  },

  stop() {
    speechSynthesis.cancel();
  },
};

const runtime = useLocalRuntime(MyModelAdapter, {
  adapters: { speech: speechAdapter },
});
```

### Feedback Adapter

Collect user feedback on messages:

```tsx
const feedbackAdapter: FeedbackAdapter = {
  async submit(feedback) {
    await fetch("/api/feedback", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        messageId: feedback.messageId,
        rating: feedback.type, // "positive" or "negative"
      }),
    });
  },
};

const runtime = useLocalRuntime(MyModelAdapter, {
  adapters: { feedback: feedbackAdapter },
});
```

### Suggestion Adapter

Provide follow-up suggestions:

```tsx
const suggestionAdapter: SuggestionAdapter = {
  async *generate({ messages }) {
    // Analyze conversation context
    const lastMessage = messages[messages.length - 1];

    // Generate suggestions
    const suggestions = await generateSuggestions(lastMessage);

    yield suggestions.map((text) => ({
      id: crypto.randomUUID(),
      text,
    }));
  },
};

const runtime = useLocalRuntime(MyModelAdapter, {
  adapters: { suggestion: suggestionAdapter },
});
```

## Advanced Features

### Resuming a Run

<Callout type="warning">
  The `unstable_resumeRun` method is experimental and may change in future
  releases.
</Callout>

Resume a conversation with a custom stream:

```tsx
import { useThreadRuntime, type ChatModelRunResult } from "@assistant-ui/react";

// Get the thread runtime
const thread = useThreadRuntime();

// Create a custom stream
async function* createCustomStream(): AsyncGenerator<ChatModelRunResult> {
  let text = "Initial response";
  yield {
    content: [{ type: "text", text }],
  };

  // Simulate delay
  await new Promise((resolve) => setTimeout(resolve, 500));

  text = "Initial response. And here's more content...";
  yield {
    content: [{ type: "text", text }],
  };
}

// Resume a run with the custom stream
thread.unstable_resumeRun({
  parentId: "message-id", // ID of the message to respond to
  stream: createCustomStream(), // The stream to use for resuming
});
```

### Custom Thread Management

Access thread runtime for advanced control with `useThreadRuntime`:

```tsx
import { useThreadRuntime } from "@assistant-ui/react";

function MyComponent() {
  const thread = useThreadRuntime();

  // Cancel current generation
  const handleCancel = () => {
    thread.cancelRun();
  };

  // Switch to a different branch
  const handleSwitchBranch = (messageId: string, branchIndex: number) => {
    thread.switchToBranch(messageId, branchIndex);
  };

  // Reload a message
  const handleReload = (messageId: string) => {
    thread.reload(messageId);
  };

  return (
    // Your UI
  );
}
```

## Integration Examples

### OpenAI Integration

```tsx
import { OpenAI } from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  dangerouslyAllowBrowser: true, // Use server-side in production
});

const OpenAIAdapter: ChatModelAdapter = {
  async *run({ messages, abortSignal, context }) {
    const stream = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: messages.map((m) => ({
        role: m.role,
        content: m.content
          .filter((c) => c.type === "text")
          .map((c) => c.text)
          .join("\n"),
      })),
      stream: true,
      signal: abortSignal,
    });

    for await (const chunk of stream) {
      const content = chunk.choices[0]?.delta?.content;
      if (content) {
        yield {
          content: [{ type: "text", text: content }],
        };
      }
    }
  },
};
```

### Custom REST API Integration

```tsx
const CustomAPIAdapter: ChatModelAdapter = {
  async run({ messages, abortSignal, unstable_threadId }) {
    const response = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        messages: messages.map((m) => ({
          role: m.role,
          content: m.content,
        })),
        threadId: unstable_threadId, // Pass thread ID to your backend
      }),
      signal: abortSignal,
    });

    if (!response.ok) {
      throw new Error(`API error: ${response.statusText}`);
    }

    const data = await response.json();
    return {
      content: [{ type: "text", text: data.message }],
    };
  },
};
```

## Best Practices

1. **Error Handling** - Always handle API errors gracefully:

   ```tsx
   async *run({ messages, abortSignal }) {
     try {
       const response = await fetchAPI(messages, abortSignal);
       yield response;
     } catch (error) {
       if (error.name === 'AbortError') {
         // User cancelled - this is normal
         return;
       }
       // Re-throw other errors to display in UI
       throw error;
     }
   }
   ```

2. **Abort Signal** - Always pass the abort signal to fetch requests:

   ```tsx
   fetch(url, { signal: abortSignal });
   ```

3. **Memory Management** - For long conversations, consider implementing message limits:

   ```tsx
   const recentMessages = messages.slice(-20); // Keep last 20 messages
   ```

4. **Type Safety** - Use TypeScript for better development experience:
   ```tsx
   import type { ChatModelAdapter, ThreadMessage } from "@assistant-ui/react";
   ```

## Comparison with `ExternalStoreRuntime`

| Feature               | `LocalRuntime`                               | `ExternalStoreRuntime`                           |
| --------------------- | -------------------------------------------- | ------------------------------------------------ |
| State Management      | Built-in                                     | You manage                                       |
| Setup Complexity      | Simple                                       | More complex                                     |
| Flexibility           | Extensible via adapters                      | Full control                                     |
| Message Editing       | Automatic                                    | Requires `onEdit` handler                        |
| Branch Switching      | Automatic                                    | Requires `setMessages` handler                   |
| Multi-Thread Support  | Yes (with Assistant Cloud or custom adapter) | Yes (with thread list adapter)                   |
| Custom Thread Storage | Yes (with useRemoteThreadListRuntime)        | Yes                                              |
| Persistence           | Via history adapter or Assistant Cloud       | Your implementation                              |
| Best For              | Quick prototypes, standard apps, cloud-based | Complex state requirements, custom storage needs |

## Troubleshooting

### Common Issues

<Callout type="error">
  **Messages not appearing**: Ensure your adapter returns the correct format:

  ```tsx
  return {
    content: [{ type: "text", text: "response" }]
  };
  ```
</Callout>

<Callout type="warning">
  **Streaming not working**: Make sure to use `async *run` (note the asterisk):

  ```tsx
  async *run({ messages }) { // ✅ Correct
  async run({ messages }) {  // ❌ Wrong for streaming
  ```
</Callout>

### Tool UI Flickers or Disappears During Streaming

A common issue when implementing a streaming `ChatModelAdapter` is seeing a tool's UI appear for a moment and then disappear. This is caused by failing to accumulate the `tool_calls` correctly across multiple stream chunks. State must be stored **outside** the streaming loop to persist.

**❌ Incorrect: Forgetting Previous Tool Calls**

This implementation incorrectly re-creates the `content` array for every chunk. If a later chunk contains only text, tool calls from previous chunks are lost, causing the UI to disappear.

```tsx
// This implementation incorrectly re-creates the `content` array for every chunk.
// If a later chunk contains only text, tool calls from previous chunks are lost.
async *run({ messages, abortSignal, context }) {
  const stream = await backendApi({ messages, abortSignal, context });
  let text = "";

  for await (const chunk of stream) {
    // ❌ DON'T: This overwrites toolCalls with only the current chunk's data
    const toolCalls = chunk.tool_calls || [];
    const content = [{ type: "text", text }];
    for (const toolCall of toolCalls) {
      content.push({
        type: "tool-call",
        toolName: toolCall.name,
        toolCallId: toolCall.id,
        args: toolCall.args,
      });
    }
    yield { content }; // This yield might not contain the tool call anymore
  }
}
```

**✅ Correct: Accumulating State**

This implementation uses a `Map` outside the loop to remember all tool calls.

```tsx
// This implementation uses a Map outside the loop to remember all tool calls.
async *run({ messages, abortSignal, context }) {
  const stream = await backendApi({ messages, abortSignal, context });
  let text = "";
  // ✅ DO: Declare state outside the loop
  const toolCallsMap = new Map();

  for await (const chunk of stream) {
    text += chunk.content || "";

    // ✅ DO: Add/update tool calls in the persistent map
    for (const toolCall of chunk.tool_calls || []) {
      toolCallsMap.set(toolCall.toolCallId, {
        type: "tool-call",
        toolName: toolCall.name,
        toolCallId: toolCall.toolCallId,
        args: toolCall.args,
      });
    }

    // ✅ DO: Build content from accumulated state
    const content = [
      ...(text ? [{ type: "text", text }] : []),
      ...Array.from(toolCallsMap.values()),
    ];

    yield { content }; // Yield the complete, correct state every time
  }
}
```

### Debug Tips

1. **Log adapter calls** to trace execution:

   ```tsx
   async *run(options) {
     console.log("Adapter called with:", options);
     // ... rest of implementation
   }
   ```

2. **Check network requests** in browser DevTools

3. **Verify message format** matches ThreadMessage structure

## API Reference

### `ChatModelAdapter`

The main interface for connecting your API to `LocalRuntime`.

<ParametersTable
  type="ChatModelAdapter"
  parameters={[
  {
    name: "run",
    type: "ChatModelRunOptions => ChatModelRunResult | AsyncGenerator<ChatModelRunResult>",
    description:
      "Function that sends messages to your API and returns the response",
    required: true,
  },
]}
/>

### `ChatModelRunOptions`

Parameters passed to the `run` function.

<ParametersTable
  type="ChatModelRunOptions"
  parameters={[
  {
    name: "messages",
    type: "readonly ThreadMessage[]",
    description: "The conversation history to send to your API",
    required: true,
  },
  {
    name: "abortSignal",
    type: "AbortSignal",
    description: "Signal to cancel the request if user interrupts",
    required: true,
  },
  {
    name: "context",
    type: "ModelContext",
    description: "Additional context including configuration and tools",
    required: true,
  },
  {
    name: "unstable_threadId",
    type: "string | undefined",
    description: "The current thread/conversation identifier. Useful for passing to your backend API.",
  },
]}
/>

### `LocalRuntimeOptions`

Configuration options for the `LocalRuntime`.

<ParametersTable
  type="LocalRuntimeOptions"
  parameters={[
  {
    name: "initialMessages",
    type: "readonly ThreadMessage[]",
    description: "Pre-populate the thread with messages",
  },
  {
    name: "maxSteps",
    type: "number",
    description:
      "Maximum number of sequential tool calls before requiring user input",
    default: "5",
  },
  {
    name: "cloud",
    type: "AssistantCloud",
    description:
      "Enable Assistant Cloud integration for multi-thread support and persistence",
  },
  {
    name: "adapters",
    type: "LocalRuntimeAdapters",
    description:
      "Additional capabilities through adapters. Features are automatically enabled based on provided adapters",
    children: [
      {
        type: "adapters",
        parameters: [
          {
            name: "attachments",
            type: "AttachmentAdapter",
            description: "Enable file/image attachments",
          },
          {
            name: "speech",
            type: "SpeechSynthesisAdapter",
            description: "Enable text-to-speech for messages",
          },
          {
            name: "feedback",
            type: "FeedbackAdapter",
            description: "Enable message feedback (thumbs up/down)",
          },
          {
            name: "history",
            type: "ThreadHistoryAdapter",
            description: "Enable thread persistence and resumption",
          },
          {
            name: "suggestions",
            type: "SuggestionAdapter",
            description: "Enable follow-up suggestions",
          },
        ],
      },
    ],
  },
  {
    name: "unstable_humanToolNames",
    type: "string[]",
    description:
      "Tool names that require human approval before execution (experimental API)",
  },
]}
/>

### `RemoteThreadListAdapter`

Interface for implementing custom thread list storage.

<ParametersTable
  type="RemoteThreadListAdapter"
  parameters={[
  {
    name: "list",
    type: "() => Promise<RemoteThreadListResponse>",
    description: "Returns list of all threads (regular and archived)",
    required: true,
  },
  {
    name: "initialize",
    type: "(threadId: string) => Promise<RemoteThreadInitializeResponse>",
    description: "Creates a new thread with the given ID",
    required: true,
  },
  {
    name: "rename",
    type: "(remoteId: string, newTitle: string) => Promise<void>",
    description: "Updates the title of a thread",
    required: true,
  },
  {
    name: "archive",
    type: "(remoteId: string) => Promise<void>",
    description: "Archives a thread",
    required: true,
  },
  {
    name: "unarchive",
    type: "(remoteId: string) => Promise<void>",
    description: "Unarchives a thread",
    required: true,
  },
  {
    name: "delete",
    type: "(remoteId: string) => Promise<void>",
    description: "Deletes a thread permanently",
    required: true,
  },
  {
    name: "generateTitle",
    type: "(remoteId: string, messages: readonly ThreadMessage[]) => Promise<AssistantStream>",
    description: "Generates a title for the thread based on the conversation",
    required: true,
  },
]}
/>

### Related Runtime APIs

* [AssistantRuntime API](/docs/api-reference/runtimes/assistant-runtime) - Core runtime interface and methods
* [ThreadRuntime API](/docs/api-reference/runtimes/thread-runtime) - Thread-specific operations and state management

## Related Resources

* [Runtime Layer Concepts](/docs/concepts/runtime-layer)
* [Pick a Runtime Guide](/docs/runtimes/pick-a-runtime)
* [`ExternalStoreRuntime`](/docs/runtimes/custom/external-store)
* [Examples Repository](https://github.com/assistant-ui/assistant-ui/tree/main/examples)
