# LocalRuntime URL: /docs/runtimes/custom/local-runtime Quickest path to a working chat. Handles state while you handle the API. `LocalRuntime` is the simplest way to connect a custom backend. You implement a single `ChatModelAdapter` (one `run` function) and the runtime handles everything else: messages, threads, branching, editing, regeneration, cancellation. State lives inside the runtime by default. Multi-thread persistence and shared adapters are added via the standard interfaces, see [adapters](/docs/runtimes/concepts/adapters) and [threads](/docs/runtimes/concepts/threads). ## When to use it \[#when-to-use-it] Pick `LocalRuntime` when: * You want assistant-ui to manage chat state for you. * Your backend exposes a function-call shaped API (REST, OpenAI SDK, your own model client). * Branching, editing, and regeneration should work without you writing extra code. * You want to compose adapters (attachments, speech, feedback, history, suggestions). If you already keep messages in redux, zustand, tanstack-query, or another store, use [`ExternalStoreRuntime`](/docs/runtimes/custom/external-store) instead. ## Quickstart \[#quickstart] ### Create a Next.js project \[#create-a-nextjs-project] ```sh npx create-next-app@latest my-app cd my-app ``` ### Install `@assistant-ui/react` \[#install-assistant-uireact] ### Add the Thread component \[#add-the-thread-component] ```sh npx assistant-ui@latest add thread ``` ### Define a `MyRuntimeProvider` \[#define-a-myruntimeprovider] Replace the `MyModelAdapter` body with your backend call. ```tsx title="app/MyRuntimeProvider.tsx" "use client"; import type { ReactNode } from "react"; import { AssistantRuntimeProvider, useLocalRuntime, type ChatModelAdapter, } from "@assistant-ui/react"; const MyModelAdapter: ChatModelAdapter = { async run({ messages, abortSignal }) { const result = await fetch("", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages }), 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 ( {children} ); } ``` ### Wrap your app \[#wrap-your-app] ```tsx title="app/layout.tsx" import type { ReactNode } from "react"; import { MyRuntimeProvider } from "@/app/MyRuntimeProvider"; export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ); } ``` ### Render the Thread \[#render-the-thread] ```tsx title="app/page.tsx" import { Thread } from "@/components/assistant-ui/thread"; export default function Page() { return ; } ``` ## Streaming responses \[#streaming-responses] Declare `run` as an `async *` generator and yield the full cumulative content on each iteration: ```tsx import { ChatModelAdapter, ThreadMessage, type ModelContext, } from "@assistant-ui/react"; import { OpenAI } from "openai"; const openai = new OpenAI(); const MyModelAdapter: ChatModelAdapter = { async *run({ messages, abortSignal, context }) { const stream = await openai.chat.completions.create({ model: "gpt-4o", messages: convertToOpenAIMessages(messages), stream: true, signal: abortSignal, }); let text = ""; for await (const part of stream) { text += part.choices[0]?.delta?.content || ""; yield { content: [{ type: "text", text }], }; } }, }; ``` Each yield replaces the previous content. Yield the full state every time, not deltas. ### Streaming with tool calls \[#streaming-with-tool-calls] Accumulate tool calls in a `Map` outside the streaming loop so they persist across chunks: ```tsx 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 text = ""; const toolCallsMap = new Map(); for await (const chunk of stream) { text += chunk.choices[0]?.delta?.content ?? ""; for (const toolCall of chunk.choices[0]?.delta?.tool_calls ?? []) { toolCallsMap.set(toolCall.id, { type: "tool-call", toolName: toolCall.function?.name, toolCallId: toolCall.id, args: JSON.parse(toolCall.function?.arguments ?? "{}"), }); } yield { content: [ ...(text ? [{ type: "text" as const, text }] : []), ...Array.from(toolCallsMap.values()), ], }; } } ``` If you build the `content` array fresh from the current chunk each iteration, tool calls from earlier chunks will disappear when a later chunk carries only text. The Map outside the loop is the fix. ## Tool calling \[#tool-calling] `LocalRuntime` supports OpenAI-compatible function calling. Register tools through `useAui` so the runtime exposes them to your adapter via `context.tools`: ```tsx import { useAui, Tools, type Toolkit } from "@assistant-ui/react"; import { z } from "zod"; const myToolkit: Toolkit = { getWeather: { description: "Get the current weather in a location", parameters: z.object({ location: z.string(), unit: z.enum(["celsius", "fahrenheit"]).default("celsius"), }), execute: async ({ location, unit }) => fetchWeather(location, unit), }, }; function MyRuntimeProvider({ children }: { children: React.ReactNode }) { const runtime = useLocalRuntime(MyModelAdapter); const aui = useAui({ tools: Tools({ toolkit: myToolkit }) }); return ( {children} ); } ``` See the [tools guide](/docs/guides/tools) for advanced patterns. ### Human-in-the-loop approval \[#human-in-the-loop-approval] Require user confirmation before specific tools execute: ```ts const runtime = useLocalRuntime(MyModelAdapter, { unstable_humanToolNames: ["delete_file", "send_email"], }); ``` `unstable_humanToolNames` is unstable; see [stability](/docs/runtimes/concepts/stability). ## Resuming a run \[#resuming-a-run] `resumeRun` reconnects to an in-progress assistant run. Useful for page refresh, network reconnect, tab backgrounding, or thread switching when the backend is still generating. Unlike `startRun` (which uses the `ChatModelAdapter`), `resumeRun` requires a `stream` parameter; you provide the async generator that produces the response. ```tsx import { useAui } from "@assistant-ui/react"; import type { ChatModelRunResult } from "@assistant-ui/core"; const aui = useAui(); async function* createCustomStream(): AsyncGenerator { yield { content: [{ type: "text", text: "Initial response" }] }; await new Promise((r) => setTimeout(r, 500)); yield { content: [ { type: "text", text: "Initial response. And here's more content..." }, ], }; } aui.thread().resumeRun({ parentId: "message-id", stream: createCustomStream, }); ``` A common pattern is to check whether the backend is still running on mount, then reconnect: ```tsx function useStreamReconnect(threadId: string) { const aui = useAui(); const checkedRef = useRef(false); useEffect(() => { if (checkedRef.current) return; checkedRef.current = true; (async () => { const status = await fetch(`/api/status/${threadId}`).then((r) => r.json(), ); if (status.isRunning) { const parentId = aui.thread().getState().messages.at(-1)?.id ?? null; aui.thread().resumeRun({ parentId }); } })(); }, [aui, threadId]); } ``` ## Adapters \[#adapters] Attachments, speech, feedback, history, and suggestions are wired through the standard adapter contracts, see [adapters](/docs/runtimes/concepts/adapters): ```tsx const runtime = useLocalRuntime(MyModelAdapter, { adapters: { attachments: myAttachmentAdapter, speech: mySpeechAdapter, feedback: myFeedbackAdapter, history: myHistoryAdapter, suggestion: mySuggestionAdapter, }, }); ``` ## Multi-thread \[#multi-thread] `LocalRuntime` supports multi-thread either via [AssistantCloud](/docs/cloud) or via a custom `RemoteThreadListAdapter`. See [threads](/docs/runtimes/concepts/threads) for the contract and full examples. ```tsx // managed (see "AssistantCloud" in /docs/runtimes/concepts/threads for cloud setup) const runtime = useLocalRuntime(MyModelAdapter, { cloud }); ``` ## Integration examples \[#integration-examples] ### OpenAI \[#openai] ```tsx import { OpenAI } from "openai"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); 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, }); let fullText = ""; for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) { fullText += content; yield { content: [{ type: "text", text: fullText }] }; } } }, }; ``` ### Custom REST API \[#custom-rest-api] ```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, }), 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 \[#best-practices] 1. **Always pass `abortSignal`** to `fetch` and SDK calls so cancel works: ```tsx fetch(url, { signal: abortSignal }); ``` 2. **Handle errors gracefully.** Swallow `AbortError` (it is the user cancelling); rethrow others to surface in the UI. 3. **Yield cumulative state, not deltas.** Each yield replaces the previous content; if you yield deltas the UI flickers. 4. **Accumulate tool calls outside the streaming loop**, otherwise they vanish on the first text-only chunk. ## Troubleshooting \[#troubleshooting] **Messages not appearing.** Ensure your adapter returns the correct shape: `{ content: [{ type: "text", text: "..." }] }`. **Streaming not working.** Use `async *run` (with the asterisk). A plain `async run` cannot yield. **Tool UI flickers and disappears.** state is being reset between chunks. Accumulate tool calls in a `Map` declared outside the `for await` loop. ## API reference \[#api-reference] ### `ChatModelAdapter` \[#chatmodeladapter] ### `ChatModelRunOptions` \[#chatmodelrunoptions] ### `LocalRuntimeOptions` \[#localruntimeoptions] ## Related \[#related]