@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 withnodeIntegration: 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.
Links in the chat
@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:
- Pass
onLinkClickto<AdeChat>,<Transcript>,<ProviderCard>or<ProviderCards>. chat-ui then callspreventDefault()and gives you the link. Nothing opens unless you open it. - 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, andallowThreadKey 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.
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):
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:
authorize already runs on every method before the SDK sees it.
Preload
A preload undersandbox: 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:
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 undersandbox: 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.Bundle the preload
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:
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, andsummaryis itsThreadSummary(fromclient.threads.get). The stored provider, model and policy apply. Return{ refresh }alone, orundefined. 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).
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.
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
webContentsemitsdestroyed; - on a cross-document main-frame navigation (
did-start-navigationordid-navigate).
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
AnipcMain.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
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:- 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.
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.