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
nodeIntegrationin renderer — The preload is the sole bridge - All system access is auditable — Check
desktop-rpc.tsto see every capability - Type safety across processes — Effect schemas catch serialization errors at build time
- Testable boundaries — Mock
DesktopRpcClientfor 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.tsexposes a typedDesktopApiviacontextBridge, hiding IPC detailselectron-rpc.tsimplements the RPC client transport overipcRenderermain.tshosts 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →