Electron Main Process, Preload, and Renderer Architecture in Craft Agents
Craft Agents implements a secure three-layer Electron architecture where the main process manages the native runtime and RPC server, the preload script constructs a typed RPC bridge exposing a window.craft API, and the sandboxed React renderer communicates exclusively through that API without direct access to Node.js or Electron APIs.
Craft Agents ships as a full-stack Electron application that strictly separates concerns across the main process, preload script, and renderer. This architecture follows Electron security best practices by enforcing context isolation and disabling node integration, while providing a type-safe RPC layer that allows the React UI to communicate with backend services. Understanding how these three layers interact is essential for developers extending the desktop client or debugging transport issues.
Main Process: Bootstrapping the Native Runtime
The main process serves as the entry point and native runtime controller. Located in apps/electron/src/main/index.ts, it initializes the Electron app, creates the browser window, loads shell environment variables, and registers all RPC handlers.
Shell Environment and CLI Tooling
Before creating any windows, the main process loads the user’s shell environment to ensure tools like nvm or Homebrew are available to the embedded agent runtime. It also initializes Sentry conditionally and exposes bundled CLI tools through environment variables.
// apps/electron/src/main/index.ts (excerpt)
import { app, BrowserWindow, ipcMain } from 'electron';
import { loadShellEnv } from './shell-env';
import { registerAllRpcHandlers } from './handlers/index';
// 1. Load shell environment (nvm, Homebrew, etc.)
loadShellEnv();
// 2. Initialize Sentry only when DSN is provided
Sentry.init({ dsn: process.env.SENTRY_ELECTRON_INGEST_URL, ... });
// 3. Expose bundled CLI tools to agents
process.env.CRAFT_UV = ...;
process.env.CRAFT_BUN = ...;
// 4. Create BrowserWindow with security settings
const win = new BrowserWindow({
webPreferences: {
preload: join(__dirname, '..', 'preload', 'bootstrap.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadURL(`file://${join(__dirname, '..', 'renderer', 'index.html')}`);
// 5. Register RPC handlers
registerAllRpcHandlers();
Key responsibilities include:
- Shell environment loading via
loadShellEnv()ensures Python and Node.js version managers are accessible to spawned agent processes. - Security configuration with
contextIsolation: trueandnodeIntegration: falseprevents the renderer from accessing Node.js APIs directly. - Preload injection through
webPreferences.preloadestablishes the bridge before the renderer loads.
Preload Script: The Typed RPC Bridge
The preload script runs in a privileged context with access to both Electron APIs and the renderer’s DOM. Located in apps/electron/src/preload/bootstrap.ts, it builds a typed RPC client that communicates with the main process over IPC, then exposes a safe window.craft API via contextBridge.
Dual-Mode Transport Architecture
The preload detects whether to run in thin-client mode (remote server only) or normal mode (local server + optional remote workspace):
// apps/electron/src/preload/bootstrap.ts (excerpt)
import { contextBridge, ipcRenderer, shell } from 'electron';
import { WsRpcClient } from '../transport/client';
import { RoutedClient } from '../transport/routed-client';
import { buildClientApi } from '../transport/build-api';
import { CHANNEL_MAP } from '../transport/channel-map';
// Detect thin-client mode via environment variable
const isClientOnly = !!process.env.CRAFT_SERVER_URL;
let client: TransportClient;
if (isClientOnly) {
// Direct connection to remote server
client = new WsRpcClient(process.env.CRAFT_SERVER_URL!, ...);
} else {
// Local WebSocket server with optional remote workspace routing
const local = new WsRpcClient(`ws://127.0.0.1:${wsPort}`, ...);
const routed = new RoutedClient(local, initialWorkspaceClient);
client = routed;
}
// Register capability handlers for server-to-client calls
client.handleCapability(CLIENT_OPEN_EXTERNAL, (url) => shell.openExternal(url));
client.handleCapability(CLIENT_SHOW_IN_FOLDER, (path) => shell.showItemInFolder(path));
client.handleCapability(CLIENT_BROWSER_INVOKE, (req) =>
ipcRenderer.invoke('__browser:invoke', req)
);
// Build and expose the typed API
const api = buildClientApi(client, CHANNEL_MAP, (ch) => client.isChannelAvailable(ch));
api.getRuntimeEnvironment = () => 'electron';
contextBridge.exposeInMainWorld('craft', api);
Key concepts:
- WsRpcClient handles the underlying WebSocket communication for both local and remote connections.
- RoutedClient multiplexes calls between a local server and remote workspace when operating in hybrid mode.
- Capability handlers allow the server to request privileged actions like opening external URLs or showing files in the system folder.
- buildClientApi generates a strongly-typed proxy object that mirrors the server’s RPC surface, providing methods like
api.session.list()orapi.file.open().
Renderer Process: The Sandboxed React UI
The renderer is a Vite-built React application that runs in a sandboxed web page. Located in apps/electron/src/renderer/, it never imports Electron directly; all communication flows through the window.craft object injected by the preload.
Transport State Management
The renderer monitors connection health using the useTransportConnectionState hook:
// apps/electron/src/renderer/hooks/useTransportConnectionState.ts
import { useEffect, useState } from 'react';
import { TransportConnectionState } from '@craft-agent/server-core/transport';
export function useTransportConnectionState() {
const [state, setState] = useState<TransportConnectionState>('disconnected');
useEffect(() => {
const client = (window as any).craft;
const unsubscribe = client.onConnectionStateChanged(setState);
return unsubscribe;
}, []);
return {
state,
reconnect: () => (window as any).craft.reconnectNow()
};
}
Usage in a React component:
// apps/electron/src/renderer/App.tsx (simplified)
function App() {
const { state, reconnect } = useTransportConnectionState();
if (state === 'disconnected') {
return <button onClick={reconnect}>Reconnect</button>;
}
// All backend calls go through window.craft
const sessions = await (window as any).craft.session.list();
return <div>{/* React components */}</div>;
}
How the Three Layers Communicate
The architecture enforces strict boundaries while enabling rich functionality:
-
Main → Preload: The
BrowserWindowloads the preload script viawebPreferences.preload. The preload can callipcRenderer.invoketo request data from the main process. -
Preload → Main: The transport client sends RPC messages over Electron’s IPC channels. Handlers registered via
registerAllRpcHandlers()in the main process execute requests and return results. -
Preload → Renderer: The
contextBridge.exposeInMainWorld('craft', api)call makes the RPC client available as a read-only global object. The renderer calls methods likewindow.craft.session.create(), which are serialized, routed through the transport layer, and processed by the main process.
This separation ensures that even if the renderer is compromised, it cannot access the file system or shell, as all privileged operations must traverse the capability-based RPC system defined in the preload.
Practical Code Examples
Creating a Session from the UI
// In a React component (renderer layer)
async function createSession(name: string) {
const result = await (window as any).craft.session.create({ name });
console.log('Created session:', result);
}
The session.create RPC surface is defined in packages/server-core/src/handlers/session.ts and exposed through the preload’s buildClientApi function.
Opening Files in the System Explorer
// Renderer calls the capability exposed by preload
async function revealFile(path: string) {
await (window as any).craft.client.showInFolder(path);
}
This invokes the CLIENT_SHOW_IN_FOLDER capability handler registered in bootstrap.ts, which forwards to Electron’s shell.showItemInFolder.
Handling Connection State Changes
useEffect(() => {
const unsubscribe = (window as any).craft
.onConnectionStateChanged((state) => {
if (state === 'disconnected') {
showReconnectDialog();
}
});
return unsubscribe;
}, []);
Summary
- Main Process: Controls the Electron runtime in
apps/electron/src/main/index.ts, loads shell environments, bundles CLI tools (uv, bun), and hosts the RPC server. - Preload Script: Acts as a privileged bridge in
apps/electron/src/preload/bootstrap.ts, creating either a localWsRpcClientor remote connection, and exposing a type-safewindow.craftAPI viacontextBridge. - Renderer: A sandboxed React app built with Vite that communicates exclusively through
window.craft, maintaining security throughcontextIsolationandnodeIntegration: false. - Transport Layer: Supports both embedded server mode and thin-client mode (
CRAFT_SERVER_URL), withRoutedClienthandling multiplexing between local and remote workspaces. - Security Model: All privileged operations (file system, shell, external links) are capability-based, registered in the preload, and invoked by the main process only through validated IPC channels.
Frequently Asked Questions
How does the preload script maintain security while enabling powerful features?
The preload script runs in a privileged context with access to both Node.js and the DOM, but it exposes only a curated API via contextBridge.exposeInMainWorld(). This creates a read-only, type-safe bridge (window.craft) that the renderer can call, but the renderer cannot access Electron or Node APIs directly. All privileged actions like shell.openExternal are wrapped as capability handlers that validate requests before execution.
Can Craft Agents run as a thin client without the local Electron server?
Yes. When the CRAFT_SERVER_URL environment variable is set, the preload script in bootstrap.ts creates a direct WsRpcClient connection to the remote server instead of starting a local WebSocket server. In this mode, the main process still manages the window lifecycle, but all RPC traffic routes directly to the remote Craft Agents server, enabling a lightweight client architecture.
Why does the renderer use window.craft instead of importing Electron directly?
The renderer explicitly disables nodeIntegration and enables contextIsolation for security. This prevents arbitrary code execution if the web content is compromised. By forcing all communication through the window.craft object exposed by the preload, the architecture ensures that only predefined, type-safe RPC methods are available, eliminating direct access to the file system, shell, or other native resources.
How does the main process make shell tools like nvm or brew available to agents?
The main process calls loadShellEnv() from apps/electron/src/main/shell-env.ts before creating the browser window. This function loads the user’s shell environment variables (including PATH modifications from .bashrc, .zshrc, or similar), ensuring that version managers and package managers are available in the environment where agent processes execute.
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 →