> ## Documentation Index
> Fetch the complete documentation index at: https://ade-app.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Electron

> Bridge @ade-dev/sdk from Electron's main process to a sandboxed renderer: registerAdeIpc, a copy-paste sandboxed preload, createAdeIpcClient, the openOptions hook, link handling, and runtime lifecycle events.

`@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.

```bash theme={null}
npm install @ade-dev/sdk
```

`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

```ts theme={null}
import path from "node:path";
import { app, BrowserWindow, ipcMain } from "electron";
import { createAdeChat } from "@ade-dev/sdk";
import { registerAdeIpc } from "@ade-dev/sdk/electron";

const client = await createAdeChat({
  home: path.join(app.getPath("userData"), "ade"),
});

const disposeIpc = registerAdeIpc(ipcMain, client, {
  /** Channel namespace. Defaults to "ade", giving `ade:invoke` and `ade:event`. */
  channelPrefix: "ade",
  /** Runs before every call. Return false and the SDK is never reached. */
  authorize: (event, method, args) => isTrustedFrame(event.sender),
  /** A thread key carries MCP servers and a permission policy. Pin it. */
  allowThreadKey: (key) => key === "main",
  /** Main decides how a key opens. The renderer's options are ignored. */
  openOptions: (key) => ({
    provider: "claude",
    model: "claude-sonnet-4-5",
    permissions: { allowedTools: ["mcp:app:*"], fallback: "deny" },
  }),
  /** Which models a renderer may pick. */
  allowModel: (key, { modelId }) => modelId.startsWith("claude-"),
});

const window = new BrowserWindow({
  webPreferences: {
    preload: require.resolve("@ade-dev/sdk/electron/preload-auto"),   // see "Preload" below
    contextIsolation: true,
    nodeIntegration: false,
    sandbox: true,
  },
});

app.on("before-quit", async () => {
  disposeIpc();          // drop the bridge before the client
  await client.dispose();
});
```

`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:

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.

```tsx theme={null}
// renderer
<AdeChat
  client={client}
  threadKey="main"
  onLinkClick={(href, { source }) => window.app.openExternal(href, source)}
/>
```

```ts theme={null}
// main
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));

ipcMain.handle("app:open-external", (_event, href: string) => {
  const url = new URL(href);
  if (url.protocol === "https:" && ALLOWED_HOSTS.has(url.host)) return shell.openExternal(href);
});
```

`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.

```ts theme={null}
const catalog = new Set(await loadChatKeys());   // your own chat list

registerAdeIpc(ipcMain, client, {
  authorize: (event) => isTrustedFrame(event.sender),
  allowThreadKey: (key) => catalog.has(key),
});
```

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:

```ts theme={null}
allowThreadKey: async (key) => key === "new-chat" || (await client.threads.get(key)) !== null,
```

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):

```ts theme={null}
registerAdeIpc(ipcMain, () => currentClient(), {
  // …
  onThreadRemoved: (key, kind) => {
    if (kind === "deleted") catalog.delete(key);
    else catalog.markArchived(key);
  },
});
```

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`:

```ts theme={null}
authorize: (event, method) => isTrustedFrame(event.sender) && method !== "threads.delete",
```

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:

```ts theme={null}
webPreferences: {
  preload: require.resolve("@ade-dev/sdk/electron/preload-auto"),
  sandbox: true,
}
```

`@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:

```js theme={null}
// preload.js — safe under sandbox: true. Requires nothing but "electron".
const { contextBridge, ipcRenderer } = require("electron");

const PREFIX = "ade"; // must equal registerAdeIpc's channelPrefix
const INVOKE = `${PREFIX}:invoke`;
const EVENT = `${PREFIX}:event`;

contextBridge.exposeInMainWorld("ade", {
  invoke(method, args) {
    return ipcRenderer.invoke(INVOKE, { method, args });
  },
  onEvent(listener) {
    const handler = (_event, payload) => listener(payload);
    ipcRenderer.on(EVENT, handler);
    return () => ipcRenderer.removeListener(EVENT, handler);
  },
});
```

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`:

```ts theme={null}
import { checkAdeBridge } from "@ade-dev/sdk/electron";

// Run the bridge part of your preload with fakes, and compare it with the SDK's own.
await checkAdeBridge((contextBridge, ipcRenderer) => exposeMyBridge(contextBridge, ipcRenderer));
```

`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](#bundle-the-preload).

### Renderer

```tsx theme={null}
import "@ade-dev/sdk/electron/global"; // types window.ade
import { createAdeIpcClient } from "@ade-dev/sdk/electron/renderer";
import { adaptSdkClient, AdeChat } from "@ade-dev/chat-ui";

const client = adaptSdkClient(createAdeIpcClient(window.ade), {
  providerFilter: ["claude", "codex"],
});

export function App() {
  return <AdeChat client={client} threadKey="main" />;
}
```

`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.

<CardGroup cols={3}>
  <Card title="The shipped file" icon="box">
    Point `webPreferences.preload` at `require.resolve("@ade-dev/sdk/electron/preload-auto")`. Default key and prefix only.
  </Card>

  <Card title="Copy the file" icon="copy">
    Use the [preload above](#preload). It requires only `electron`, so it needs no build step.
  </Card>

  <Card title="Your own bundle" icon="layer-group">
    Import `exposeAdeBridge` and bundle the preload into one file with esbuild, Vite or Rollup. Keep `electron` external.
  </Card>
</CardGroup>

<Warning>
  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`.
</Warning>

### Bundle the preload

```ts theme={null}
// src/preload.ts
import { contextBridge, ipcRenderer } from "electron";
import { exposeAdeBridge } from "@ade-dev/sdk/electron/preload";

exposeAdeBridge(contextBridge, ipcRenderer); // key "ade", channel prefix "ade"
```

```bash theme={null}
esbuild src/preload.ts --bundle --platform=node --format=cjs \
  --external:electron --outfile=dist/preload.js
```

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`.

| IPC method | Renderer call | Gate | Notes |
| - | - | - | - |
| `providers.status` / `providers.refresh` | `providers.status()` / `providers.refresh()` | `authorize` | request and response |
| `providers.subscribe` / `providers.unsubscribe` | `providers.onChange(cb)` | `authorize` | a subscription; the unsubscribe reaches main |
| `models.list` | `models.list()` | `authorize` | request and response |
| `doctor` | `doctor()` | `authorize` | the full `DoctorReport` |
| `client.subscribe` / `client.unsubscribe` | `on("exit" \| "transport" \| "restart" \| "threadLifecycle", cb)` | `authorize` | runtime and thread lifecycle; see [below](#runtime-lifecycle-in-the-renderer) |
| `threads.open` | `threads.open(key, opts)` | `authorize`, `allowThreadKey` | returns a thread handle; see [what a renderer may send](#what-a-renderer-may-send-to-threadsopen) |
| `threads.list` | `threads.list()` | `authorize` | every `ThreadSummary` whose key `allowThreadKey` accepts. Rows with no key are left out |
| `threads.get` | `threads.get(key)` | `authorize`, `allowThreadKey` | one `ThreadSummary`, or `null`. SDK 0.4 |
| `threads.delete` / `threads.archive` / `threads.unarchive` | `threads.delete(key)` / `.archive(key)` / `.unarchive(key)` | `authorize`, `allowThreadKey` | the renderer does not need to open the key first. Delete also drops the key's handles on every renderer, and both delete and archive call `onThreadRemoved` (SDK 0.5) |
| `thread.export` | `exportThread(key)` | `authorize`, `allowThreadKey` | JSONL |
| `thread.update` | `thread.update(patch, { force })` | `authorize`, `allowThreadKey` | only `title`, `reasoningEffort` and `fastMode` cross |
| `thread.retry` / `thread.editLast` | `thread.retry()` / `thread.editLast(text, opts)` | `authorize`, `allowThreadKey` | SDK 0.4. `editLast` takes only `displayText` and `attachments` |
| `thread.send` | `thread.send(text, opts)` | `authorize`, `allowThreadKey` | resolves on dispatch, matching the SDK's own asymmetry. Options are filtered; see [below](#what-a-renderer-may-send-to-threadsend) |
| `thread.steer` | `thread.steer(text, { attachments })` | `authorize`, `allowThreadKey` | attachments cross as paths, filtered like `thread.send` |
| `thread.interrupt` | `thread.interrupt()` | `authorize`, `allowThreadKey` | request and response |
| `thread.history` | `thread.history({ limit })` | `authorize`, `allowThreadKey` | merged against the live stream; see [ordering](#ordering) |
| `thread.historyPage` | `thread.historyPage({ beforeSequence, limit })` | `authorize`, `allowThreadKey` | the newest page is merged like `history()` |
| `thread.setModel` | `thread.setModel(id, opts)` | `authorize`, `allowThreadKey`, `allowModel` | refuses mid-turn like the real thing |
| `thread.approve` / `thread.pendingApprovals` | the same names | `authorize`, `allowThreadKey` | see [Permissions](/docs/sdk/permissions) |
| `thread.subscribe` / `thread.unsubscribe` | `thread.on("event" \| "status" \| "usage", cb)` | `authorize`, `allowThreadKey` | a subscription |

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:

```ts theme={null}
registerAdeIpc(ipcMain, client, {
  allowThreadKey: (key) => catalog.has(key),
  openOptions: async (key, rendererOptions) => {
    const chat = await loadChat(key);      // your own catalog
    return {
      provider: chat.provider,
      model: chat.model,
      cwd: chat.cwd,
      permissions: { allowedTools: ["mcp:app:*"], fallback: "deny" },
      mcpServers: appMcpServers(),
      refresh: { mcpServers: appMcpServers() },   // current token on every open
    };
  },
});
```

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:

```ts theme={null}
openOptions: async (key, _rendererOptions, { exists, summary }) => {
  if (exists) return { refresh: { mcpServers: appMcpServers() } };   // resume: stored record applies
  return {                                                             // create, or recreate
    provider: "claude",
    model: await pickModel(),
    permissions: lockedPolicy,
    mcpServers: appMcpServers(),
  };
},
```

* `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](/docs/sdk/threads#when-the-runtime-lost-the-session)).

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:

| Forwarded | Dropped |
| - | - |
| `provider`, `model`, `title`, `reasoningEffort` (strings only) | `permissions`, `mcpServers`, `cwd`, `instructions`, `settingSources`, `loadUserMcpServers`, `refresh`, and anything else |

The bridge logs one line for each field that it drops. `ADE_IPC_RENDERER_OPEN_FIELDS` exports the list of four.

<Warning>
  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`.
</Warning>

A key that the runtime lost is recreated from the SDK's stored record, and the record wins over any option. See [Threads](/docs/sdk/threads#when-the-runtime-lost-the-session).

### 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:

| Kept | Dropped |
| - | - |
| `displayText` (string), `reasoningEffort` (string or `null`), and `attachments` with `path`, `name`, `mimeType`, `bytes`, `type`, `hydrate` of the right types | every other field, with one log line each |

The runtime's own path rules still decide which attachment paths it reads. See [Where a path may point](/docs/sdk/threads#where-a-path-may-point).

### `allowModel`

```ts theme={null}
allowModel: (key, { modelId }) => allowedModels.has(modelId),
```

`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:

```ts theme={null}
ipcMain.handle("app:pick-file", async () => {
  const result = await dialog.showOpenDialog({ properties: ["openFile"] });
  return result.filePaths[0] ?? null;
});
```

## 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`.

```ts theme={null}
try {
  await thread.setModel(next);
} catch (error) {
  if (error.code === "turn_in_flight") showBusyHint();
}
```

`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:

```ts theme={null}
if (error.code === "turn_in_flight") showHint("Wait for the reply to finish, then try again.");
```

## 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](/docs/sdk/runtime#when-the-runtime-exits).

**`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

```ts theme={null}
const ipcClient = createAdeIpcClient(window.ade);

ipcClient.on("exit", ({ code, signal, error }) => showBanner("Chat stopped", error));
ipcClient.on("restart", ({ attempt, ok, final }) => {
  if (ok) reloadTranscript();
  else if (final) showBanner("Chat could not restart");   // no more attempts
});
ipcClient.on("transport", ({ state }) => setOnline(state === "reconnected"));
ipcClient.on("threadLifecycle", ({ key, change }) => refreshChatList(key, change));
```

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:

```ts theme={null}
let current = await createAdeChat({ home: homeFor(account) });

registerAdeIpc(ipcMain, () => current, { allowThreadKey, openOptions });

async function switchAccount(next) {
  const previous = current;
  current = await createAdeChat({ home: homeFor(next) });   // the bridge now serves this one
  await previous.dispose();
}
```

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

<Card title="Permissions and approvals" href="/docs/sdk/permissions" icon="shield-check" horizontal>
  What `approve()` answers, and what an unanswered approval blocks.
</Card>
