Skip to main content
@ade-dev/sdk spawns a child process and speaks over a Unix socket, so it runs in Electron’s main process. @ade-dev/chat-ui is React and runs in the renderer. They cannot share an object. This page describes the bridge between them: three functions, one per process.
electron is not a dependency of this package, and not an optional peer. Every Electron object is described structurally, so any version whose ipcMain, ipcRenderer and contextBridge still carry these members works.

Why not run the SDK in the renderer

You cannot, and you would not want to. The SDK spawns a runtime child process and connects to it over a Unix socket or a Windows named pipe. A renderer with nodeIntegration: false and sandbox: true has neither child_process nor net. Turning those on to make it fit would put a process spawner and your users’ provider credentials inside the one process that renders untrusted markdown. A strict Content Security Policy closes the other door. A renderer with default-src 'none' cannot fetch, so it cannot reach a remote API either. Everything crosses through main. That is the correct posture, and the bridge below is what it costs.

The three files

Main

registerAdeIpc returns a disposer. Call it before client.dispose() so no event is pushed at a closing runtime. The disposer also has a forget(key) method (SDK 0.4). It releases every renderer’s handles and subscriptions for one key and does not touch the conversation. You seldom need it: the bridge already releases a key when it is deleted through the client it serves, by a renderer or by your own client.threads.delete(key) in main. Call forget for a key that you removed some other way. @ade-dev/chat-ui draws links in assistant text and a “Documentation” link on ProviderCard. Without a handler these links use target="_blank". In Electron, target="_blank" opens a new BrowserWindow with no browser chrome. That window looks like part of your app and shows a URL that a model wrote, so text from a prompt injection becomes a phishing page inside your app. Do two things:
  1. Pass onLinkClick to <AdeChat>, <Transcript>, <ProviderCard> or <ProviderCards>. chat-ui then calls preventDefault() and gives you the link. Nothing opens unless you open it.
  2. Deny new windows in main as a backstop, for any link that you did not handle.
source is "markdown" for a link in assistant text and "provider-docs" for a provider card’s documentation link. href has already passed chat-ui’s safeHref, so it is never javascript: or data:. Check the host in main anyway: the renderer is not trusted.

Hosts with many threads

The example pins one key. A host with a chat list has many keys, and allowThreadKey must then check that the key is in your own catalog: a Set of the keys you created, or a lookup in the store that holds your chat list. Do not check a prefix such as key.startsWith("chat-"). A compromised renderer can make any number of keys that match a prefix, and each new key opens a new thread with the tool surface that your options give it.
Add a key to the catalog in main when the user creates a chat, before the renderer opens it. Remove the key when you delete the thread. From SDK 0.4 the gate can be async, so it can ask the runtime instead of a copy that you keep in sync by hand:
The gate runs on every keyed call, so keep it cheap. allowModel can also be async. To keep your own list correct when a renderer deletes or archives a chat over the bridge, pass onThreadRemoved to registerAdeIpc (SDK 0.5):
It fires for every delete and archive this client reports, whoever made it — the renderer over the bridge, or main code calling client.threads.delete. kind is "deleted" or "archived"; an unarchive is not reported, and a client that does not emit threadLifecycle (an SDK older than 0.4) never calls it. Your own client.on("threadLifecycle") listener sees the same events, but only on the client you subscribed to: when you swap clients per account through a client getter, this callback moves to the current client as soon as the bridge serves a call, and a hand-written listener never moves at all. To stop the renderer from deleting or archiving at all, refuse those methods in authorize:
There is deliberately no separate deny list: authorize already runs on every method before the SDK sees it.

Preload

A preload under sandbox: true can require("electron") and nothing else. The SDK ships one file that obeys that rule and exposes the bridge by itself:
@ade-dev/sdk/electron/preload-auto is one CommonJS file whose only require is "electron". It calls exposeAdeBridge with the default key ade and the default channel prefix ade. There is no build step. If you need another key or prefix, or your own channels next to ADE’s, write your own preload. Copy this file as it is. It is the whole preload half of the bridge:
This is what exposeAdeBridge in @ade-dev/sdk/electron/preload does. The surface is two functions. Every method name and argument shape is in the renderer half, so this file does not change when the SDK adds methods. A preload that you wrote for 0.2 keeps working with 0.3. The channel name is the contract between two processes. A wrong name gives no error: the bridge never answers. From SDK 0.4, check your copy in your test suite with checkAdeBridge:
checkAdeBridge runs your function with a fake contextBridge and ipcRenderer. It checks the window key, the two channel names, the { method, args } payload, that events pass through unchanged, and that the unsubscribe removes the same listener. It rejects with invalid_option and names the first difference. Pass { key, channelPrefix } when you do not use the defaults. Run it again after each SDK upgrade. If you prefer to import exposeAdeBridge, bundle it into one file. See Bundle the preload.

Renderer

createAdeIpcClient returns the shape adaptSdkClient expects. There is no cast and no host glue. @ade-dev/sdk/electron/global is types only. It declares window.ade?: AdeBridge, and its JavaScript is empty, so the import costs nothing and is safe under a strict CSP. You can also list it in tsconfig.json compilerOptions.types. It declares the default key ade only. If your preload uses another key, declare interface Window { <key>: AdeBridge } yourself. From SDK 0.4 window.ade is optional, because a preload that failed to load leaves it undefined. You can check if (!window.ade) with no cast. createAdeIpcClient(window.ade) still compiles, and throws invalid_option when the bridge is missing. A page with no bundler cannot import an ES module over file://. Load dist/electron/renderer.global.js with a plain <script> tag instead and read window.AdeElectron.createAdeIpcClient.

Sandbox requirements

A preload running under sandbox: true has no module resolution. require reaches Electron’s own built-ins and nothing else: not a file path, not node_modules. A preload is therefore one of two things.

The shipped file

Point webPreferences.preload at require.resolve("@ade-dev/sdk/electron/preload-auto"). Default key and prefix only.

Copy the file

Use the preload above. It requires only electron, so it needs no build step.

Your own bundle

Import exposeAdeBridge and bundle the preload into one file with esbuild, Vite or Rollup. Keep electron external.
Do not set webPreferences.preload to require.resolve("@ade-dev/sdk/electron/preload"). That file exports exposeAdeBridge and does not call it, so window.ade stays undefined. Use preload-auto.

Bundle the preload

With Vite or Rollup the rules are the same: one CommonJS output file, electron external, and no code splitting. A shared chunk is an import that a sandboxed preload cannot resolve. Check the result: dist/preload.js must contain no require call except require("electron"). Everything that crosses contextBridge is structured-cloned, which drops prototypes. So the bridge exposes only plain data and functions, and the thread handle a renderer holds is an id plus functions rather than a class instance.

What crosses the bridge

ADE_IPC_METHODS in @ade-dev/sdk/electron is the full list. A method that is not on it is refused with invalid_option. The thread handle also carries snapshots: id, key, title, model, mcpCapability, instructionsCapability, settingSourcesCapability and permissionCapability. They are taken at open. The handle’s own setModel() and update() refresh them. A change from another renderer or from main shows at the next threads.open of the key. thread.updateMcpServers is not on the bridge. MCP servers carry credentials and choose the agent’s tool surface, so only main may change them: with thread.updateMcpServers(), or with refresh.mcpServers from the openOptions hook.

What a renderer may send to threads.open

The renderer is the least trusted process in your app. A thread key carries a tool surface, a permission policy and a working directory, so the renderer does not configure a thread. With an openOptions hook (recommended for every host), main decides:
The hook gets the key, the options that the renderer sent, and (from SDK 0.4) a context. Read the renderer’s options if you want, but do not trust them. The SDK opens the key with what the hook returns, and ignores the renderer’s options. Return undefined to reopen a stored key with its record. For a key that this home never saw, that fails with invalid_option.

Resume or create: context.exists

The third argument tells a resume from a create:
  • exists: true: the runtime has a session for the key, and summary is its ThreadSummary (from client.threads.get). The stored provider, model and policy apply. Return { refresh } alone, or undefined. Full options also work, but the SDK logs one “ignored” line for each field that differs from the record.
  • exists: false: the key is new, or the runtime lost its session. Return the full create options. For a lost session the stored record still wins over them (see Threads).
Do not pick a model inside the hook for a resume. A model pick can fail, for example when no provider CLI is signed in, and a failure in the hook fails the open of a chat that already exists. refresh.mcpServers on every open is safe. Every renderer threads.open reaches the hook, including a key the renderer already holds, and the SDK sends the servers to the runtime only when they differ from what it last sent for that key. An unchanged token does not restart the provider. If a turn is running when a changed refresh arrives, the runtime refuses the update. The open still succeeds and returns the live thread, and the SDK logs one line. The thread keeps its old servers until a later open, or a thread.updateMcpServers() call from main, succeeds after the turn ends. Without the hook, the bridge forwards four renderer fields and drops the rest: The bridge logs one line for each field that it drops. ADE_IPC_RENDERER_OPEN_FIELDS exports the list of four.
This is a breaking change in 0.3. Before 0.3 the bridge forwarded every renderer option. A compromised renderer could then open an allowed key with permissions: "always-allow", its own MCP servers, or its own cwd. If your renderer sent host configuration, move that configuration into openOptions.
A key that the runtime lost is recreated from the SDK’s stored record, and the record wins over any option. See Threads.

What a renderer may send to thread.send

From SDK 0.4 the bridge rebuilds the thread.send and thread.steer options field by field: The runtime’s own path rules still decide which attachment paths it reads. See Where a path may point.

allowModel

allowModel gates thread.setModel. Without an openOptions hook, it also gates a model that the renderer sends to threads.open. Return false and the call fails with unauthorized. Without the hook, the bridge accepts any model id that the catalog resolves, on any provider.

Attachments are paths, not bytes

SendOptions.attachments are AgentChatFileRef values with a filesystem path. The bridge does not upload buffers, and a File object from a renderer drag-and-drop is not one. Open a dialog in main, and send the path back:

Teardown guarantees

providers.onChange and thread.on return an Unsubscribe that only main can call. A renderer that reloads drops its side and leaves main’s listener attached. Nothing fails; the transcript simply starts showing every envelope twice, then three times. In development a fast-refresh loop reloads the renderer constantly, so the leak grows fast and looks like a provider bug. registerAdeIpc keeps a registry keyed by webContents.id and disposes it:
  • when that webContents emits destroyed;
  • on a cross-document main-frame navigation (did-start-navigation or did-navigate).
A same-document navigation, such as a hash-route change, is not a teardown. It does not replace the renderer’s JavaScript world, and tearing down there would kill a live transcript on a route change. Teardown drops listeners and forgets the bridge’s handles. It does not end the conversation: the SDK keeps the session, so the renderer reopens the same key and resumes the same transcript. On the renderer side, on() is refcounted. Twelve components watching one thread cost main one subscription, and the last unsubscribe releases it.

Ordering

@ade-dev/chat-ui states the rule in prose: envelope order is sequence first and timestamp second, provider clocks are not trusted, and an event emitted while history() is in flight must also reach the "event" subscriber, de-duplicated on sessionId:sequence:timestamp:type. The renderer half carries that rule in code. history() subscribes before it asks, buffers what arrives while the request is in flight, and returns the merge — so an envelope that reached a live subscriber is not repeated in the page, and one that reached nobody is folded into it. Each envelope is delivered exactly once, in envelope order, and envelopeDedupeKey is exported so you can key on the same string. An event type the SDK has never heard of passes through untouched. Adding a new event kind upstream does not need a bridge release.

Errors

An ipcMain.handle rejection reaches the renderer as a plain Error with a rewritten message, which loses AdeError.code — the one field you branch on. So the handler never rejects. It resolves with { __adeError: true, name: "AdeError", code, message } and the renderer rebuilds a real AdeError.
error.code is the stable check. instanceof is not, because each subpath is a separate bundle and therefore carries its own copy of the class. Import AdeError from @ade-dev/sdk/electron/renderer if you want instanceof to hold in a renderer. authorize, allowThreadKey or allowModel returning false rejects with code unauthorized, and the SDK is not called at all. A call that is refused only because a turn is running (setModel, a reasoning update, retry, editLast) rejects with code turn_in_flight from SDK 0.4. Before 0.4 those refusals used invalid_option. Branch on the code, not on the message:

Transport degradation and restarts

The runtime exits. The client tells you. client.on("exit" | "transport" | "restart", cb) in main, or ipcClient.on(...) in a renderer, reports each event. Every live thread also gets a synthetic status envelope with turnStatus: "failed", so a transcript does not stay “running”. Pass autoRestart to createAdeChat and the client respawns the runtime and re-opens every live thread. The client object stays the same, so the bridge keeps working with no change. See Runtime. DoctorReport.events.mode. The push transport can silently become polling: mode reads "push" normally and "drain" when the SDK is polling instead. Nothing about the bridge changes — envelopes still arrive on the same channel — but latency does, so report mode in your diagnostics rather than assuming push. events.epoch. The epoch changes when the runtime restarts. Envelopes from before and after a change belong to different runtime lifetimes, so a transcript stitched across one can appear to go backwards. When a restart event with ok: true arrives, re-run history() or historyPage() rather than trusting the buffer you already hold. A renderer can read both values with doctor() over the bridge.

Runtime lifecycle in the renderer

The payloads are the same as client.on in main. One IPC subscription serves every listener in the renderer. restart.final (SDK 0.4) is true when no further attempt follows: the attempt succeeded, or it failed and was the last one that autoRestart.maxAttempts allows. You do not need your own copy of maxAttempts. useAdeThread in @ade-dev/chat-ui 0.4 re-opens the thread and re-reads its history after a successful restart by itself. You do not need to remount the chat.

Swap the client

A host that makes one client per signed-in account can pass a function instead of a client:
The bridge reads the function on every call. On the first call after the client changed, it re-opens each renderer’s threads on the new client with the options they were opened with, and moves every subscription across under the same id. The renderer does not see the change. Two limits:
  • Events that the old client would have pushed between the swap and that first call are not replayed.
  • A key that the new client cannot open is dropped. The renderer’s next call on it gets thread_not_found.
A client with autoRestart never changes identity, so it does not need a swap.

Example app

packages/sdk/examples/electron is a complete app with contextIsolation: true, nodeIntegration: false, sandbox: true, and a strict CSP meta tag. It opens a thread, sends a message and prints streamed text in about a hundred lines of vanilla JavaScript. It needs a provider CLI installed, and CI does not install it.

Next

Permissions and approvals

What approve() answers, and what an unanswered approval blocks.