# Understanding the Client-Host and Agent-Host Boundary in Magnitude's Desktop Application

> Explore the client-host and agent-host boundary in Magnitude's desktop app. Understand how Electron IPC and typed RPC in desktop/src ensure secure system resource access.

- Repository: [Magnitude/magnitude](https://github.com/magnitudedev/magnitude)
- Tags: architecture
- Published: 2026-09-06

---

**The boundary between client-host and agent-host in Magnitude's desktop application is the Electron IPC channel carrying Effect-RPC payloads, with `desktop/src` enforcing strict separation through a typed RPC layer that prevents renderer process code from directly accessing system resources.**

Magnitude is an open-source desktop automation framework that separates untrusted user code from privileged system operations. Its Electron-based architecture uses `desktop/src` to maintain a clean boundary between the UI renderer and the underlying agent runtime. This article explains exactly how that separation works and which files enforce it.

## The Three-Layer Architecture

Magnitude isolates functionality across three distinct layers:

- **Renderer (client-host)** — The Electron renderer process running the React UI
- **Preload (bridge)** — An intermediate layer converting typed API calls to RPC messages
- **Main / Daemon (agent-host)** — The Electron main process plus ACN daemon handling privileged operations

All communication flows in one direction: renderer → preload → IPC → main → daemon. No layer skips its neighbor.

## The Client-Host Boundary: Electron IPC with Effect-RPC

The definitive boundary sits between the preload script and the main process. In [`desktop/src/electron-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop/src/electron-rpc.ts), the codebase creates a client that serializes calls through Electron's IPC:

```typescript
// desktop/src/electron-rpc.ts
const makeElectronRpcClientLayer = (ipcRenderer: IpcRenderer) => {
  return Layer.succeed(DesktopRpcClient, {
    invoke: (request) => Effect.gen(function* () {
      const response = yield* Effect.async<unknown>((resume) => {
        ipcRenderer.invoke(DesktopRpcChannel, request).then(
          (res) => resume(Effect.succeed(res)),
          (err) => resume(Effect.fail(err))
        )
      })
      return response
    })
  })
}

```

The `DesktopRpcChannel` constant (`"__magnitude:desktop-rpc:request"`) names the IPC channel. Every call crosses this wire using schemas defined in [`desktop/src/desktop-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop/src/desktop-rpc.ts) to guarantee type safety.

## How `desktop/src` Enforces Separation of Concerns

Each file in `desktop/src` has a single, non-overlapping responsibility:

### [`preload.ts`](https://github.com/magnitudedev/magnitude/blob/main/preload.ts) — The Bridge

The preload script is the *only* code with access to both `contextBridge` and `ipcRenderer`. It exposes a clean `DesktopApi` that hides all IPC complexity:

```typescript
// desktop/src/preload.ts
export const makeDesktopApi = (): DesktopApi => {
  const runtime = makeDesktopRpcRuntime()
  
  return {
    platform: getPlatform(),
    storage: {
      getItem: (key) => RpcClient.invoke(StorageGetItem, { key }, runtime),
      setItem: (key, value) => RpcClient.invoke(StorageSetItem, { key, value }, runtime),
    },
    dialogs: {
      openFile: (opts) => RpcClient.invoke(DialogOpenFile, opts ?? {}, runtime),
    },
    onMenuAction: (handler) => RpcClient.stream(MenuActionEvent, {}, handler, runtime),
    browser: {
      createTab: (url) => RpcClient.invoke(BrowserCreateTab, { url }, runtime),
      navigate: (tabId, url) => RpcClient.invoke(BrowserNavigate, { tabId, url }, runtime),
      observe: (onState, onError, onEnd) => 
        RpcClient.stream(BrowserWorkspaceStateEvent, {}, onState, runtime),
    },
  }
}

contextBridge.exposeInMainWorld("__magnitudeDesktop", makeDesktopApi())

```

Renderer code accesses this through `window.__magnitudeDesktop` — a typed, restricted surface area.

### [`desktop-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop-rpc.ts) — The Contract

All RPC payloads live in one file, making the boundary explicit and auditable:

```typescript
// desktop/src/desktop-rpc.ts
export const DesktopRpcs = RpcGroup.make("DesktopRpcs")
  .add(StorageGetItem)
  .add(StorageSetItem)
  .add(DialogOpenFile)
  .add(BrowserCreateTab)
  .add(BrowserNavigate)
  .add(BrowserWorkspaceStateEvent)
  .add(MenuActionEvent)
  // ...

// Schemas enforce serialization boundaries
const BrowserWorkspaceStateSchema = Schema.Struct({
  tabs: Schema.Array(Schema.Struct({
    id: Schema.String,
    url: Schema.String,
    title: Schema.optional(Schema.String),
    isLoading: Schema.Boolean,
    canGoBack: Schema.Boolean,
    canGoForward: Schema.Boolean,
  })),
  activeTabId: Schema.optional(Schema.String),
})

```

### [`main.ts`](https://github.com/magnitudedev/magnitude/blob/main/main.ts) — The Agent-Host Entry Point

The main process registers handlers and spawns the ACN daemon. It never imports UI code:

```typescript
// desktop/src/main.ts (simplified)
const main = async () => {
  await app.whenReady()
  
  const rpcServer = yield* RpcServer.make(DesktopRpcs)
  
  // Wire IPC requests to the RPC server
  ipcMain.handle(DesktopRpcChannel, async (_event, request) => {
    return rpcServer.handle(request)
  })
  
  // Spawn and supervise the ACN daemon
  const daemon = yield* Daemon.spawn()
  
  createWindow()
}

```

### Platform and Utility Modules

| File | Responsibility | Boundary Enforcement |
|------|---------------|----------------------|
| [`platform.ts`](https://github.com/magnitudedev/magnitude/blob/main/platform.ts) | OS detection (`"darwin" \| "win32" \| "linux"`) | Provides `DesktopPlatform` type without leaking `process.platform` to renderer |
| [`embedded-browser.ts`](https://github.com/magnitudedev/magnitude/blob/main/embedded-browser.ts) | Chromium webview management | Browser RPCs stay in main process; UI observes state via streams |
| [`sqlite-driver.ts`](https://github.com/magnitudedev/magnitude/blob/main/sqlite-driver.ts) | Local SQLite storage | Only imported by main/daemon; renderer accesses via `storage.*` RPCs |
| [`shell-env.ts`](https://github.com/magnitudedev/magnitude/blob/main/shell-env.ts) | Shell environment resolution | Main-process only, used for daemon spawning |

## Practical Example: Cross-Boundary Flow

Here's how a file dialog actually traverses the boundary:

```typescript
// In a React component (renderer/client-host)
const handleOpenFile = async () => {
  // Typed call to window.__magnitudeDesktop
  const files = await window.__magnitudeDesktop.dialogs.openFile({ 
    multiple: true,
    filters: [{ name: 'Images', extensions: ['png', 'jpg'] }]
  })
  
  if (files) {
    setSelectedFiles(files)
  }
}

// What happens internally:
// 1. preload.ts converts to RpcClient.invoke(DialogOpenFile, ...)
// 2. electron-rpc.ts serializes to IPC channel
// 3. main.ts receives via ipcMain.handle, forwards to RpcServer
// 4. RpcServer delegates to DialogOpenFile handler
// 5. Handler uses Electron's dialog.showOpenDialog (privileged)
// 6. Result flows back through the same chain

```

Streaming operations like browser observation use the same boundary:

```typescript
// Subscribe to embedded browser state changes
const unsubscribe = window.__magnitudeDesktop.browser.observe(
  (state) => updateBrowserUI(state),
  (error) => console.error('Browser error:', error),
  () => console.log('Browser closed')
)

// Cleanup stops the stream at all layers
return () => unsubscribe()

```

## Why This Separation Matters

The architecture prevents common Electron security pitfalls:

- **No direct `nodeIntegration` in renderer** — The preload is the sole bridge
- **All system access is auditable** — Check [`desktop-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop-rpc.ts) to see every capability
- **Type safety across processes** — Effect schemas catch serialization errors at build time
- **Testable boundaries** — Mock `DesktopRpcClient` for renderer tests without Electron

The ACN daemon adds a second isolation layer: even the main process delegates to a separate runtime for user-defined automation logic.

## Summary

- The **client-host ↔ agent-host boundary** is Electron IPC carrying Effect-RPC payloads, defined in [`desktop/src/desktop-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop/src/desktop-rpc.ts)
- **[`preload.ts`](https://github.com/magnitudedev/magnitude/blob/main/preload.ts)** exposes a typed `DesktopApi` via `contextBridge`, hiding IPC details
- **[`electron-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/electron-rpc.ts)** implements the RPC client transport over `ipcRenderer`
- **[`main.ts`](https://github.com/magnitudedev/magnitude/blob/main/main.ts)** hosts the RPC server and spawns the ACN daemon, never touching UI code
- **System utilities** ([`sqlite-driver.ts`](https://github.com/magnitudedev/magnitude/blob/main/sqlite-driver.ts), [`embedded-browser.ts`](https://github.com/magnitudedev/magnitude/blob/main/embedded-browser.ts), etc.) are agent-host only
- **Schemas guarantee** that all data crossing the boundary is serializable and version-checked

## Frequently Asked Questions

### What prevents renderer code from calling `ipcRenderer` directly?

The preload script runs with `nodeIntegration: false` and `contextIsolation: true` in the renderer's `webPreferences`. Only the preload has access to `ipcRenderer`, and it never exposes that object directly. Instead, it exposes the typed `DesktopApi` through `contextBridge.exposeInMainWorld`.

### Can I add new system capabilities to the renderer?

You must define a new RPC in [`desktop/src/desktop-rpc.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop/src/desktop-rpc.ts), add it to `DesktopRpcs`, implement the handler in the main process (or delegate to the daemon), and expose it through `makeDesktopApi()` in [`desktop/src/preload.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop/src/preload.ts). This three-step process ensures the boundary remains explicit.

### How does the embedded browser stay isolated from the main UI?

The embedded browser runs in a separate `WebContentsView` managed by [`desktop/src/embedded-browser.ts`](https://github.com/magnitudedev/magnitude/blob/main/desktop/src/embedded-browser.ts). Navigation, permission requests, and state changes flow through the RPC layer. The renderer observes browser state via the `BrowserWorkspaceStateEvent` stream but cannot directly manipulate the webview.

### What is the ACN daemon, and why does it exist as a separate process?

The ACN (Automation Control Node) daemon runs user-defined automation scripts with its own Node.js runtime. Separating it from the main process provides crash isolation: a misbehaving script cannot bring down the entire desktop application. The main process supervises the daemon and proxies RPC calls to it.