Electron

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

PatternUse it whenRuntime
Hosted backendYou already have an AI SDK endpoint, need server auth or persistence, or ship the app to other peopleuseChatRuntime with an absolute HTTPS URL
Local main processThe desktop app owns the provider or agent process and must work without your web backenduseChatRuntime with a route served by protocol.handle
absolute HTTPS fetch app://local/api/chat Electron rendererassistant-ui React Hosted chat backend Electron main processprotocol.handle Model or agent

Pattern 1: hosted backend

This is the smallest integration. Keep your existing AI SDK chat route and point AssistantChatTransport at its public URL.

renderer/assistant-runtime.tsx
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

main/main.ts
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 app as a standard, secure scheme gives the renderer a real origin, app://local. The relative /api/chat URL is then same-origin, so it needs no CORS setup and survives packaging. Compare hosts rather than url.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.
  • initiatorOrigin comes from Electron, so a page cannot fake it the way it can set referrer. Without this check, any page loaded in the assistant session could post to the route, even with mode: "no-cors". Requests started by the main process itself carry no initiator and are rejected too.
  • net.fetch also reads files from inside a packaged app.asar archive.

2. Run the AI SDK route in the main process

Install the provider packages in the main-process bundle:

pnpm add ai @ai-sdk/openai

This 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 validateUIMessages before 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 pass tools and any data part schemas to validateUIMessages so it checks those parts too.
  • Electron builds the handler's Request without an abort signal, so abortSignal: req.signal would 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.
main/api/chat.ts
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:

renderer/assistant-runtime.tsx
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:

main/main.ts
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 disable webSecurity to work around origin errors.
  • Check initiatorOrigin on 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 in frame-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.