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

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, the codebase creates a client that serializes calls through Electron's IPC:

// 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 to guarantee type safety.

How desktop/src Enforces Separation of Concerns

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

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:

// 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 — The Contract

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

// 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 — The Agent-Host Entry Point

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

// 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 OS detection ("darwin" | "win32" | "linux") Provides DesktopPlatform type without leaking process.platform to renderer
embedded-browser.ts Chromium webview management Browser RPCs stay in main process; UI observes state via streams
sqlite-driver.ts Local SQLite storage Only imported by main/daemon; renderer accesses via storage.* RPCs
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:

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

// 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 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
  • preload.ts exposes a typed DesktopApi via contextBridge, hiding IPC details
  • electron-rpc.ts implements the RPC client transport over ipcRenderer
  • main.ts hosts the RPC server and spawns the ACN daemon, never touching UI code
  • System utilities (sqlite-driver.ts, 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, 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. 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. 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →