# How Modly Handles IPC Communication Between Electron Main and Renderer Processes

> Learn how Modly handles IPC communication between Electron main and renderer processes using a secure preload script and contextBridge for controlled API access.

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

---

**Modly implements a secure preload script architecture that exposes a controlled API via `contextBridge.exposeInMainWorld`, allowing the renderer process to invoke main-process operations through `ipcRenderer.invoke` while receiving real-time progress updates via event-driven `ipcRenderer.on` listeners.**

Modly is an Electron-based application that orchestrates complex workflows by coordinating between its React frontend and a Python backend runtime. According to the `lightningpixel/modly` source code, the application establishes robust **IPC communication between Electron main and renderer processes** through a carefully isolated preload script pattern. This design ensures that sensitive Node.js APIs remain inaccessible to the renderer while still enabling powerful workflow management capabilities.

## Secure API Exposure via electron/preload/electron-api.ts

The foundation of Modly's IPC security lies in [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts), which acts as a controlled bridge between the UI and the privileged main process. Rather than exposing raw Electron modules to the renderer, this file uses `contextBridge.exposeInMainWorld` to attach a curated API object to `window.electron`.

The preload script implements two distinct communication patterns:

- **Request-response RPC**: Uses `ipcRenderer.invoke` for synchronous-style calls that return promises
- **Event-driven updates**: Uses `ipcRenderer.on` and `removeAllListeners` for subscribing to streaming data from the main process

This approach ensures that the renderer can only invoke explicitly defined methods, preventing arbitrary access to the filesystem or shell execution capabilities.

## Main Process IPC Handlers in electron/main/ipc-handlers.ts

The main process registers its API surface in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts), which uses `ipcMain.handle` for asynchronous RPC and `ipcMain.on` for fire-and-forget messaging. This file manages the CRUD operations for workflow definitions through five specific channels:

1. **`workflows:list`** – Returns an array of saved workflow JSON files from the storage directory
2. **`workflows:save`** – Persists a workflow definition to disk after validation
3. **`workflows:delete`** – Removes a workflow file by identifier
4. **`workflows:import`** – Reads a user-selected JSON file and stores it in the application's workflow directory
5. **`workflows:export`** – Writes a workflow definition to a user-chosen file path outside the application directory

Each handler is registered during application startup and remains active throughout the main process lifecycle, ensuring consistent state management across renderer reloads.

## Workflow Execution and the Python Bridge

When the UI initiates workflow execution, the IPC flow extends beyond JavaScript into a spawned Python process. The renderer calls `window.electron.workflow.run()`, which triggers `ipcRenderer.invoke('workflow:run', { workflowId, overrides })` in the preload script.

The main-process listener resides in [`electron/main/process-runner.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/process-runner.ts) and performs the following sequence:

- Loads the requested workflow definition from the `workflows` directory
- Spawns a child Python process through [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts) to handle generation and mesh processing
- Streams progress updates back to the renderer via `ipcMain.emit('workflow:progress', …)`

The Python bridge communicates status messages—including log lines and completion percentages—back to the main process, which then forwards them to the renderer through the preload-exposed `window.electron.workflow.onProgress` callback.

## Bidirectional Communication Patterns

Modly's architecture supports full-duplex communication during workflow execution. While the renderer can initiate actions through the preload API, the main process actively pushes updates without waiting for renderer requests.

**From Renderer to Main:**

```typescript
// Start execution
await window.electron.workflow.run({ workflowId: 'my-wf' })

// Stop execution
window.electron.workflow.stop()  // Maps to ipcRenderer.send('workflow:stop')

```

**From Main to Renderer:**

```typescript
// Subscribe to progress in the renderer
window.electron.workflow.onProgress(({ nodeId, percent }) => {
  console.log(`Node ${nodeId} is ${percent}% complete`)
})

```

This bidirectional flow enables real-time feedback during long-running Python operations while maintaining process isolation.

## Code Implementation Examples

### Listing Available Workflows

From the renderer context, fetching stored workflows uses the exposed list method:

```typescript
const workflows = await window.electron.workflows.list()

```

This invokes the `workflows:list` handler in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts), which scans the workflows directory and returns parsed JSON objects.

### Saving Workflow Changes

After editing in the UI defined in [`src/areas/workflows/workflowRunStore.ts`](https://github.com/lightningpixel/modly/blob/main/src/areas/workflows/workflowRunStore.ts), persist changes through:

```typescript
await window.electron.workflows.save(updatedWorkflow)

```

The preload script forwards this to the `workflows:save` channel, which validates the schema before writing to disk.

### Monitoring Execution Progress

To receive real-time updates during execution:

```typescript
// Initiate workflow
window.electron.workflow.run({ workflowId: 'my-wf' })

// Handle progress events
window.electron.workflow.onProgress(({ nodeId, percent }) => {
  console.log(`Node ${nodeId} is ${percent}% complete`)
})

```

The `onProgress` method wraps `ipcRenderer.on('workflow:progress', callback)` and manages listener cleanup through `removeAllListeners` when subscriptions end.

## Summary

- Modly uses [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) to create a secure `window.electron` API via `contextBridge.exposeInMainWorld`, preventing direct renderer access to Node.js modules
- **CRUD operations** for workflows are handled through `ipcMain.handle` registrations in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) using channels like `workflows:list` and `workflows:save`
- **Workflow execution** flows from the renderer through `workflow:run` to [`electron/main/process-runner.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/process-runner.ts), which spawns Python processes via [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts)
- **Real-time progress updates** travel from Python through the main process to the renderer via `workflow:progress` events exposed as `window.electron.workflow.onProgress`
- **Process isolation** is maintained throughout, with the renderer in [`src/areas/workflows/workflowRunStore.ts`](https://github.com/lightningpixel/modly/blob/main/src/areas/workflows/workflowRunStore.ts) communicating exclusively through the preload-defined interface

## Frequently Asked Questions

### Why does Modly use a preload script instead of direct ipcRenderer access?

Modly follows Electron security best practices by restricting direct access to `ipcRenderer` from the renderer process. The preload script in [`electron/preload/electron-api.ts`](https://github.com/lightningpixel/modly/blob/main/electron/preload/electron-api.ts) acts as a capabilities proxy, exposing only specific methods through `contextBridge.exposeInMainWorld`. This prevents arbitrary IPC calls and ensures that malicious code in the renderer cannot exploit Node.js APIs to access the filesystem or execute shell commands.

### How does Modly stream progress updates from Python to the UI?

During workflow execution, the Python child process communicates status messages to [`electron/main/process-runner.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/process-runner.ts), which then emits `workflow:progress` events through `ipcMain`. The preload script listens for these events via `ipcRenderer.on` and invokes registered callbacks exposed as `window.electron.workflow.onProgress`. This creates a streaming channel from the Python backend through the main process to the React frontend without blocking the renderer thread.

### What is the difference between ipcMain.handle and ipcMain.on in Modly's architecture?

Modly uses `ipcMain.handle` in [`electron/main/ipc-handlers.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/ipc-handlers.ts) for request-response patterns like `workflows:list` and `workflows:save`, where the renderer expects a return value or error. Conversely, `ipcMain.on` handles fire-and-forget messages like `workflow:stop` and the outgoing `workflow:progress` events that originate from the main process. The former supports async/await patterns in the renderer, while the latter enables event-driven updates without blocking.

### How are workflow files managed across the IPC boundary?

Workflow persistence is abstracted through the preload API. When the renderer calls `window.electron.workflows.save()`, the preload script uses `ipcRenderer.invoke` to trigger the `workflows:save` handler in the main process. This handler validates the workflow structure in [`src/areas/workflows/preflight.ts`](https://github.com/lightningpixel/modly/blob/main/src/areas/workflows/preflight.ts) before writing JSON to the filesystem. File operations never execute in the renderer; they are always proxied through the main process via these defined IPC channels.