# IPC and preload bridge

> How ipcMain handlers map to renderer window.electronAPI, typed channel constants, event vs invoke patterns, and secure contextBridge exposure in preload.ts.

- Repository: Parcha-ai/build
- GitHub: https://github.com/Parcha-ai/build
- Human docs: https://grok-wiki.com/public/docs/parcha-ai-build-bea5702b371b
- Complete Markdown: https://grok-wiki.com/public/docs/parcha-ai-build-bea5702b371b/llms-full.txt

## Source Files

- `src/shared/constants/channels.ts`
- `src/main/preload.ts`
- `src/main/ipc/claude.ipc.ts`
- `src/main/ipc/session.ipc.ts`
- `src/main/ipc/settings.ipc.ts`
- `CLAUDE.md`

---

---
title: "IPC and preload bridge"
description: "How ipcMain handlers map to renderer window.electronAPI, typed channel constants, event vs invoke patterns, and secure contextBridge exposure in preload.ts."
---

Build routes all renderer-to-main communication through a single preload script that exposes `window.electronAPI`. Channel names live in `src/shared/constants/channels.ts` as `IPC_CHANNELS`; main-process handlers register in `src/main/ipc/*.ipc.ts` via `registerIPCHandlers()` in `src/main/index.ts`; the renderer never imports `ipcRenderer` directly.

## End-to-end flow

```mermaid
sequenceDiagram
  participant R as Renderer (React stores)
  participant P as preload.ts
  participant M as ipcMain handlers
  participant S as Main services

  R->>P: window.electronAPI.sessions.create(config)
  P->>M: ipcRenderer.invoke("session:create", config)
  M->>S: sessionService.createSession(config)
  S-->>M: Session
  M-->>P: resolved promise
  P-->>R: Session

  Note over M,R: Push path (no return value)
  S->>M: sessionService.emit("statusChanged")
  M->>R: broadcastToAll("session:status-changed", session)
  R->>P: onStatusChanged callback (ipcRenderer.on)
```

| Layer | File / symbol | Responsibility |
|-------|----------------|----------------|
| Channel catalog | `IPC_CHANNELS` in `channels.ts` | Stable string IDs (`domain:action`) shared by main and preload |
| Registration | `register*Handlers(ipcMain)` in `src/main/ipc/` | Bind `ipcMain.handle` / `ipcMain.on` to services |
| Bridge | `electronAPI` in `preload.ts` | Map domain methods to `invoke`, `send`, or `on` |
| Consumer | `window.electronAPI` in renderer | Zustand stores and components call typed API |
| Types | `ElectronAPI` exported from `preload.ts` | `Window` augmentation in `src/renderer/index.tsx` |

## Channel naming and typing

`IPC_CHANNELS` groups roughly thirty domains (auth, session, claude, git, terminal, settings, browser, ssh, mcp, qmd, analytics, and others). Values use a `namespace:verb` pattern, for example `session:create` and `claude:stream-chunk`.

```typescript
export const IPC_CHANNELS = {
  SESSION_CREATE: 'session:create',
  CLAUDE_STREAM_CHUNK: 'claude:stream-chunk',
  // ...
} as const;

export type IPCChannel = typeof IPC_CHANNELS[keyof typeof IPC_CHANNELS];
```

Preload and handlers import the same object so renames stay synchronized. A few legacy or auxiliary channels are still string literals in preload (for example `auth:oauth-callback`, `session-switcher`, and some `browser:*` push names); new surface area should prefer adding entries to `IPC_CHANNELS`.

<Info>
The full channel inventory, grouped by domain with invoke vs push semantics, is documented on the IPC channels reference page.
</Info>

## Preload security model

`BrowserWindow` is created with `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: false` (required for `node-pty`). Webpack injects the preload bundle via `MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY`.

At the end of `preload.ts`:

```typescript
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
export type ElectronAPI = typeof electronAPI;
```

Only the curated `electronAPI` object crosses the isolation boundary. The renderer cannot call Node APIs or `ipcRenderer` unless exposed here. Pop-out browser windows reuse the same preload path (`__preloadPath` on the parent window) with the same `webPreferences`.

`DEV_INSTANCE_NAME` from the dev script is exposed as `electronAPI.devInstanceName` for status-bar identification in development builds.

## Invoke vs send vs push

Build uses three IPC shapes. Choosing the right one keeps streaming responsive and avoids blocking the UI thread.

| Pattern | Preload API | Main registration | Typical use |
|---------|-------------|-------------------|-------------|
| **Invoke** (request/response) | `ipcRenderer.invoke(channel, ...args)` → `Promise` | `ipcMain.handle(channel, async handler)` | CRUD, settings, start/stop session, send chat message kickoff |
| **Send** (renderer → main, no reply) | `ipcRenderer.send(channel, ...args)` | `ipcMain.on(channel, handler)` | High-frequency terminal input, resize, browser webview registration |
| **Push** (main → renderer) | `ipcRenderer.on(channel, handler)` + unsubscribe fn | `webContents.send` or `broadcastToAll` | Stream chunks, session list updates, permission dialogs |

### Invoke handlers

Domain modules export `registerXHandlers(ipcMain: IpcMain)`. Handlers are thin wrappers around services.

**Sessions** (`session.ipc.ts`): every session operation is `ipcMain.handle` delegating to `SessionService`. Service events fan out to all windows:

```typescript
sessionService.on('statusChanged', (session) => {
  broadcastToAll(IPC_CHANNELS.SESSION_STATUS_CHANGED, session);
});
```

**Settings** (`settings.ipc.ts`): `SETTINGS_GET`, `SETTINGS_SET`, API key getters/setters, plus app utilities (`APP_GET_VERSION`, `APP_OPEN_EXTERNAL`, dialogs, Docker probe) on the same registrar.

Preload mirrors these as `window.electronAPI.sessions.create(...)`, `window.electronAPI.settings.get()`, and so on.

### Fire-and-forget send

Terminal I/O uses `send` because keystrokes and resize events do not need acknowledgements:

- Preload: `terminal.sendInput`, `terminal.resize`, `terminal.close` → `ipcRenderer.send`
- Main: `ipcMain.on(TERMINAL_INPUT | TERMINAL_RESIZE | TERMINAL_CLOSE, ...)`

Browser webview registration similarly uses `ipcRenderer.send('browser:register-webview', { sessionId, webContentsId })` handled in `browser.service.ts`.

### Main-to-renderer push

Push channels almost always return an **unsubscribe function** from preload `on*` methods:

```typescript
onStreamChunk: (callback) => {
  const handler = (_: IpcRendererEvent, chunk) => callback(chunk);
  ipcRenderer.on(IPC_CHANNELS.CLAUDE_STREAM_CHUNK, handler);
  return () => ipcRenderer.removeListener(IPC_CHANNELS.CLAUDE_STREAM_CHUNK, handler);
},
```

Stores subscribe in effects and call the returned cleanup on unmount.

**Broadcast vs targeted send**

- `broadcastToAll(channel, ...args)` in `index.ts` sends to every non-destroyed window in `allWindows`. Session list/status and wakeup events use this so multiple renderer surfaces stay in sync.
- **Claude streaming** uses `sendToSender` inside `claude.ipc.ts`: events go to `event.sender` (the window that invoked `CLAUDE_SEND_MESSAGE`). If that `webContents` is destroyed mid-stream, the handler falls back to any live window.

Terminal output uses a **dynamic channel**: `terminal:output:${terminalId}` batched every 16ms on the main side to limit IPC volume.

## Bidirectional dialogs (push + invoke)

Permission, question, and plan-approval flows combine push and invoke:

1. Main pushes `CLAUDE_PERMISSION_REQUEST` (or `CLAUDE_QUESTION_REQUEST`, `CLAUDE_PLAN_APPROVAL_REQUEST`) via `sendToSender`.
2. Renderer shows UI and calls `claude.respondToPermission(...)` (invoke on `CLAUDE_PERMISSION_RESPONSE`).

Same pattern applies to plan approval and harness questions. This avoids long-polling invoke from main while keeping user responses on the trusted invoke path.

## `window.electronAPI` shape

`electronAPI` is organized by product domain, not by IPC file:

| Namespace | Examples | Main registrar |
|-----------|----------|----------------|
| `auth` | `login`, `checkProviders`, `onOAuthCallback` | `auth.ipc.ts` |
| `sessions` | `create`, `list`, `onStatusChanged` | `session.ipc.ts` |
| `claude` | `sendMessage`, `onStreamChunk`, `respondToPermission` | `claude.ipc.ts` |
| `terminal` | `create`, `sendInput`, `onOutput` | `terminal.ipc.ts` |
| `git` | `getStatus`, `onBranchChanged` | `git.ipc.ts` |
| `settings` / `app` | `get`, `setApiKey`, `getVersion` | `settings.ipc.ts` |
| `browser` | `registerWebview`, `sendSnapshotData` | `browser.ipc.ts` + service listeners |
| `secureKeys` | `intercept`, `clearSession` | `secure-keys.ipc.ts` |
| `ssh`, `mcp`, `qmd`, `voice`, `analytics`, … | matching domains | respective `*.ipc.ts` |

Renderer code guards for non-Electron contexts with `!!window.electronAPI` (see `session.store.ts`) so tests or static previews do not throw.

TypeScript consumers use:

```typescript
declare global {
  interface Window {
    electronAPI: import('../main/preload').ElectronAPI;
  }
}
```

## Handler registration lifecycle

On `app.ready`, `registerIPCHandlers()` runs **before** `createWindow()`:

```typescript
function registerIPCHandlers(): void {
  registerAuthHandlers(ipcMain);
  registerSessionHandlers(ipcMain);
  registerGitHandlers(ipcMain);
  registerTerminalHandlers(ipcMain);
  registerClaudeHandlers(ipcMain);
  registerSettingsHandlers(ipcMain);
  // ... dev, fs, audio, realtime, voice, extension, browser, ssh,
  // memory, secure keys, qmd, mcp, plugin, codex, openclaw, analytics, queue
  registerSecureKeysIPC();
}
```

GStack mode helpers are registered inline in `index.ts` (not a separate ipc file). `registerQmdHandlers` receives `getMainWindow` for progress pushes tied to the primary window.

`getMainWindow()` resolves the focused tracked window, then `mainWindow`, then any surviving entry in `allWindows`—used when handlers need a target for `webContents.send`.

## Performance patterns on the bridge

<Warning>
High-frequency paths batch or namespace channels intentionally. Copying a invoke-based stream design for terminal or PTY output will flood IPC.
</Warning>

- **Claude text/thinking**: `ChunkBatcher` in `claude.ipc.ts` flushes every ~100ms before `CLAUDE_STREAM_CHUNK` / `CLAUDE_THINKING_CHUNK`.
- **Terminal output**: 16ms flush buffer per `terminalId`.
- **Codex second opinion**: mirrors Claude batching in `codex.ipc.ts`.

## Adding a new capability

<Steps>
<Step title="Add channel constants">
Add `MY_FEATURE_DO_THING: 'my-feature:do-thing'` to `IPC_CHANNELS` in `channels.ts`. Use push suffix channels only when main initiates (`my-feature:updated`).
</Step>
<Step title="Register main handler">
Create or extend `src/main/ipc/my-feature.ipc.ts` with `registerMyFeatureHandlers(ipcMain)`. Use `handle` for operations that return data; use `webContents.send` / `broadcastToAll` for subscriptions.
</Step>
<Step title="Wire registration">
Import and call your registrar inside `registerIPCHandlers()` in `index.ts`.
</Step>
<Step title="Expose preload API">
Add a namespace on `electronAPI` in `preload.ts`: `invoke` for calls, `on*` with cleanup for pushes, `send` only when no response is needed.
</Step>
<Step title="Consume in renderer">
Call `window.electronAPI.myFeature...` from a store or hook. Subscribe in `useEffect` and tear down with the returned unsubscribe function.
</Step>
</Steps>

## Common pitfalls

| Symptom | Likely cause |
|---------|----------------|
| Handler never runs | Channel string mismatch between preload and `ipcMain`; or handler registered after first invoke |
| Stream events missing after reload | Listener not re-subscribed; or events sent to destroyed `event.sender` without fallback |
| `electronAPI` undefined in test | Renderer running outside Electron; guard with `hasElectronAPI` |
| Permission UI stuck | `onPermissionRequest` not subscribed, or `respondToPermission` not invoked with matching `requestId` |
| Duplicate session updates | Multiple windows each receive `broadcastToAll`; filter by `sessionId` in the callback |

Secure API key capture uses invoke-only paths (`secure-keys:intercept`, `secure-keys:get`) so raw keys are stripped in main before transcript persistence.

## Related pages

<CardGroup>
<Card title="Electron process model" href="/electron-architecture">
Main vs renderer boundaries, service layout, webpack preload entry, and window lifecycle.
</Card>
<Card title="IPC channels reference" href="/ipc-channels-reference">
Full `IPC_CHANNELS` catalog with invoke vs push classification by domain.
</Card>
<Card title="Configure API keys and providers" href="/configure-providers">
Settings and secure-keys channels for BYOC/BYOK storage.
</Card>
<Card title="Manage coding sessions" href="/manage-sessions">
Session invoke handlers and status push events used by the session store.
</Card>
</CardGroup>
