# How Modly Handles IPC Communication Between Electron Main and Renderer for Workflow Execution

> Learn how Modly manages IPC communication between Electron main and renderer processes for workflow execution. Discover its preload script architecture and subprocess streaming for seamless integration.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: internals
- Published: 2026-08-20

---

**Modly uses a preload script architecture with `contextBridge.exposeInMainWorld` to expose a safe `window.electron` API that forwards renderer calls to the main process via `ipcRenderer.invoke`, while workflow execution spawns a Python subprocess that streams progress back through `ipcMain.emit`.**

Modly is an open-source Electron application for procedural 3D content generation. Its **IPC (Inter-Process Communication)** layer enables the renderer process UI to control workflow execution in the main process, which delegates heavy computation to a Python backend. This article examines the complete IPC pathway based on the `lightningpixel/modly` source code.

## The Preload Script: Secure API Exposure

Electron's **context isolation** prevents direct access to Node.js APIs from the renderer. Modly solves this through [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts), which creates a controlled bridge.

### Exposing Workflow Methods

The preload script uses `contextBridge.exposeInMainWorld` to attach methods to `window.electron`:

```typescript
// electron/preload/electron-api.ts
contextBridge.exposeInMainWorld('electron', {
  workflow: {
    run: (args) => ipcRenderer.invoke('workflow:run', args),
    stop: () => ipcRenderer.send('workflow:stop'),
    onProgress: (callback) => {
      ipcRenderer.on('workflow:progress', (_, data) => callback(data))
      return () => ipcRenderer.removeAllListeners('workflow:progress')
    }
  },
  workflows: {
    list: () => ipcRenderer.invoke('workflows:list'),
    save: (workflow) => ipcRenderer.invoke('workflows:save', workflow),
    delete: (id) => ipcRenderer.invoke('workflows:delete', id),
    import: () => ipcRenderer.invoke('workflows:import'),
    export: (id) => ipcRenderer.invoke('workflows:export', id)
  }
})

```

Key patterns here:

- **`ipcRenderer.invoke`** for **request/response** RPC (returns promises)
- **`ipcRenderer.send`** for **fire-and-forget** commands
- **`ipcRenderer.on`** with cleanup functions for **event subscriptions**

## Main Process Handlers: IPC Registration

The main process registers handlers in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) using `ipcMain.handle` and `ipcMain.on`:

| Channel | Handler Type | Purpose |
|---------|-----------|---------|
| `workflows:list` | `ipcMain.handle` | Returns array of saved workflow JSON files |
| `workflows:save` | `ipcMain.handle` | Persists workflow definition to disk |
| `workflows:delete` | `ipcMain.handle` | Removes workflow file by ID |
| `workflows:import` | `ipcMain.handle` | Reads user-selected JSON, validates, stores |
| `workflows:export` | `ipcMain.handle` | Writes workflow to user-chosen path |

These handlers perform filesystem operations through Node.js APIs—operations inaccessible to the isolated renderer.

## Workflow Execution: The Complete IPC Flow

Running a workflow triggers Modly's most complex IPC sequence. The implementation spans three files and involves a Python subprocess.

### Step 1: Renderer Initiates Execution

From the UI layer (typically [`workflowRunStore.ts`](https://github.com/lightningpixel/modly/blob/main/workflowRunStore.ts)):

```typescript
// Start workflow execution
await window.electron.workflow.run({ 
  workflowId: 'terrain-generator-v2',
  overrides: { seed: 42, resolution: 2048 }
})

```

### Step 2: Main Process Receives and Spawns

In [`electron/main/process-runner.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/process-runner.ts), the handler:

1. Loads the workflow JSON from `workflows/` directory
2. Validates the node graph
3. Spawns Python via [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts)

```typescript
// electron/main/process-runner.ts (conceptual)
ipcMain.handle('workflow:run', async (event, { workflowId, overrides }) => {
  const workflow = await loadWorkflow(workflowId)
  const pythonProcess = spawnPythonBridge(workflow, overrides)
  
  pythonProcess.on('message', (status) => {
    // Forward Python progress to renderer
    event.sender.send('workflow:progress', {
      nodeId: status.currentNode,
      percent: status.progressPercent,
      logs: status.logLines
    })
  })
  
  return { pid: pythonProcess.pid }
})

```

### Step 3: Bidirectional Progress Streaming

While Python executes, status flows back through:

```

Python subprocess → python-bridge.ts → process-runner.ts 
                                          ↓
                                   event.sender.send('workflow:progress')
                                          ↓
Renderer ← ipcRenderer.on('workflow:progress') ← preload re-exposure

```

The renderer subscribes via the preload's cleanup-aware API:

```typescript
// Listen to execution progress
const unsubscribe = window.electron.workflow.onProgress(({ 
  nodeId, 
  percent, 
  logs 
}) => {
  updateNodeStatus(nodeId, percent)
  appendToLogPanel(logs)
})

// Cleanup on unmount or workflow completion
unsubscribe()

```

### Step 4: Stopping Execution

The stop command uses `ipcRenderer.send` for immediate delivery without awaiting response:

```typescript
// Immediate termination request
window.electron.workflow.stop()  // → ipcMain.on('workflow:stop', ...)

```

The main process kills the Python subprocess and emits a final `workflow:progress` with `{ status: 'cancelled' }`.

## IPC Communication Patterns in Modly

Modly employs three distinct IPC patterns depending on the use case:

**Synchronous RPC with `invoke/handle`**

- Use when the renderer needs a result or confirmation
- Examples: `workflows:list`, `workflows:save`, initial `workflow:run` (returns PID)

**Event streaming with `send/on`**

- Use for ongoing, multi-message updates
- Example: `workflow:progress` events during execution

**Fire-and-forget with `send/on`**

- Use for commands requiring no acknowledgment
- Example: `workflow:stop`

## Renderer-Side State Management

The IPC API is consumed by two key renderer modules:

- **[`workflowsStore.ts`](https://github.com/lightningpixel/modly/blob/main/workflowsStore.ts)** (shared store) — wraps CRUD operations (`list`, `save`, `delete`) with reactive state
- **[`workflowRunStore.ts`](https://github.com/lightningpixel/modly/blob/main/workflowRunStore.ts)** (execution store) — orchestrates `run`, `stop`, and progress subscription lifecycle

Preflight validation in [`src/areas/workflows/preflight.ts`](https://github.com/lightningpixel/modly/blob/main/src/areas/workflows/preflight.ts) checks node connectivity and parameter validity before the IPC call, preventing invalid workflows from reaching the main process.

## Security Considerations

Modly's IPC architecture follows Electron security best practices:

- **No `nodeIntegration`** — renderer cannot access filesystem or spawn processes directly
- **Context isolation enabled** — preload runs in separate context from renderer
- **IPC channel allowlisting** — only predefined channels pass through preload
- **User input never eval'd** — workflow JSON is parsed with `JSON.parse`, not `eval`

## Summary

- **Preload bridge** ([`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts)) exposes controlled `window.electron` API using `contextBridge.exposeInMainWorld`
- **Main handlers** ([`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts)) register async RPC for workflow CRUD operations
- **Process runner** ([`electron/main/process-runner.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/process-runner.ts)) spawns Python via [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts) and streams progress via `event.sender.send`
- **Bidirectional flow** uses `invoke` for commands, `on/emit` for progress updates, and `send` for stop signals
- **Renderer stores** abstract IPC details from UI components while managing subscription lifecycles

## Frequently Asked Questions

### How does Modly prevent renderer process from directly accessing the filesystem?

Modly enables Electron's **context isolation** and disables `nodeIntegration`. The renderer can only access filesystem operations through the explicitly exposed `window.electron` API in [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts), which forwards requests to the main process. This prevents arbitrary code execution and follows Electron's security guidelines.

### Why does Modly use both `ipcRenderer.invoke` and `ipcRenderer.send`?

**`invoke`** provides request/response semantics suitable for CRUD operations where the renderer needs confirmation or data (e.g., `workflows:list` returning an array). **`send`** is fire-and-forget, used when the renderer doesn't need acknowledgment—like `workflow:stop` where immediate delivery matters more than response. Progress streaming uses `on` with callbacks because it involves multiple messages over time.

### Where does the actual workflow computation happen if IPC connects renderer to main?

The main process delegates to a **Python subprocess** spawned through [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts). The main process acts as a coordinator: it manages the Python lifecycle, parses stdout/stderr, and forwards structured progress messages back to the renderer via IPC. This Python bridge handles generation algorithms, mesh processing, and file I/O that would block the main process.

### Can multiple workflows run simultaneously in Modly?

The current architecture in [`process-runner.ts`](https://github.com/lightningpixel/modly/blob/main/process-runner.ts) tracks a single Python subprocess per workflow execution. Multiple concurrent executions would require extending the runner to manage a process map (workflowId → subprocess) rather than singleton state. The IPC channels support this—`workflow:run` returns a PID that could identify specific instances—but the store layers would need corresponding updates to handle parallel execution state.