Skip to content

Preload Bridge & IPC Contract

dazeb edited this page Sep 17, 2026 · 2 revisions

Preload Bridge & IPC Contract

The preload layer is the only channel between the renderer and the main process. It has two halves that must stay in lockstep:

  1. An enumerable channel inventory in src/shared/ipc.ts — every IPC channel name is a key in one frozen object, so the set of capabilities the app has is greppable and typeable rather than scattered string literals.
  2. A narrow contextBridge surface in src/preload/index.ts — a hand-authored api object exposed as window.termsprawl, which wraps ipcRenderer so the renderer never holds the raw Electron handle.

Before adding any new main/renderer capability, this is the pair of files to read and changes to make.

Key files

Path Role
src/shared/ipc.ts Channel-name source of truth, the IpcChannel union, and the per-entity channel-suffix helpers.
src/preload/index.ts Builds the api object, wires each method to invoke / send / on, and calls contextBridge.exposeInMainWorld('termsprawl', api). Also exports TermsprawlApi = typeof api.
src/renderer/src/env.d.ts Ambient declare global { interface Window { termsprawl: … } } mirror so renderer code typechecks against the bridge without importing preload.
src/preload/AGENTS.md The two hard rules: renderer never touches ipcRenderer; the build must resolve ../preload/index.mjs, not .js.

1. The channel inventory

IPC is declared as const at src/shared/ipc.ts#L4-L167, with a header comment stating the intent directly: "Channel names — single source of truth for every IPC channel. Never hardcode a channel string elsewhere." The observed revision contains 104 entries.

Group Count Examples
Agent tools 4 agent-tools:request, -reply, -status, -status-get
App metadata / settings / updates 15 app:version, shell:open-external, app:settings-get, update:*, settings:capabilities-get, settings:set-skill-enabled
Workspace & projects 13 workspace:snapshot, workspace:export-bundle, project:*, dialog:select-folder
Terminal / PTY 8 pty:create, pty:write, pty:resize, pty:destroy, terminal:close, pty:read-scrollback, plus two push channels
Node services 5 diff:info, dialog:open-file, file:read, file:write, file:list
Agent hooks 2 agent:status, agent:session-name (both push-only)
Context links 3 context:list, context:add, context:remove
Node links 5 links:run, links:mark-dirty, links:update, links:list, links:send-to-peer
Managed accounts 4 app:account-create, app:account-delete, app:permission-probe, app:login-command
Source control 14 git:snapshot … git:worktree-remove
Cloud 10 cloud:status, cloud:device-start/-poll, cloud:sign-out, cloud:backup-now, cloud:list-backups, cloud:space-*
GitHub (desktop) 3 github:repos, github:clone, github:disconnect
GitHub (import) 2 github:import, github:suggest (push-only)
Embedded browser 5 browser:cdp-info, browser:register/-unregister, browser:navigate, browser:agent-open
Chat driver 4 chat:send, chat:stop, chat:approve, chat:event
Relay 7 relay:connect, -disconnect, -status, -mint, relay:frame:subscribe/-unsubscribe/-send

Several entries are annotated rather than merely named — the comments carry contract detail that would otherwise be lost: githubClone mints its credential-bearing URL inside main so it never crosses IPC (src/shared/ipc.ts#L121-L129); projectImport accepts a caller-supplied id and rejects on collision so snapshot restores keep ids stable across machines (#L39-L41); settingsSetSkillEnabled documents that folder-move is the only state a CLI respects (#L22-L24).

Derived channel names

Five channels carry per-entity fan-out, so the base name lives in IPC and a suffix helper appends the id:

export function ptyDataChannel(sessionId: string): string { return `${IPC.ptyData}:${sessionId}` }
export function ptyExitChannel(sessionId: string): string { return `${IPC.ptyExit}:${sessionId}` }
export function agentSessionNameChannel(sessionId: string): string { return `${IPC.agentSessionName}:${sessionId}` }
export function chatEventChannel(nodeId: string): string { return `${IPC.chatEvent}:${nodeId}` }

src/shared/ipc.ts#L171-L185

IpcChannel is derived, not enumerated by hand:

export type IpcChannel = (typeof IPC)[keyof typeof IPC]

src/shared/ipc.ts#L169

Because IPC is as const, this is a union of string literals — any helper or handler typed as IpcChannel fails to compile on a typo. Note the observed asymmetry: agent:status is suffixed inline as `${IPC.agentStatus}:${sessionId}` in the preload (src/preload/index.ts#L197) instead of through a helper, so it is the one per-entity channel without a named constructor.

2. The bridge surface

src/preload/index.ts builds a plain object literal, api, grouped into feature namespaces, and hands it to Electron:

contextBridge.exposeInMainWorld('termsprawl', api)

export type TermsprawlApi = typeof api

src/preload/index.ts#L389-L391

Deriving the exported type from the value means the preload's own type can never disagree with the exposed object. Drift can only enter where the type is restated — see §3.

Three call shapes

Every method in api is one of exactly three shapes, and the distinction is meaningful:

Shape Electron call Return Used for
Request/response ipcRenderer.invoke(channel, …args) Promise<T> The vast majority — file reads, git ops, snapshots, settings.
Fire-and-forget ipcRenderer.send(channel, …args) void Hot-path or advisory writes with no ack: pty.write, pty.resize, agentTools.reply, relay.onFrame's subscribe/unsubscribe pings.
Push subscription ipcRenderer.on(channel, listener) () => void unsubscribe closure Main → renderer streams, named onX.

Push subscriptions always return their own teardown, and the closure is the only way to remove the listener:

onData: (id: string, cb: (data: string) => void): (() => void) => {
  const channel = ptyDataChannel(id)
  const listener = (_event, data: string): void => cb(data)
  ipcRenderer.on(channel, listener)
  return () => { ipcRenderer.removeListener(channel, listener) }
}

src/preload/index.ts#L160-L167

The same onX → unsubscribe contract is used for agentTools.onRequest, agentTools.onStatus, updates.onStatus, agent.onStatus, agent.onSessionName, and relay.onStatus.

Namespaces exposed

  • agentTools — onRequest, reply, status, onStatus
  • top-level — appVersion(), openExternal(url)
  • runtime — a static { kind: 'desktop' as const } literal (not IPC), used to discriminate the desktop build from the Server Edition shim's { kind: 'server' }
  • runtimeInfo() — packaged flag for UI gating
  • settings — get/set, account create/delete, permissionSupported, loginCommand, capabilities, setSkillEnabled, setPluginEnabled, reinstallHooks, usage
  • updates — check/download/install/dismiss plus onStatus
  • announcements.get
  • workspace — snapshot, saveNodes, bundle export/import, project add/close/archive/reopen/delete, updateSettings, renameProject, selectFolder
  • pty — create/write/resize/destroy/closeNode/readScrollback plus onData/onExit
  • diff.info, files (openDialog/read/write/list)
  • agent — onStatus, onSessionName
  • contextLinks — list/add/remove
  • git — 14 operations, each taking a GitTarget ({ cwd } local or { remote } ssh)
  • links — list/run/markDirty/update/sendToPeer
  • relay — status/connect/mintInvite/disconnect/sendFrame plus onStatus/onFrame

The pull-gated relay frame path

relay.onFrame is the one subscription that has a side effect on subscribe, not just on delivery:

onFrame: (cb: (frame: { from: string; text: string }) => void): (() => void) => {
  // Frames are push-gated: subscribing tells main to attach its frame
  // listener (audit B7 — no listener, no decrypted frames flowing).
  ipcRenderer.send(IPC.relayFrameSubscribe)
  const listener = (_event, frame) => cb(frame)
  ipcRenderer.on('relay:frame', listener)
  return () => {
    ipcRenderer.removeListener('relay:frame', listener)
    ipcRenderer.send(IPC.relayFrameUnsubscribe)
  }
}

src/preload/index.ts#L375-L385

This makes the renderer's subscription state the source of truth for whether main decrypts and forwards peer frames at all — a deliberate capability-narrowing rather than an always-on stream.

Call flow

sequenceDiagram
    participant R as Renderer (window.termsprawl)
    participant P as preload/index.ts
    participant S as shared/ipc.ts
    participant M as Main process

    Note over R,M: invoke — request/response
    R->>P: termsprawl.pty.create(req)
    P->>S: IPC.ptyCreate
    P->>M: ipcRenderer.invoke("pty:create", req)
    M-->>P: PtyCreateResult
    P-->>R: Promise<PtyCreateResult>

    Note over R,M: send — fire and forget
    R->>P: termsprawl.pty.write(id, data)
    P->>S: IPC.ptyWrite
    P->>M: ipcRenderer.send("pty:write", id, data)
    Note over P,M: no reply path

    Note over R,M: on — per-entity push
    R->>P: termsprawl.pty.onData(id, cb)
    P->>S: ptyDataChannel(id)
    S-->>P: "pty:data:<id>"
    P->>M: ipcRenderer.on("pty:data:<id>", listener)
    M-->>P: payload on pty:data:<id>
    P-->>R: cb(data)
    R->>P: unsubscribe()
    P->>M: removeListener("pty:data:<id>", listener)

    Note over R,M: relay frames — subscribe gates decryption
    R->>P: termsprawl.relay.onFrame(cb)
    P->>M: send(relay:frame:subscribe)
    M-->>P: relay:frame
    P-->>R: cb({ from, text })
Loading

Key nodes. The renderer only ever names a namespace method — it never sees ipcRenderer and never constructs a channel string. The preload is the single translation point from a method call to a channel constant. Per-entity push channels are distinct listeners per id, so a renderer that forgets to call the returned teardown accumulates live ipcRenderer listeners indefinitely. The relay subscription is the only path where the renderer's listener presence changes main-process behavior.

3. The renderer-side declaration

src/renderer/src/env.d.ts redeclares the whole surface as an ambient global:

declare global {
  interface Window {
    termsprawl: {
      agentTools?: {
        onRequest(callback: (request: CanvasToolRequest) => void): …

src/renderer/src/env.d.ts#L50-L54

This is a hand-written mirror, not an import of TermsprawlApi. It independently re-imports every payload type from @shared/types, @shared/agent-status, @shared/update-status, and ../../core/chat/types. It is therefore the one place in the contract where drift is structurally possible: a method added to the preload without a matching entry here compiles fine in preload and fails only at the renderer call site. Note also that agentTools is declared optional (agentTools?), so renderer code is expected to treat the agent-tool surface as potentially absent.

Boundary conditions and invariants

  • Renderer never touches ipcRenderer. Stated as a rule in src/preload/AGENTS.md#L12. Everything crosses the bridge by structured clone, so payloads must be plain serializable data — the shared types imported by the preload are all plain object shapes.
  • Channel strings live only in src/shared/ipc.ts. Two observed exceptions bypass the map: ipcRenderer.invoke('app:runtime-info') (src/preload/index.ts#L84) and ipcRenderer.on('relay:frame', …) (#L380). These are the escape hatches through which the IpcChannel union no longer protects anything.
  • runtimeInfo has a fail-soft fallback. invoke('app:runtime-info').catch(() => ({ packaged: true })) means a missing or failing handler resolves to the packaged-shaped value rather than rejecting.
  • Build artifact path is a silent-failure trap. The preload builds as index.mjs (ESM) and main must reference ../preload/index.mjs, not .js; a stale path "silently breaks the bridge in production builds" (src/preload/AGENTS.md#L13-L15). A silently broken bridge presents as window.termsprawl === undefined, which is why the renderer declaration matters.
  • Credential material stays in main. githubClone receives only the repo reference; the handler mints the credential-bearing clone URL and hands it straight to the local clone (src/shared/ipc.ts#L121-L129). This is the pattern any new credential-touching capability should follow.
  • Edition discrimination is a literal on the surface. The desktop preload hardcodes runtime: { kind: 'desktop' }; the Server Edition shim carries { kind: 'server' }. Settings UI gates on this so desktop-only surfaces do not render in the browser edition (src/preload/index.ts#L76-L84).
  • Declared-but-unexposed channels exist. IPC.projectImport (src/shared/ipc.ts#L41) has no corresponding method in the read preload ranges. The inventory being a superset of the desktop bridge is expected — Server Edition RPC and push-only channels (githubSuggest, browserAgentOpen, chatEvent) share the same map.

Extension points: adding a new IPC capability

  1. Add the channel to the IPC object in src/shared/ipc.ts. Never write the string anywhere else; pick the existing prefix convention (pty:, git:, cloud:, settings:…) so the group stays greppable.
  2. If it is per-entity push, add an xChannel(id) helper next to ptyDataChannel / ptyExitChannel / agentSessionNameChannel / chatEventChannel rather than interpolating inline.
  3. Add the method to the api object in src/preload/index.ts, choosing the correct shape: invoke returning a Promise for request/response, send returning void for ack-less writes, and onX(channelId, cb) returning a teardown closure for pushes. Keep the namespace grouping.
  4. Mirror it in src/renderer/src/env.d.ts. This is the step the compiler will not remind you about.
  5. Register the handler on the other side — the Electron main dispatcher, or the Server Edition WS-RPC table for methods that must also work in the browser edition. A method that exists only in the preload will surface at runtime as an unresolved invoke (or the runtimeInfo-style fallback, if you add one).

Sources

Sources: src/shared/ipc.ts, src/preload/index.ts, src/preload/AGENTS.md, src/renderer/src/env.d.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally