Run assistant-ui in an Electron renderer with a hosted backend or an AI SDK route in the main process.
assistant-ui works in Electron through @assistant-ui/react. The renderer is a React DOM environment, so there is no separate @assistant-ui/electron package to install. The Electron-specific decision is where model requests run and how the renderer reaches them.
Warning
Never put a provider API key in renderer code, a VITE_* variable, or a preload script. Bundled values are readable by anyone with the app. Keep remote credentials on your backend, or keep local credentials in the main process and provision them with an OS-backed secret store.
Choose a connection pattern
| Pattern | Use it when | Runtime |
|---|---|---|
| Hosted backend | You already have an AI SDK endpoint, need server auth or persistence, or ship the app to other people | useChatRuntime with an absolute HTTPS URL |
| Local main process | The desktop app owns the provider or agent process and must work without your web backend | useChatRuntime with a route served by protocol.handle |
Pattern 1: hosted backend
This is the smallest integration. Keep your existing AI SDK chat route and point AssistantChatTransport at its public URL.
import type { ReactNode } from "react";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/ai-sdk";
const transport = new AssistantChatTransport({
api: "https://api.example.com/chat",
});
export function ElectronRuntimeProvider({ children }: { children: ReactNode }) {
const runtime = useChatRuntime({ transport });
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}The URL must be absolute in a packaged app. A relative value such as /api/chat targets the renderer's file:// or custom-protocol origin, not your deployed backend. Configure the backend's CORS policy for the packaged origin, allow the endpoint in connect-src, and authenticate requests as you would from any desktop client.
The endpoint must return the AI SDK UI message stream consumed by AssistantChatTransport. See the AI SDK runtime guide for the backend route and tool-calling setup.
Pattern 2: local main process
Use this pattern when the desktop app calls the model itself and must work without your web backend. Electron's protocol.handle answers renderer fetch calls with a (Request) => Response handler, so the main process runs the same AI SDK route a web app would, and the renderer keeps AssistantChatTransport. It needs no preload script or IPC messages. The renderer gets the full UI message stream, so tool calls, reasoning, and attachments work as they do on the web.
This pattern needs Electron 44.4 or later (or 43.7 and later on the 43 line), the first releases that set request.initiatorOrigin on protocol requests.
1. Serve the renderer and the route from one scheme
import path from "node:path";
import { pathToFileURL } from "node:url";
import { app, BrowserWindow, net, protocol, session } from "electron";
import { POST } from "./api/chat";
protocol.registerSchemesAsPrivileged([
{
scheme: "app",
privileges: { standard: true, secure: true, supportFetchAPI: true },
},
]);
const APP_ORIGIN = "app://local";
const rendererRoot = path.join(__dirname, "renderer");
function serveRenderer(url: URL) {
const pathname =
url.pathname === "/" ? "/index.html" : decodeURIComponent(url.pathname);
const filePath = path.join(rendererRoot, pathname);
if (!filePath.startsWith(rendererRoot + path.sep)) {
return new Response("Not found", { status: 404 });
}
return net.fetch(pathToFileURL(filePath).toString());
}
app.whenReady().then(() => {
const assistantSession = session.fromPartition("persist:assistant");
assistantSession.protocol.handle("app", (req) => {
const url = new URL(req.url);
if (url.host !== "local") return new Response("Not found", { status: 404 });
if (url.pathname !== "/api/chat") return serveRenderer(url);
// Set by Electron, not the page. Electron's typings don't declare it yet.
const { initiatorOrigin } = req as Request & { initiatorOrigin?: string };
if (req.method !== "POST" || initiatorOrigin !== APP_ORIGIN) {
return new Response("Forbidden", { status: 403 });
}
return POST(req);
});
const mainWindow = new BrowserWindow({
webPreferences: {
session: assistantSession,
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
mainWindow.loadURL(`${APP_ORIGIN}/index.html`);
});- Registering
appas a standard, secure scheme gives the renderer a real origin,app://local. The relative/api/chatURL is then same-origin, so it needs no CORS setup and survives packaging. Compare hosts rather thanurl.origin, which Node reports as"null"for custom schemes. - The handler is registered on a dedicated session partition. Only windows created with that session can load
app://at all, so keep other windows, such as ones that show external content, on the default session. initiatorOrigincomes from Electron, so a page cannot fake it the way it can setreferrer. Without this check, any page loaded in the assistant session could post to the route, even withmode: "no-cors". Requests started by the main process itself carry no initiator and are rejected too.net.fetchalso reads files from inside a packagedapp.asararchive.
2. Run the AI SDK route in the main process
Install the provider packages in the main-process bundle:
pnpm add ai @ai-sdk/openaiThis is the AI SDK route with two changes:
- The body is untrusted input to a privileged process, so the route caps it at 20 MB before decoding and checks it with
validateUIMessagesbefore anything reaches the model. Electron buffers this JSON upload in the main process before calling the handler, so the cap limits what the route decodes, processes, and sends on, not that first copy. Raise the cap if your app accepts larger attachments, and passtoolsand any data part schemas tovalidateUIMessagesso it checks those parts too. - Electron builds the handler's
Requestwithout an abort signal, soabortSignal: req.signalwould never fire and the model request would keep running after the user presses Stop. Instead, the route aborts when the renderer cancels the response body, which happens on Stop, on reload, and when the window closes.
import { openai } from "@ai-sdk/openai";
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
validateUIMessages,
type UIMessage,
} from "ai";
// Attachments arrive inline as data URLs, so leave room for them.
const MAX_BODY_BYTES = 20_000_000;
// Electron has already buffered the upload; this avoids a second full-size copy.
async function readBody(req: Request) {
const decoder = new TextDecoder();
let bytesRead = 0;
let body = "";
for await (const chunk of req.body ?? []) {
bytesRead += chunk.byteLength;
if (bytesRead > MAX_BODY_BYTES) return null;
body += decoder.decode(chunk, { stream: true });
}
return body + decoder.decode();
}
export async function POST(req: Request) {
const body = await readBody(req);
if (body === null) {
return new Response("Request too large", { status: 413 });
}
let messages: UIMessage[];
try {
messages = await validateUIMessages({ messages: JSON.parse(body).messages });
} catch {
return new Response("Invalid request", { status: 400 });
}
// protocol.handle never aborts req.signal, so abort when the renderer
// cancels the response body instead (Stop, reload, or window close).
const abortController = new AbortController();
const result = streamText({
model: openai("gpt-6-luna"),
messages: await convertToModelMessages(messages),
abortSignal: abortController.signal,
});
const reader = toUIMessageStream({ stream: result.stream }).getReader();
return createUIMessageStreamResponse({
stream: new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) controller.close();
else controller.enqueue(value);
},
cancel(reason) {
abortController.abort(reason);
return reader.cancel(reason);
},
}),
});
}Set OPENAI_API_KEY only in the main-process environment. Add tools, a system prompt, and other streamText options exactly as in a web route.
3. Point the renderer at the route
The renderer is the same as in Pattern 1, with a relative URL:
import type { ReactNode } from "react";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
} from "@assistant-ui/ai-sdk";
const transport = new AssistantChatTransport({ api: "/api/chat" });
export function ElectronRuntimeProvider({ children }: { children: ReactNode }) {
const runtime = useChatRuntime({ transport });
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}Wrap your existing assistant-ui thread with ElectronRuntimeProvider. Primitives and installed UI components work exactly as they do in a browser React app.
Development server
If the window loads your dev server's URL directly, /api/chat resolves against http://localhost and never reaches the handler. Load app://local in development too, and have serveRenderer forward to the dev server:
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
function serveRenderer(url: URL) {
if (devServerUrl) {
const target = new URL(devServerUrl);
target.pathname = url.pathname;
target.search = url.search;
return net.fetch(target.toString());
}
// ...serve built files as above
}Use whichever variable your build tool provides: Electron Forge's Vite plugin defines MAIN_WINDOW_VITE_DEV_SERVER_URL, and electron-vite sets process.env.ELECTRON_RENDERER_URL. With Vite, set server.hmr.clientPort to the dev server's port so the HMR client connects from app://local, and allow ws://localhost:<port> in your development CSP's connect-src.
Packaged-app checklist
- Use an absolute HTTPS endpoint or a route on your own privileged scheme. A relative
/api/*URL only works when the page itself is served from the scheme that handles it. - Serve local content through a custom protocol instead of
file://when possible, and define a restrictive Content Security Policy. Do not disablewebSecurityto work around origin errors. - Check
initiatorOriginon every route the main process serves, and validate request bodies there. Treat renderer data as untrusted even when the renderer is local. - Intercept new windows and external links with
webContents.setWindowOpenHandler; do not let model-generated links create unrestricted Electron windows. - If you render remote HTML with
SafeContentFrame, allow its documented host inframe-src. Ordinary text and Markdown rendering need no Electron-specific change. - Test the packaged build, not only the Vite or Webpack development server. The scheme, origin, file paths inside
app.asar, CSP, and environment loading can all differ.
For the underlying platform constraints, see Electron's protocol API, context isolation, and the security checklist.