IPC Communication Architecture

OpenWork uses Electron’s Inter-Process Communication (IPC) system to enable secure communication between the renderer process (React UI) and the main process (Node.js context). All communication flows through the preload script’s contextBridge API.

IPC Architecture Overview

graph TB
    subgraph "Renderer Process"
        UI[React Components]
        STORE[taskStore<br/>Zustand]
        API[accomplish.ts<br/>IPC Wrapper]
    end
    
    subgraph "Preload Script"
        BRIDGE[contextBridge<br/>Security Boundary]
        EXPOSED[window.accomplish<br/>Exposed API]
    end
    
    subgraph "Main Process"
        IPC_MAIN[ipcMain<br/>Handler Registry]
        HANDLERS[Handler Functions<br/>Business Logic]
        SUBSYSTEMS[TaskManager, Stores,<br/>Permission API]
    end
    
    UI --> STORE
    STORE --> API
    API -->|invoke| EXPOSED
    EXPOSED -->|ipcRenderer.invoke| IPC_MAIN
    IPC_MAIN --> HANDLERS
    HANDLERS --> SUBSYSTEMS
    
    SUBSYSTEMS -->|webContents.send| IPC_MAIN
    IPC_MAIN -->|ipcRenderer.on| EXPOSED
    EXPOSED -->|callback| API
    API --> STORE
    STORE --> UI
    
    style UI fill:#e3f2fd
    style BRIDGE fill:#fff3e0
    style IPC_MAIN fill:#f3e5f5
    style SUBSYSTEMS fill:#e8f5e9

Communication Patterns

Bidirectional Communication Model

sequenceDiagram
    participant R as Renderer
    participant P as Preload
    participant M as Main
    
    Note over R,M: Request/Response Pattern (invoke)
    R->>P: window.accomplish.method()
    P->>M: ipcRenderer.invoke(channel, args)
    M->>M: Execute handler
    M-->>P: Promise<result>
    P-->>R: Return value
    
    Note over R,M: Event Pattern (on/send)
    M->>P: webContents.send(channel, data)
    P->>R: callback(event)
    R->>R: Update state

Preload Script API

File: apps/desktop/src/preload/index.ts

API Structure

classDiagram
    class accomplishAPI {
        +getVersion() Promise~string~
        +getPlatform() Promise~string~
        +openExternal(url) Promise~void~
        
        +startTask(config) Promise~Task~
        +cancelTask(id) Promise~void~
        +interruptTask(id) Promise~void~
        +getTask(id) Promise~Task~
        +listTasks() Promise~Task[]~
        +deleteTask(id) Promise~void~
        +clearTaskHistory() Promise~void~
        
        +respondToPermission(resp) Promise~void~
        +resumeSession(sid, prompt) Promise~Task~
        
        +getApiKeys() Promise~ApiKeyConfig[]~
        +addApiKey(provider, key) Promise~ApiKeyConfig~
        +removeApiKey(id) Promise~void~
        +getDebugMode() Promise~boolean~
        +setDebugMode(enabled) Promise~void~
        +getAppSettings() Promise~AppSettings~
        
        +hasApiKey() Promise~boolean~
        +setApiKey(key) Promise~void~
        +getApiKey() Promise~string | null~
        +validateApiKey(key) Promise~ValidateResult~
        +validateApiKeyForProvider(p, k) Promise~ValidateResult~
        +clearApiKey() Promise~void~
        
        +getAllApiKeys() Promise~Record~
        +hasAnyApiKey() Promise~boolean~
        
        +getOnboardingComplete() Promise~boolean~
        +setOnboardingComplete(bool) Promise~void~
        
        +checkOpenCodeCli() Promise~CliStatus~
        +getOpenCodeVersion() Promise~string | null~
        
        +getSelectedModel() Promise~SelectedModel~
        +setSelectedModel(m) Promise~void~
        
        +onTaskUpdate(cb) Unsubscribe
        +onTaskUpdateBatch(cb) Unsubscribe
        +onPermissionRequest(cb) Unsubscribe
        +onTaskProgress(cb) Unsubscribe
        +onDebugLog(cb) Unsubscribe
        +onTaskStatusChange(cb) Unsubscribe
        
        +logEvent(payload) Promise~unknown~
    }

contextBridge Exposure

Source: Lines 139-146

// Expose the API to the renderer
contextBridge.exposeInMainWorld('accomplish', accomplishAPI);

// Also expose shell info for compatibility checks
contextBridge.exposeInMainWorld('accomplishShell', {
  version: process.env.npm_package_version || '1.0.0',
  platform: process.platform,
  isElectron: true,
});

Event Subscription Pattern

Source: Lines 101-132

// Event subscriptions
onTaskUpdate: (callback: (event: unknown) => void) => {
  const listener = (_: unknown, event: unknown) => callback(event);
  ipcRenderer.on('task:update', listener);
  return () => ipcRenderer.removeListener('task:update', listener);
},

Pattern: Returns unsubscribe function for React cleanup

IPC Channel Registry

Invoke Channels (Request/Response)

graph TB
    subgraph "Task Operations"
        T_START[task:start]
        T_CANCEL[task:cancel]
        T_INT[task:interrupt]
        T_GET[task:get]
        T_LIST[task:list]
        T_DEL[task:delete]
        T_CLEAR[task:clear-history]
    end
    
    subgraph "Session Operations"
        S_RESUME[session:resume]
    end
    
    subgraph "Permission Operations"
        P_RESPOND[permission:respond]
    end
    
    subgraph "Settings Operations"
        SG_KEYS[settings:api-keys]
        SG_ADD[settings:add-api-key]
        SG_RM[settings:remove-api-key]
        SG_DBG[settings:debug-mode]
        SG_SET_DBG[settings:set-debug-mode]
        SG_APP[settings:app-settings]
    end
    
    subgraph "API Key Operations"
        AK_EXISTS[api-key:exists]
        AK_SET[api-key:set]
        AK_GET[api-key:get]
        AK_VAL[api-key:validate]
        AK_VAL_P[api-key:validate-provider]
        AK_CLR[api-key:clear]
        AK_ALL[api-keys:all]
        AK_ANY[api-keys:has-any]
    end
    
    subgraph "Onboarding Operations"
        OB_GET[onboarding:complete]
        OB_SET[onboarding:set-complete]
    end
    
    subgraph "OpenCode Operations"
        OC_CHECK[opencode:check]
        OC_VER[opencode:version]
    end
    
    subgraph "Model Operations"
        M_GET[model:get]
        M_SET[model:set]
    end
    
    subgraph "Shell Operations"
        SH_OPEN[shell:open-external]
    end
    
    subgraph "Logging"
        LOG[log:event]
    end
    
    subgraph "App Info"
        APP_VER[app:version]
        APP_PLAT[app:platform]
    end

Send Channels (Events)

graph TB
    subgraph "Task Events"
        TU_UPDATE[task:update<br/>Single message]
        TU_BATCH[task:update:batch<br/>Batched messages]
        T_PROG[task:progress<br/>Progress update]
        T_STATUS[task:status-change<br/>Status transition]
    end
    
    subgraph "Permission Events"
        PERM_REQ[permission:request<br/>User approval needed]
    end
    
    subgraph "Debug Events"
        DBG_LOG[debug:log<br/>Debug output]
    end
    
    subgraph "Auth Events"
        AUTH_CB[auth:callback<br/>OAuth response]
    end

Message Types

Task Update Event

interface TaskUpdateEvent {
  taskId: string;
  type: 'message' | 'progress' | 'complete' | 'error';
  message?: TaskMessage;
  progress?: TaskProgress;
  result?: TaskResult;
  error?: string;
}

Flow:

sequenceDiagram
    participant CLI as OpenCode CLI
    participant TM as TaskManager
    participant IPC as IPC Handler
    participant R as Renderer
    
    CLI->>TM: NDJSON message
    TM->>IPC: callbacks.onMessage()
    IPC->>IPC: toTaskMessage() conversion
    IPC->>IPC: queueMessage() batching
    IPC->>R: webContents.send('task:update')
    R->>R: addTaskUpdate()

Batch Update Event

interface TaskUpdateBatchEvent {
  taskId: string;
  messages: TaskMessage[];
}

Optimization:

graph LR
    subgraph "Without Batching"
        M1[Message 1] --> STATE1((State Update))
        M2[Message 2] --> STATE2((State Update))
        M3[Message 3] --> STATE3((State Update))
    end
    
    subgraph "With Batching"
        M1 --> BUFFER[Buffer 50ms]
        M2 --> BUFFER
        M3 --> BUFFER
        BUFFER --> STATE((Single State Update))
    end
    
    style STATE1 fill:#ffebee
    style STATE2 fill:#ffebee
    style STATE3 fill:#ffebee
    style STATE fill:#e8f5e9

Permission Request Event

interface PermissionRequest {
  id: string;
  taskId: string;
  type: 'file' | 'question';
  fileOperation?: 'create' | 'delete' | 'rename' | 'move' | 'modify' | 'overwrite';
  filePath?: string;
  targetPath?: string;
  contentPreview?: string;
  question?: string;
  options?: Array<{label: string; description: string}>;
  multiSelect?: boolean;
  createdAt: string;
}

Security Model

Security Boundaries

graph TB
    subgraph "Renderer Context"
        R_WEB[Web APIs Only]
        R_NO[No Node.js Access]
        R_NO2[No Direct fs, child_process]
    end
    
    subgraph "Preload Context"
        P_LIMIT[Limited Node APIs]
        P_IPC[ipcRenderer access]
        P_BRIDGE[contextBridge]
    end
    
    subgraph "Main Context"
        M_FULL[Full Node.js APIs]
        M_FS[File System]
        M_PROC[Child Processes]
        M_CRYPTO[Cryptography]
    end
    
    R_WEB --> P_BRIDGE
    P_BRIDGE --> P_IPC
    P_IPC --> M_FULL
    M_FULL --> M_FS
    M_FULL --> M_PROC
    M_FULL --> M_CRYPTO
    
    style R_WEB fill:#ffebee
    style P_LIMIT fill:#fff3e0
    style M_FULL fill:#e8f5e9

Input Validation Chain

sequenceDiagram
    participant R as Renderer
    participant P as Preload
    participant V as Validator
    participant S as Sanitizer
    participant H as Handler
    
    R->>P: invoke(channel, data)
    P->>V: Receive raw data
    V->>V: Check schema
    alt Invalid
        V-->>R: Throw error
    end
    V->>S: Pass validated data
    S->>S: sanitizeString()
    S->>S: Check max length
    S->>H: Call handler
    H->>H: assertTrustedWindow()
    H-->>R: Return result

Source: apps/desktop/src/main/ipc/handlers.ts

  1. Schema Validation (lines 239-251)
  2. String Sanitization (lines 180-192)
  3. Trusted Window Check (lines 167-178)

Window Focus Validation

Source: apps/desktop/src/main/ipc/handlers.ts lines 167-178

function assertTrustedWindow(window: BrowserWindow | null): BrowserWindow {
  if (!window || window.isDestroyed()) {
    throw new Error('Untrusted window');
  }

  const focused = BrowserWindow.getFocusedWindow();
  if (BrowserWindow.getAllWindows().length > 1 && 
      focused && focused.id !== window.id) {
    throw new Error('IPC request must originate from the focused window');
  }

  return window;
}

Purpose: Prevents other windows from making privileged IPC calls

IPC Handler Implementation

Handler Wrapper

Source: apps/desktop/src/main/ipc/handlers.ts lines 239-251

function handle<Args extends unknown[], ReturnType = unknown>(
  channel: string,
  handler: (event: IpcMainInvokeEvent, ...args: Args) => ReturnType
): void {
  ipcMain.handle(channel, async (event, ...args) => {
    try {
      return await handler(event, ...(args as Args));
    } catch (error) {
      console.error(`IPC handler ${channel} failed`, error);
      throw normalizeIpcError(error);
    }
  });
}

Features:

  • Type-safe generic wrapper
  • Automatic error handling
  • Async handler support
  • Error normalization

Task Start Handler Flow

stateDiagram-v2
    [*] --> Receive: task:start IPC
    Receive --> Validate: assertTrustedWindow
    Validate --> Sanitize: validateTaskConfig
    Sanitize --> InitPerm: Initialize Permission API
    InitPerm --> CreateID: Generate taskId
    CreateID --> Callbacks: Create TaskCallbacks
    Callbacks --> Start: taskManager.startTask()
    Start --> Save: saveTask()
    Save --> Return: Return Task object
    Return --> [*]
    
    note right of Sanitize
        Sanitize prompt, taskId,
        sessionId, workingDirectory
    end note
    
    note right of Callbacks
        onMessage, onProgress,
        onPermissionRequest,
        onComplete, onError,
        onDebug, onStatusChange
    end note

Source: apps/desktop/src/main/ipc/handlers.ts lines 264-389

Message Batching Implementation

Batcher Architecture

Source: apps/desktop/src/main/ipc/handlers.ts lines 88-165

graph TB
    subgraph "Per-Task Batcher"
        STATE[pendingMessages: TaskMessage[]<br/>timeout: NodeJS.Timeout<br/>taskId: string<br/>flush: function]
    end
    
    subgraph "Queue Operation"
        ADD[queueMessage]
        RESET[Reset timer]
        SET[Set timeout 50ms]
    end
    
    subgraph "Flush Operation"
        FLUSH[flush()]
        SEND[Send batch via IPC]
        PERSIST[persist to history]
        CLEAR[Clear pending messages]
        CLR_TMR[Clear timeout]
    end
    
    ADD --> STATE
    RESET --> STATE
    SET --> STATE
    
    STATE --> FLUSH
    FLUSH --> SEND
    FLUSH --> PERSIST
    FLUSH --> CLEAR
    FLUSH --> CLR_TMR
    
    style STATE fill:#e3f2fd
    style FLUSH fill:#e8f5e9

Batch Flow Diagram

sequenceDiagram
    participant CLI as OpenCode CLI
    participant Parser as StreamParser
    participant Adapter as OpenCodeAdapter
    participant Batcher as MessageBatcher
    participant Renderer as Renderer
    
    loop Rapid messages
        CLI->>Parser: NDJSON line
        Parser->>Adapter: Emit message
        Adapter->>Batcher: queueMessage(msg)
        Note over Batcher: Add to pendingMessages<br/>Reset 50ms timer
    end
    
    Note over Batcher: 50ms elapsed
    Batcher->>Renderer: task:update:batch [msg1, msg2, msg3]
    Batcher->>Store: addTaskMessage(msg1, msg2, msg3)
    Batcher->>Batcher: Clear pendingMessages

Event Subscription Pattern

Renderer-Side Subscriptions

Source: apps/desktop/src/preload/index.ts lines 101-132

onTaskUpdate: (callback: (event: unknown) => void) => {
  const listener = (_: unknown, event: unknown) => callback(event);
  ipcRenderer.on('task:update', listener);
  return () => ipcRenderer.removeListener('task:update', listener);
}

React Integration:

useEffect(() => {
  const unsubscribe = window.accomplish.onTaskUpdate((event) => {
    // Handle event
  });
  return unsubscribe;
}, []);

Multi-Event Subscription

Source: apps/desktop/src/renderer/stores/taskStore.ts lines 440-465

// Global subscription to setup progress events
if (typeof window !== 'undefined' && window.accomplish) {
  window.accomplish.onTaskProgress((progress: unknown) => {
    const event = progress as SetupProgressEvent;
    if (event.message) {
      useTaskStore.getState().setSetupProgress(event.taskId, event.message);
    }
  });

  window.accomplish.onTaskUpdate((event: unknown) => {
    const updateEvent = event as TaskUpdateEvent;
    if (updateEvent.type === 'complete' || updateEvent.type === 'error') {
      const state = useTaskStore.getState();
      if (state.setupProgressTaskId === updateEvent.taskId) {
        state.setSetupProgress(null, null);
      }
    }
  });
}

Error Handling

Error Normalization

Source: apps/desktop/src/main/ipc/handlers.ts (referenced in handle wrapper)

function normalizeIpcError(error: unknown): Error {
  if (error instanceof Error) {
    return error;
  }
  if (typeof error === 'string') {
    return new Error(error);
  }
  return new Error('An unknown error occurred');
}

Error Propagation

graph TB
    subgraph "Main Process"
        H[Handler Function]
        TRY[try-catch block]
        NORM[normalizeIpcError]
        LOG[console.error]
    end
    
    subgraph "IPC Transport"
        THROW[Throw error]
    end
    
    subgraph "Renderer Process"
        CATCH[await catch]
        SET_ERROR[set error in store]
        SHOW[Display error UI]
    end
    
    H --> TRY
    TRY -->|Error| NORM
    NORM --> LOG
    NORM --> THROW
    THROW --> CATCH
    CATCH --> SET_ERROR
    SET_ERROR --> SHOW
    
    style TRY fill:#fff3e0
    style THROW fill:#ffebee
    style SHOW fill:#e8f5e9

Performance Considerations

1. Message Batching

Problem: Rapid message streams cause excessive re-renders

Solution: 50ms batching window

Benefit: Reduces state updates from N to 1

2. Selective Event Handling

Problem: All messages sent to all listeners

Solution: Task-scoped batching

Implementation: queueMessage() only processes messages for the relevant task

3. Lazy Event Subscription

Problem: Events fire before components mount

Solution: Global subscriptions in taskStore.ts

Benefit: Events captured even if no component is listening

Complete Sequence: Task Execution

sequenceDiagram
    participant UI as React UI
    participant Store as taskStore
    participant API as accomplish.ts
    participant Preload as Preload
    participant IPC as IPC Handlers
    participant TM as TaskManager
    participant OA as OpenCodeAdapter
    participant CLI as OpenCode CLI
    
    UI->>Store: startTask({prompt})
    Store->>API: startTask(config)
    API->>Preload: invoke('task:start')
    Preload->>IPC: ipcRenderer.invoke
    IPC->>IPC: Validate input
    IPC->>TM: startTask(taskId, config, callbacks)
    TM->>OA: new OpenCodeAdapter(taskId)
    OA->>CLI: pty.spawn(opencode run)
    
    loop Message Stream
        CLI->>OA: NDJSON message
        OA->>IPC: callbacks.onMessage()
        IPC->>IPC: queueMessage() [batching]
        
        alt 50ms elapsed
            IPC->>Preload: send('task:update:batch')
            Preload->>API: onTaskUpdateBatch callback
            API->>Store: addTaskUpdateBatch()
            Store->>UI: Re-render with new messages
        end
    end
    
    CLI->>OA: step_finish
    OA->>IPC: callbacks.onComplete()
    IPC->>Preload: send('task:update', {complete})
    Preload->>API: onTaskUpdate callback
    API->>Store: addTaskUpdate()
    Store->>UI: Update status to completed

Summary

OpenWork’s IPC architecture provides:

Feature Implementation Benefit
Type Safety TypeScript interfaces Compile-time checking
Security contextBridge + validation Isolated renderer context
Performance Message batching Reduced re-renders
Error Handling try-catch + normalization Graceful failures
Cleanup Unsubscribe functions No memory leaks
Flexibility Event + invoke patterns Bidirectional communication

Key Design Principles:

  1. Security First: Validate all inputs, check window focus
  2. Performance: Batch rapid messages, selective updates
  3. Type Safety: Full TypeScript coverage
  4. Error Handling: Normalize errors, provide clear messages
  5. Resource Management: Cleanup functions for all subscriptions

Back to top

OpenWork Documentation - Community documentation for accomplish-ai/openwork