Skip to main content

Electron Multi-Process Architecture

Craft Agents uses Electron’s process isolation model for security and stability:
┌─────────────────────────────────────────────────────────────┐
│ Main Process (Node.js)                                       │
│ - Full system access (filesystem, network, native APIs)     │
│ - Window management                                          │
│ - Agent backend orchestration                               │
│ - IPC handlers                                              │
└─────────────────────────────────────────────────────────────┘

            │ IPC Bridge (contextBridge)

┌─────────────────────────────────────────────────────────────┐
│ Renderer Process (Chromium)                                  │
│ - React UI                                                   │
│ - No direct Node.js access (sandboxed)                      │
│ - Communicates via IPC only                                 │
└─────────────────────────────────────────────────────────────┘
The preload script acts as a secure bridge, exposing only whitelisted APIs from the main process to the renderer.

Directory Structure

apps/electron/
├── src/
│   ├── main/          # Main process (Node.js)
│   │   ├── index.ts   # Entry point
│   │   ├── ipc.ts     # IPC handlers (4,200+ lines)
│   │   ├── sessions.ts # Agent backend management
│   │   ├── window-manager.ts
│   │   └── ...
│   ├── preload/       # Context bridge
│   │   ├── index.ts   # Main preload script
│   │   └── browser-toolbar.ts
│   ├── renderer/      # React UI (Vite)
│   │   ├── components/
│   │   ├── pages/
│   │   ├── hooks/
│   │   └── main.tsx   # React entry point
│   └── shared/        # Types shared between processes
│       └── types.ts   # IPC channel definitions
├── dist/              # Build output
│   ├── main.cjs       # Bundled main process
│   ├── preload.cjs    # Bundled preload
│   └── renderer/      # Vite build output
├── package.json
└── vite.config.ts

Main Process

The main process is the backend of the Electron app. It has full access to Node.js APIs and native OS features.

Key Responsibilities

Window Management

Creating and controlling BrowserWindows

Agent Orchestration

Managing ClaudeAgent and PiAgent backends

IPC Handlers

Responding to renderer requests

File System

Reading/writing config, sessions, credentials

Core Files

FilePurposeKey Exports
index.tsApp entry pointapp.whenReady(), lifecycle
ipc.tsIPC handlers140+ IPC channel handlers
sessions.tsAgent backend lifecyclecreateSession(), sendMessage()
window-manager.tsWindow orchestrationWindowManager class
browser-pane-manager.tsBrowser automationCDP integration
ipc.ts contains all IPC handlers for the renderer process. This includes session management, source CRUD, workspace operations, preferences, OAuth flows, and more. It’s the entire backend API for the UI.Future refactoring may split this into smaller domain-specific modules.

Example: Session Creation Flow

// apps/electron/src/main/sessions.ts
export async function createSession(
  workspaceId: string,
  llmConnectionId: string,
  options: SessionOptions
): Promise<Session> {
  // 1. Resolve backend (Claude vs Pi)
  const backend = await createBackendFromConnection({
    connectionId: llmConnectionId,
    workspaceId,
  });

  // 2. Load sources (MCP servers, APIs)
  const sources = await loadWorkspaceSources(workspaceId);

  // 3. Initialize agent
  await backend.initialize({
    mcpServers: sources,
    permissionMode: 'ask',
    thinkingLevel: 'low',
  });

  // 4. Persist session metadata
  const session = await createStoredSession({
    workspaceId,
    llmConnectionId,
    status: 'inbox',
  });

  return session;
}

Preload Scripts

Preload scripts run before the renderer process loads and bridge the main and renderer processes securely.

Context Bridge Pattern

// apps/electron/src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';

// Expose ONLY whitelisted APIs to renderer
contextBridge.exposeInMainWorld('electronAPI', {
  // Session management
  createSession: (workspaceId: string) => 
    ipcRenderer.invoke('session:create', workspaceId),
  
  sendMessage: (sessionId: string, content: string) =>
    ipcRenderer.invoke('session:sendMessage', sessionId, content),
  
  // Event listeners (streaming responses)
  onSessionEvent: (callback: (event: SessionEvent) => void) => {
    ipcRenderer.on('session:event', (_, event) => callback(event));
  },
});
The preload script uses contextBridge.exposeInMainWorld() to safely expose main process functionality without giving the renderer full Node.js access.

Security Model

✅ Allowed

Whitelisted IPC channels only

❌ Blocked

Direct filesystem, require(), exec()
This prevents malicious code in the renderer (e.g., from a compromised npm package) from accessing the system.

Renderer Process

The renderer process is the frontend - a React app running in Chromium. It’s sandboxed and communicates with the main process via IPC.

Tech Stack

TechnologyUsage
React 18UI framework
ViteDev server + bundler
shadcn/uiComponent library
Tailwind CSS v4Styling
JotaiState management
TanStack TableData tables

Key Directories

renderer/
├── components/
│   ├── chat/          # Chat interface
│   ├── sidebar/       # Workspace/session tree
│   ├── sources/       # Source management UI
│   └── ui/            # shadcn/ui components
├── pages/
│   ├── AllSessions.tsx
│   ├── Chat.tsx
│   └── Settings.tsx
├── hooks/
│   ├── useSession.ts
│   └── useSources.ts
├── lib/
│   └── ipc.ts         # Typed IPC client
└── main.tsx           # React entry point

Example: Sending a Message

// apps/electron/src/renderer/pages/Chat.tsx
import { useCallback } from 'react';
import { ipc } from '@/lib/ipc';

export function Chat({ sessionId }: { sessionId: string }) {
  const sendMessage = useCallback(async (content: string) => {
    // Invoke IPC to main process
    await ipc.sendMessage(sessionId, content);
  }, [sessionId]);

  return (
    <ChatInput onSubmit={sendMessage} />
  );
}
// apps/electron/src/renderer/lib/ipc.ts
export const ipc = {
  sendMessage: (sessionId: string, content: string) => 
    window.electronAPI.sendMessage(sessionId, content),
};

IPC Communication

Electron uses Inter-Process Communication (IPC) for renderer ↔ main communication.

IPC Patterns

invoke/handle

Request-response (async/await)

send/on

One-way messages and events

Request-Response Pattern

// Renderer → Main
const result = await ipcRenderer.invoke('session:create', workspaceId);

// Main handler
ipcMain.handle('session:create', async (event, workspaceId) => {
  return await createSession(workspaceId);
});

Event Streaming Pattern

// Main → Renderer (streaming agent events)
window.webContents.send('session:event', {
  type: 'message:start',
  sessionId: 'abc',
});

// Renderer listener
ipcRenderer.on('session:event', (event, data) => {
  console.log('Received event:', data);
});
Agent responses stream through the main process:
  1. Agent backend yields events (for await (const event of agent.chat(...)))
  2. Main process forwards events via webContents.send('session:event', ...)
  3. Renderer listens on ipcRenderer.on('session:event', ...) and updates UI
This allows real-time streaming without blocking the UI thread.

Window Management

The WindowManager class orchestrates multiple windows:
// apps/electron/src/main/window-manager.ts
export class WindowManager {
  private mainWindow: BrowserWindow | null = null;
  private browserPanes = new Map<string, BrowserWindow>();

  async createMainWindow() {
    this.mainWindow = new BrowserWindow({
      width: 1400,
      height: 900,
      webPreferences: {
        preload: join(__dirname, 'preload.cjs'),
        contextIsolation: true,
        nodeIntegration: false, // Security best practice
      },
    });

    await this.mainWindow.loadFile('dist/renderer/index.html');
  }

  createBrowserPane(url: string): BrowserWindow {
    const pane = new BrowserWindow({
      parent: this.mainWindow,
      webPreferences: {
        preload: join(__dirname, 'browser-toolbar-preload.cjs'),
      },
    });
    this.browserPanes.set(url, pane);
    return pane;
  }
}

Build Process

Craft Agents uses different bundlers for different processes:

esbuild

Main process and preload (fast, native)

Vite

Renderer process (HMR, optimized chunks)

Build Scripts

// apps/electron/package.json
{
  "scripts": {
    "build:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron",
    "build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron",
    "build:renderer": "vite build",
    "build": "bun run build:main && bun run build:preload && bun run build:renderer"
  }
}

Environment Variables

OAuth credentials are baked into the build at compile time:
# .env file
MICROSOFT_OAUTH_CLIENT_ID=your-client-id
SLACK_OAUTH_CLIENT_ID=your-slack-client-id
SLACK_OAUTH_CLIENT_SECRET=your-slack-client-secret
These are injected via esbuild’s --define flag:
esbuild ... --define:process.env.SLACK_OAUTH_CLIENT_ID=\"${SLACK_OAUTH_CLIENT_ID:-}\"
Google OAuth credentials are NOT baked in. Users provide their own via source configuration.

Distribution

Electron apps are packaged using platform-specific scripts:

macOS (DMG)

bash scripts/build-dmg.sh arm64   # Apple Silicon
bash scripts/build-dmg.sh x64     # Intel

Windows (Installer)

powershell -ExecutionPolicy Bypass -File scripts/build-win.ps1
Packaged apps include:
  • Bundled main process (main.cjs)
  • Bundled preload scripts
  • Vite-built renderer assets
  • Electron runtime
  • Native dependencies

Next Steps

Packages

Explore @craft-agent/core and @craft-agent/shared

Agent Backends

How Claude and Pi SDKs are abstracted

Build docs developers (and LLMs) love