Loopi is built on Electron's multi-process architecture with a clear separation between main process (Node.js) and renderer process (React/browser). This document explains the design decisions and data flow patterns.
The main process runs Node.js and manages the application lifecycle. It's organized into modular services:
- Purpose: Centralized window lifecycle management
- Responsibilities:
- Create main application window
- Create browser automation window (on-demand)
- Manage window references and cleanup
- Configure security settings (preload scripts, context isolation)
- Purpose: Execute automation steps in browser windows
- Key Methods:
executeStep(browserWindow, step): Runs a single stepevaluateConditional(browserWindow, config): Evaluates branching logicsubstituteVariables(input): Template variable substitution{{varName}}getVariableValue(path): Parse and access{{obj.prop[0].nested}}parseValue(input): Auto-detect type (number, boolean, object, array, string)
- State Management:
- Maintains
variables: Record<string, unknown>for all automation variables - Variables auto-type based on input:
"42"→ number,"true"→ boolean, JSON → object - Token-based parser handles dot notation and array indexing
- Stores
currentNodeIdfor coordinating selector injection
- Maintains
- Purpose: Interactive element selection from live pages
- Flow:
- Injects navigation bar for URL changes
- Injects picker UI (hover highlights, click capture)
- Generates unique CSS selectors using structural paths
- Handles special cases (select elements → extract option data)
- Returns selector via IPC promise
- Selector Generation Strategy:
- Prefers structural selectors (
tag:nth-of-type(n)) - Builds full path from
htmlroot - Ensures uniqueness at each level
- Prefers structural selectors (
- Purpose: Route IPC messages to appropriate services
- Registered Channels:
browser:open,browser:close→ WindowManagerbrowser:runStep,browser:runConditional→ AutomationExecutorpick-selector→ SelectorPickerbrowser:closedevent → Main window notificationloopi:listTrees→ TreeStore: List all saved automationsloopi:loadTrees→ TreeStore: Load specific automationloopi:saveTree→ TreeStore: Save/update automationloopi:loadExample→ TreeStore: Load example fromdocs/examples/loopi:deleteTree→ TreeStore: Delete automation file from diskloopi:loadSettings→ SettingsStore: Load app settingsloopi:saveSettings→ SettingsStore: Save app settingsdialog:selectFolder→ Dialog: Open folder picker for download path
- Purpose: Persist application settings
- Storage:
~/.config/loopi/settings.json - Manages:
- Theme preference (light, dark, system)
- Notifications toggle
- Download path configuration
- API:
loadSettings(): Returns AppSettings with defaults if file doesn't existsaveSettings(settings): Persists to disk, triggers download handler update
- Purpose: Handle file downloads with configurable save path
- Features:
- Auto-creates download directory if it doesn't exist
- Listens to
will-downloadevents on default session - Sets full file path (directory + filename) for each download
- Falls back to system Downloads folder if custom path not set
- Integration: Called on app startup and when settings change
React-based UI running in Chromium with restricted privileges.
App (root)
├── Dashboard (src/components/Dashboard.tsx)
│ ├── Tabs: "Your Automations" | "Examples"
│ ├── YourAutomations (src/components/dashboard/YourAutomations.tsx)
│ │ └── Edit/Delete/Export actions
│ └── Examples (src/components/dashboard/Examples.tsx)
│ └── Load example automations
│
└── AutomationBuilder (src/components/AutomationBuilder.tsx)
├── BuilderHeader
│ ├── Settings dialog (name, description, schedule)
│ └── Execution controls (run, pause, stop)
├── BuilderCanvas (ReactFlow)
│ ├── AutomationNode (visual representation)
│ ├── AddStepPopup (step type picker)
│ └── NodeDetails
│ ├── NodeHeader (title + delete)
│ ├── StepEditor (routes to step-specific forms)
│ └── ConditionEditor (branching config)
└── Hooks
├── useNodeActions (node CRUD, edge validation)
└── useExecution (automation orchestration)
useNodeActions
- Manages node/edge operations in the graph
- Enforces edge constraints:
- Regular nodes: max 1 outgoing edge
- Conditional nodes: max 2 (if/else branches)
- Root node (id="1"): cannot be deleted
- Creates type-specific initial step values via discriminated union switch
useExecution
- Orchestrates automation execution
- Implements recursive graph traversal:
executeGraph(nodeId) { visit node execute step/condition determine next nodes (based on edges + branch result) recurse for each next node }
- Manages visual feedback (nodeRunning state)
- Handles browser lifecycle (auto-open if needed)
User edits field in NodeDetails
↓
onUpdate(nodeId, "update", { step: { ...step, field: newValue } })
↓
useNodeActions.handleNodeAction
↓
setNodes((nodes) => nodes.map(n => n.id === nodeId ? { ...n, data: updates } : n))
↓
ReactFlow re-renders updated node
1. User clicks "Pick Element" button
↓
2. NodeDetails.handlePickSelector()
↓
3. window.electronAPI.pickSelector(recentUrl) [IPC invoke]
↓
4. Main: ipcHandlers → SelectorPicker.pickSelector()
↓
5. Open/focus browser window, inject picker script
↓
6. User hovers (highlight) + clicks element
↓
7. Injected script generates CSS selector
↓
8. window.electronAPI.sendSelector(selector) [IPC send]
↓
9. Main: receive "selector-picked", resolve promise
↓
10. Renderer: receives selector, updates step data
1. User clicks "Run Automation"
↓
2. useExecution.runAutomation()
↓
3. Open browser if not already open
↓
4. executeGraph("1") // Start at root node
↓
5. For each node:
- Mark as running (visual feedback)
- If step: window.electronAPI.runStep(step)
→ Main: AutomationExecutor.executeStep()
→ webContents.executeJavaScript(...)
- If conditional: window.electronAPI.runConditional(config)
→ Main: AutomationExecutor.evaluateConditional()
→ Check condition, return boolean + loop state
- Determine next nodes from edges
- Recurse
↓
6. Complete: reset state, show success message
1. User clicks Delete button in YourAutomations
↓
2. Confirmation dialog: "Are you sure?"
↓
3. If confirmed:
window.electronAPI.tree.delete(automationId) [IPC invoke]
↓
4. Main: ipcHandlers.handle("loopi:deleteTree")
↓
5. TreeStore.deleteAutomation(id, folder)
- Build file path: ~/.config/[AppName]/.trees/tree_[id].json
- fs.unlinkSync(filePath) // Permanent deletion
- Return true/false
↓
6. If success:
- Renderer updates local state
- Remove automation from automations array
- UI re-renders without deleted item
↓
7. If error:
- User sees alert: "Failed to delete automation"
- Automation remains in list (no data loss)
1. User clicks "Load Example" button on example card
↓
2. handleLoadExample(example)
↓
3. window.electronAPI.tree.loadExample(fileName) [IPC invoke]
↓
4. Main: ipcHandlers.handle("loopi:loadExample")
↓
5. Read file: docs/examples/[fileName].json
↓
6. Return parsed automation JSON
↓
7. Renderer:
- Generate new ID: Date.now().toString()
- window.electronAPI.tree.save(automation) [IPC invoke]
- Add to automations array
- Switch to "Your Automations" tab
Loopi uses an auto-typed unified variable system where all variables are stored as unknown type and auto-detect their type based on input.
Storage Structure (in AutomationExecutor):
variables: Record<string, unknown>Type Detection (parseValue() method):
"42"or"3.14"→number"true"or"false"→boolean- Valid JSON starting with
{or[→objectorarray - Everything else →
string
Example:
this.variables["count"] = this.parseValue("42"); // number: 42
this.variables["user"] = this.parseValue('{"name":"John"}'); // object
this.variables["name"] = this.parseValue("John"); // string: "John"Variable Access (getVariableValue() method):
Uses token-based parser to handle dot notation and array indexing:
// Parses path into tokens: ["users", 0, "name"]
// Then navigates: variables["users"] → [0] → .name
path: "users[0].name"
result: "John" (if users is array of objects)
// Supported syntax:
{{username}} // Simple: string value
{{user.name}} // Nested: property access
{{users[0]}} // Array: index access
{{users[0].email}} // Mixed: combined access
{{matrix[0][1]}} // Multi-dimensional arrays
{{response.data.items[2].id}} // Deep nestingVariable Substitution (substituteVariables() method):
Replaces {{varName}} tokens in strings with actual values:
// Input: "https://example.com/user/{{userId}}"
// Result: "https://example.com/user/123"
// Input: "Hello {{user.name}}, your balance is {{account.balance}}"
// Result: "Hello John, your balance is 1000"
// Regex: /\{\{\s*([a-zA-Z0-9_\[\].]+)\s*\}\}/gUsage in Steps:
-
API Call - Stores raw response as object
this.variables[step.storeKey] = dataOut; // Already typed
-
Set Variable - Auto-types input
this.variables[name] = this.parseValue(rawValue); // Auto-detect
-
Modify Variable - Type-aware operations
if (typeof current === "number") { this.variables[name] = current + increment; }
-
Navigate/Type/Extract - Substitutes variables
const url = this.substituteVariables(step.value);
For detailed variable documentation, see VARIABLES.md.
All step types are modeled as a discriminated union with type as the discriminant:
type AutomationStep =
| { type: "navigate"; id: string; description: string; value: string }
| { type: "click"; id: string; description: string; selector: string }
| { type: "type"; id: string; description: string; selector: string; value: string; credentialId?: string }
| { type: "extract"; id: string; description: string; selector: string; storeKey?: string }
| { type: "setVariable"; id: string; description: string; variableName: string; value: string }
| { type: "modifyVariable"; id: string; description: string; variableName: string; operation: ModifyOp; value: string }
| ...
// TypeScript narrows automatically based on type field
function execute(step: AutomationStep) {
switch (step.type) {
case "navigate":
// step.value is known to exist
return loadURL(step.value);
case "click":
// step.selector is known to exist
return click(step.selector);
case "extract":
// step.selector and step.storeKey are known
return extractText(step.selector, step.storeKey);
}
}Benefits:
- Compile-time exhaustiveness checking
- Prevents accessing fields that don't exist on a variant
- Self-documenting via type definitions
Similarly, schedules use discriminated unions:
type Schedule =
| { type: "manual" }
| { type: "interval"; interval: number; unit: "minutes" | "hours" | "days" }
| { type: "fixed"; value: string } // "HH:MM" formatThis prevents bugs like accessing schedule.interval when schedule.type === "fixed".
Renderer process runs with:
webPreferences: {
contextIsolation: true, // Isolate renderer from preload
nodeIntegration: false, // No direct Node.js access
preload: PRELOAD_PATH // Only bridge via preload
}Preload script exposes minimal API via contextBridge:
// ✅ Allowed: Renderer can invoke these IPC channels
electronAPI.openBrowser(url)
electronAPI.runStep(step)
electronAPI.pickSelector(url)
// ❌ Blocked: Renderer cannot access
require('fs')
require('child_process')
ipcRenderer.send('arbitrary-channel')Automation browser windows use same preload + context isolation to allow navigation bar script access to controlled IPC for URL loading.
- App-level state:
automations[],credentials[],currentView - Builder-level state:
nodes[],edges[],selectedNodeId,schedule - Execution state:
isBrowserOpen,isAutomationRunning,currentNodeId
No global state library (Redux, Zustand) needed—props drilling is minimal due to focused component hierarchy.
- WindowManager:
mainWindow,browserWindowreferences - AutomationExecutor:
loopIndices,currentNodeIdfor loop coordination
- Uses
useNodesStateanduseEdgesStatefor memoized updates - Node types registered once at module level
- Callbacks wrapped in
useCallbackto prevent re-renders
- Uses
invoke/handlefor request-response (promises) - Uses
send/onfor one-way events (browser:closed) - Avoids sending large data structures; passes IDs and fetch on demand
- Steps execute sequentially with
awaitto ensure order - Each step completes before moving to next (no parallelization within flow)
- Visual feedback (500ms delay) between steps for debugging
For detailed step-by-step instructions, see NEW_STEP_TEMPLATE.md.
Quick overview:
- Define type in
src/types/steps.ts - Create editor component in
src/components/.../stepTypes/ - Add execution logic in
src/main/automationExecutor.ts - Add initial values in
src/hooks/useNodeActions.ts - Wire up in
StepEditor.tsx - Update
docs/STEPS_REFERENCE.mdwith examples - Create example in
docs/examples/
- Add to
ConditionTypeunion inflow.ts - Update
ConditionEditorUI - Implement logic in
AutomationExecutor.evaluateConditional()
- Add handler in
ipcHandlers.ts - Expose method in
preload.ts - Invoke from renderer via
window.electronAPI.*
- Utilities:
automationIO.ts(import/export validation) - Hooks:
useNodeActions,useExecution(with mock IPC) - Main Services:
AutomationExecutor,SelectorPicker(with mock BrowserWindow)
- End-to-end flow: Create automation → Save → Load → Execute
- Selector picker: Inject → Highlight → Click → Receive selector
- Import/Export: Round-trip JSON serialization
- Create new automation with multiple step types
- Add conditional node with if/else branches
- Test variable-driven loops using
Set Variable/Modify Variableand{{index}}substitution - Pick selectors from live page
- Execute automation and verify steps
- Import/export workflows
- Schedule configuration (all types)
- Loop/Variable State Persistence: Runtime variables (e.g. loop indices) are not persisted across restarts unless stored in automation data
- Credential Encryption: Placeholder implementation (use
electron-store+ crypto for production) - Error Recovery: Limited retry logic in automation execution
- Concurrency: One automation runs at a time
- Step Validation: Minimal validation before execution (e.g., invalid selectors)
-
Persistent execution logs with screenshots
-
Subflow/reusable component support
-
Headless execution mode (no browser UI)
-
Cloud sync for workflows
-
Collaborative editing
-
Plugin system for custom step types
-
Advanced scheduling (cron expressions)