Get Your Agent to Help
Install the Electron skill (5.7K installs — the most popular):
npx skills add vercel-labs/agent-browser@electron
Then ask: "help me build an Electron app that shows my launchd job status using IPC to a CLI"
Create the App
mkdir -p ~/Code/jobs-electron && cd ~/Code/jobs-electron
npm init -y
npm install electron --save-dev
Architecture
Electron splits into main process (Node.js, can run CLI) and renderer (browser, shows UI). They talk via IPC:
main.js → execSync("bun run cli.ts status") → JSON
↕ IPC
renderer (index.html) → window.jobs.status() → render
Main Process
main.js — IPC handlers call the CLI:
const { app, BrowserWindow, ipcMain } = require("electron");
const { execSync } = require("child_process");
const path = require("path");
const BUN = path.join(process.env.HOME, ".bun/bin/bun");
const CLI = "/path/to/james-long-ai-job-scheduling/src/cli.ts"; // ← update
function run(cmd) {
try {
return JSON.parse(execSync(`${BUN} run ${CLI} ${cmd}`, { encoding: "utf-8", timeout: 10_000 }));
} catch (err) {
return { ok: false, error: { message: err.message } };
}
}
ipcMain.handle("jobs:status", () => run("status"));
ipcMain.handle("jobs:sync", () => run("sync"));
ipcMain.handle("jobs:kick", (_, name) => run(`kick ${name}`));
ipcMain.handle("jobs:logs", (_, name) => run(`logs ${name}`));
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 800, height: 600,
webPreferences: { preload: path.join(__dirname, "preload.js") },
});
win.loadFile("index.html");
});
app.on("window-all-closed", () => app.quit());
Preload
preload.js — exposes CLI calls to the renderer:
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("jobs", {
status: () => ipcRenderer.invoke("jobs:status"),
sync: () => ipcRenderer.invoke("jobs:sync"),
kick: (name) => ipcRenderer.invoke("jobs:kick", name),
logs: (name) => ipcRenderer.invoke("jobs:logs", name),
});
Renderer
index.html — same as the web branch, but use window.jobs.status() instead of fetch:
async function load() {
const data = await window.jobs.status();
// data.result.jobs — render them
}
Ask your coding agent to build the full HTML. It knows Electron.
Run It
npx electron .
Why Electron
Real desktop window. ⌘+Tab to it. System tray icon possible. Notifications via Electron API. It's more than a browser tab but less work than Swift.
Companion Notes
Branch: Electron Dashboard
A desktop app with a real window. If you want something between a web page and a native app.
Setup
mkdir -p ~/Code/ai-jobs-dashboard
cd ~/Code/ai-jobs-dashboard
npm init -y
npm install electron --save-dev
Architecture
Electron main process → spawns CLI commands
↓ IPC
Electron renderer → displays job state (same HTML as web branch)
The key insight: the renderer is basically the same HTML from the web branch, but instead of fetch('/api/status') it uses Electron's IPC to call the CLI.
Main Process
Create main.js:
const { app, BrowserWindow, ipcMain } = require("electron");
const { execSync } = require("child_process");
const path = require("path");
// Adjust these paths
const BUN = path.join(process.env.HOME, ".bun/bin/bun");
const CLI = "/path/to/james-long-ai-job-scheduling/src/cli.ts";
function runCLI(command) {
try {
const stdout = execSync(`${BUN} run ${CLI} ${command}`, {
encoding: "utf-8",
timeout: 10_000,
});
return JSON.parse(stdout);
} catch (err) {
return { ok: false, error: { message: err.message } };
}
}
// IPC handlers — renderer calls these
ipcMain.handle("jobs:status", () => runCLI("status"));
ipcMain.handle("jobs:sync", () => runCLI("sync"));
ipcMain.handle("jobs:kick", (_, name) => runCLI(`kick ${name}`));
ipcMain.handle("jobs:logs", (_, name) => runCLI(`logs ${name}`));
ipcMain.handle("jobs:doctor", (_, name) => runCLI(name ? `doctor ${name}` : "doctor"));
let mainWindow;
app.whenReady().then(() => {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
titleBarStyle: "hiddenInset",
webPreferences: {
preload: path.join(__dirname, "preload.js"),
},
});
mainWindow.loadFile("index.html");
});
app.on("window-all-closed", () => app.quit());
Preload
Create preload.js:
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("jobs", {
status: () => ipcRenderer.invoke("jobs:status"),
sync: () => ipcRenderer.invoke("jobs:sync"),
kick: (name) => ipcRenderer.invoke("jobs:kick", name),
logs: (name) => ipcRenderer.invoke("jobs:logs", name),
doctor: (name) => ipcRenderer.invoke("jobs:doctor", name),
});
Renderer
Create index.html — same structure as the web branch, but replace fetch('/api/status') with:
// Instead of: const resp = await fetch('/api/status');
// Use: const data = await window.jobs.status();
async function load() {
const data = await window.jobs.status();
// ... render exactly like the web branch
}
The actions become:
// Kick a job
await window.jobs.kick(jobName);
// View logs
const logData = await window.jobs.logs(jobName);
// Sync
await window.jobs.sync();
Run It
npx electron .
Add to package.json: "start": "electron ."
Why Electron Over Web
- Real desktop window — ⌘+Tab to it, minimize, full screen
- System notifications via Electron's Notification API
- Tray icon (like the SwiftUI menubar but cross-platform)
- File system access without a server (IPC handles it)
What to Add
- Tray icon — show job count, click to open
- Notifications — alert on job failure
- Log viewer — split pane with auto-scrolling log tail
- Dark/light theme — match macOS appearance