super-productivity/docs/plugin-development.md
Johannes Millan 97e97042cd
fix(electron): remove exec IPC to close GHSA-256q (#8669)
* fix(electron): fail-safe exec confirmation dialog (GHSA-256q)

The EXEC confirmation was the only gate before an arbitrary shell command
runs with the user's privileges, but it did not fail safe:

- defaultId: 2 was out of range for a two-button dialog, leaving the
  focused default per-platform-undefined, so an accidental Enter could
  execute. Cancel is now defaultId + cancelId (Enter/Escape never runs).
- 'Remember my answer' defaulted to checked, so one careless click could
  whitelist a command to the silent allow-list forever. It is now opt-in.

Add electron/ipc-handlers/exec.test.cjs as a regression guard.

* docs(plugins): correct misleading plugin sandboxing claims

Docs claimed JS plugins run in 'isolated VM contexts' and iframes run
'without allow-same-origin' — both are false. JS plugins run in the host
renderer via new Function, and iframes use allow-same-origin (required for
#8467), so both can reach the privileged window.ea bridge. Align the docs
with the code (plugin-iframe.util.ts) and stress the trust model.

* fix(electron): fail closed on corrupt exec allow-list, cover error paths

The EXEC handler is wired to ipcMain.on (fire-and-forget), so a throw on a
corrupt allow-list surfaced as an unhandled promise rejection with no user
feedback. Wrap the handler body in try/catch and route failures through
errorHandlerWithFrontendInform (the same channel exec errors already use),
failing closed so a corrupt store never falls through to executing.

Expand the regression tests: assert the corrupt-config path informs the error
and runs nothing, cover exec-error routing, and guard allow-list append (an
overwrite regression that wipes remembered commands previously passed green).

* docs(plugins): document window.ea.exec shell path in trust model

The trust-model section implied executeNodeScript() was the only process
path; on desktop window.ea.exec also runs arbitrary shell commands via
child_process.exec behind a separate, weaker gate (confirmation dialog +
persistent allow-list, not the nodeExecution consent).

* test(electron): run exec security regression tests in CI

The test:electron runner globs electron/*.test.cjs (non-recursive), so the
guard at electron/ipc-handlers/exec.test.cjs was never executed by CI —
verified: the suite went 160 -> 168 tests once discovered. Move it to
electron/exec.test.cjs (matching every sibling electron test) and point
execModulePath at ipc-handlers/exec.ts.

* refactor(electron): narrow exec allow-list without an unsafe cast

Drop the `as string[]` cast that asserted away the very corruption the
Array.isArray guard is meant to catch; narrow the unknown value honestly so
the guard is a real type-check. Behavior is unchanged (falsy -> empty list,
truthy non-array -> fail closed).

* fix(electron): stop logging exec command content

Per CLAUDE.md rule 9 (log history is exportable, never log user content), a
command can carry a secret in its arguments; log a content-free line instead.
Also fold the duplicated exec-spawn into a single runCommand() helper so the
allow-listed and just-confirmed paths share one audited call site.

* fix(electron): remove exec IPC to close GHSA-256q at the root

window.ea.exec exposed arbitrary shell (child_process.exec) to the whole
renderer: JS plugins (new Function in the host realm), same-origin iframe
plugins (window.parent.ea), and any renderer XSS — bypassing the per-plugin
nodeExecution consent gate entirely. Its only consumer, COMMAND task
attachments, is dormant: no UI creates them (the edit dialog offers only
LINK/IMG/FILE).

Rather than guard a dormant RCE primitive, remove it: delete the EXEC IPC
handler + preload.exec + the ElectronAPI method + the directive's COMMAND
branch. This closes the vector for plugins, iframes, AND host-realm XSS at
once (a bootstrap handoff would only hide it from plugins), and supersedes
the earlier dialog hardening (that primitive no longer exists).

Keep the 'COMMAND' literal in the synced TaskAttachment type (removing a
synced union member breaks typia validation on peers/legacy data); a click
on a legacy COMMAND attachment now shows an informational snack instead of
executing.

* docs(plugins): reflect exec IPC removal in the trust model

window.ea.exec no longer exists (removed to close GHSA-256q); the docs now
state executeNodeScript is the only sanctioned native-code path.

* test(electron): guard exec IPC stays removed (GHSA-256q)

The interim exec security tests were deleted together with the executor, so the
shipped fix had no regression coverage. Add electron/exec.test.cjs (picked up by
the electron/*.test.cjs CI glob) asserting the bare exec primitive stays gone:
no IPC.EXEC event, no exec.ts handler, no preload exec bridge, no ElectronAPI.exec
method, no initExecIpc wiring. Targets only the removed surface, not the
sanctioned PLUGIN_EXEC_NODE_SCRIPT/executeScript nodeExecution path.

* chore(electron): mark ALLOWED_COMMANDS store key as legacy

Its only reader/writer was the deleted exec handler (GHSA-256q). Document that it
is retained purely so older persisted stores keep loading.
2026-07-01 17:55:35 +02:00

35 KiB
Raw Blame History

Super Productivity Plugin Development Guide

This is a comprehensive documentation of the Super Productivity Plugin System. This guide covers everything you need to know about creating plugins for Super Productivity.

These docs might not always be perfectly up to date. You find the latest typescript interfaces here: types.ts

Personally I think the best way to figure out how to write a plugin is to check out the example plugins:

If you want to build a sophisticated UI there is a boilerplate available for solidjs: boilerplate-solid-js


Table of Contents

Quick Start

1. Basic Plugin Structure

my-plugin/
├── manifest.json      # Plugin metadata (required)
├── plugin.js          # Host-side plugin code (optional for iframe-only plugins)
├── index.html         # UI interface (required when omitting plugin.js; requires iFrame:true in manifest)
└── icon.svg           # Plugin icon (optional)

plugin.js is required for plugins that need host-side setup at plugin load time, shortcuts, header buttons, background behavior, or host-side API handlers. A UI-only iframe plugin can ship only manifest.json and index.html when the manifest sets iFrame: true.

2. Minimal Example

manifest.json:

{
  "id": "hello-world",
  "name": "Hello World Plugin",
  "version": "1.0.0",
  "description": "My first Super Productivity plugin",
  "manifestVersion": 1,
  "minSupVersion": "14.0.0"
}

plugin.js:

console.log('Hello World plugin loaded!');

// Show a notification
PluginAPI.showSnack({
  msg: 'Hello from my plugin!',
  type: 'SUCCESS',
});

// Demo a simple counter
await PluginAPI.setCounter('hello-count', 0);
PluginAPI.registerHeaderButton({
  label: 'Hello (Count: 0)',
  icon: 'waving_hand',
  onClick: async () => {
    const newCount = await PluginAPI.incrementCounter('hello-count');
    PluginAPI.showSnack({
      msg: `Button clicked! Count: ${newCount}`,
      type: 'INFO',
    });
  },
});

Plugin Manifest

The manifest.json file is required for all plugins and defines the plugin's metadata and configuration.

Manifest Fields

Field Type Required Description
id string Unique identifier for your plugin (use kebab-case)
name string Display name shown to users
version string Semantic version (e.g., "1.0.0")
description string Brief description of what your plugin does
manifestVersion number Currently must be 1
minSupVersion string Minimum Super Productivity version required
author string Plugin author name
homepage string Plugin website or repository URL
icon string Path to icon file (SVG recommended)
iFrame boolean Whether plugin uses iframe UI (default: false)
sidePanel boolean Show plugin in side panel (default: false), requires iFrame:true
permissions string[] The permissions the plugin needs
hooks string[] App events to listen to
uiKit boolean Enable UI Kit CSS reset for iframe plugins (default: true). Set to false to disable.

Complete Manifest Example

{
  "id": "my-advanced-plugin",
  "name": "My Advanced Plugin",
  "version": "2.1.0",
  "description": "An advanced plugin with UI and hooks",
  "manifestVersion": 1,
  "minSupVersion": "14.0.2",
  "author": "John Doe",
  "homepage": "https://github.com/johndoe/my-plugin",
  "icon": "icon.svg",
  "iFrame": true,
  "sidePanel": false,
  "permissions": ["getTasks", "updateTask"],
  "hooks": ["taskComplete", "taskUpdate", "currentTaskChange"]
}

Plugin Types

1. JavaScript Plugins (plugin.js)

Pure JavaScript plugins with full API access. These run in the host app's own renderer (via new Function), not in a sandbox — plugin code shares the page's context and can reach privileged host APIs, so only install plugins whose source you trust (see Security Considerations).

Use when:

  • For setup background stuff that is to be executed even when the plugin ui (iFrame) is not shown
  • For registering and handling keyboard shortcuts
  • You want to listen to app hooks/events
  • You need programmatic interaction with tasks/projects

Example:

// Register multiple UI elements
PluginAPI.registerHeaderButton({
  label: 'My Button',
  icon: 'star',
  onClick: async () => {
    const tasks = await PluginAPI.getTasks();
    console.log(`You have ${tasks.length} tasks`);
  },
});

PluginAPI.registerHook(PluginAPI.Hooks.TASK_COMPLETE, (taskId) => {
  console.log(`Task ${taskId} completed!`);
});

2. HTML/Iframe Plugins (index.html)

Plugins that render custom UI in a sandboxed iframe.

Use when:

  • You need custom UI/visualizations
  • You want to display charts, forms, or complex interfaces

Iframe-only plugins do not need a plugin.js file if all plugin behavior lives inside index.html. Super Productivity automatically adds the default menu or side-panel entry from the manifest when the plugin is loaded.

Important: Iframe plugins are served from a sandboxed blob document and talk to the host only through the filtered Plugin API message bridge. Inline CSS, JavaScript, and small assets directly in index.html; arbitrary extra files from the ZIP are not served to the iframe. External URLs can work when the app/runtime CSP allows them, but they are not part of the portable plugin contract.

Example index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>My Plugin UI</title>

    <!-- CSS must be inlined. Theme variables and UI Kit are injected automatically. -->
    <style>
      body {
        padding: var(--s3);
      }

      .task-list {
        background: var(--card-bg);
        border-radius: var(--card-border-radius);
        padding: var(--s2);
        box-shadow: var(--whiteframe-shadow-2dp);
      }

      .task-item {
        padding: var(--s);
        border-bottom: 1px solid var(--divider-color);
      }
    </style>
  </head>
  <body>
    <h1>My Plugin</h1>
    <div id="content">
      <button id="loadTasks">Load Tasks</button>
      <div
        id="taskList"
        class="task-list"
      ></div>
    </div>

    <!-- JavaScript must be inlined -->
    <script>
      document.getElementById('loadTasks').addEventListener('click', async () => {
        try {
          const tasks = await PluginAPI.getTasks();
          const taskList = document.getElementById('taskList');

          taskList.innerHTML = '<h3>Your Tasks:</h3>';

          tasks.forEach((task) => {
            const taskEl = document.createElement('div');
            taskEl.className = 'task-item';
            taskEl.textContent = task.title;
            taskList.appendChild(taskEl);
          });

          PluginAPI.showSnack({
            msg: `Loaded ${tasks.length} tasks`,
            type: 'SUCCESS',
          });
        } catch (error) {
          console.error('Error loading tasks:', error);
          PluginAPI.showSnack({
            msg: 'Failed to load tasks',
            type: 'ERROR',
          });
        }
      });
    </script>
  </body>
</html>

Theme Variables & UI Kit

Iframe plugins automatically receive:

  1. CSS variables — All theme variables (colors, spacing, shadows, transitions) are injected as CSS custom properties on :root. Use var(--c-primary), var(--bg), var(--text-color), etc.

  2. UI Kit CSS reset — By default, basic HTML elements (button, input, select, textarea, table, a, h1h6, p, code, pre, hr, etc.) are styled to match the app's look. This is injected before your plugin's own styles, so your CSS always wins.

    To disable the UI Kit, add "uiKit": false to your manifest.

Button variants:

  • Default <button> — Neutral card-background button with border
  • <button class="btn-primary"> — Filled primary-color button (white text)
  • <button class="btn-outline"> — Transparent button with primary-color border and text, fills on hover

Card component:

  • <div class="card"> — Card with background, shadow, rounded corners, and border
  • <div class="card card-clickable"> — Adds hover lift effect and primary border highlight

Utility classes:

  • .text-muted — Muted text color (var(--text-color-muted))
  • .text-primary — Primary theme color (var(--c-primary))
  • .page-fade — Fade-in animation (0.3s ease)

Key CSS variables:

  • --bg, --bg-darker — Background colors
  • --text-color, --text-color-muted — Text colors
  • --c-primary, --c-accent, --c-warn — Theme colors
  • --card-bg, --card-shadow, --card-border-radius — Card styling
  • --divider-color — Border/divider color
  • --s, --s2, --s3, --s4, --s-half, --s-quarter — Spacing scale
  • --transition-standard — Standard transition
  • --font-primary-stack — App font stack
  • --whiteframe-shadow-1dp through --whiteframe-shadow-24dp — Elevation shadows
  • --is-dark-theme1 if dark theme, 0 if light

Available API Methods

Data Operations

Tasks

  • getTasks() - Get all active tasks
  • getArchivedTasks() - Get archived tasks
  • getCurrentContextTasks() - Get tasks in current context
  • getSelectedTask() - Get the task selected in the task detail panel, or null
  • getFocusedTask() - Get the currently focused task row, or null. Task-row focus is cleared when focus moves elsewhere, including into iframe side panels; use getSelectedTask() for persistent side-panel task context.
  • addTask(task) - Create a new task
  • updateTask(taskId, updates) - Update existing task

Application State

  • getAppState() - Get the current application state (read-only; returns PluginAppState). An overview of the data returned is the JSON file exported via Settings > Sync & Backup > Import/Export > Export data. Example: const state = await PluginAPI.getAppState();

Projects

  • getAllProjects() - Get all projects
  • addProject(project) - Create new project
  • updateProject(projectId, updates) - Update project

Tags

  • getAllTags() - Get all tags
  • addTag(tag) - Create new tag
  • updateTag(tagId, updates) - Update tag

Simple Counters

Simple counters let you track lightweight metrics (e.g., daily clicks or habits) that persist and sync with your data. There are two levels: basic (key-value pairs for today's count) and full model (full CRUD on SimpleCounter entities with date-specific values).

Basic Counters

These treat counters as a simple { [id: string]: number } map for today's values (auto-upserts via NgRx).

Method Description Example
getAllCounters() Get all counters as { [id: string]: number } const counters = await PluginAPI.getAllCounters(); console.log(counters['my-key']);
getCounter(id) Get today's value for a counter (returns null if unset) const val = await PluginAPI.getCounter('daily-commits');
setCounter(id, value) Set today's value (non-negative number; validates id regex /^[A-Za-z0-9_-]+$/) await PluginAPI.setCounter('daily-commits', 5);
incrementCounter(id, incrementBy = 1) Increment and return new value (floors at 0) const newVal = await PluginAPI.incrementCounter('daily-commits', 2);
decrementCounter(id, decrementBy = 1) Decrement and return new value (floors at 0) const newVal = await PluginAPI.decrementCounter('daily-commits');
deleteCounter(id) Delete the counter await PluginAPI.deleteCounter('daily-commits');

Example:

// Track daily commits
let commits = (await PluginAPI.getCounter('daily-commits')) ?? 0;
await PluginAPI.incrementCounter('daily-commits');
PluginAPI.showSnack({
  msg: `Commits today: ${await PluginAPI.getCounter('daily-commits')}`,
  type: 'INFO',
});
Full SimpleCounter Model

For advanced use: Full CRUD on counters with metadata (title, enabled state, date-specific values via countOnDay: { [date: string]: number }).

Method Description Example
getAllSimpleCounters() Get all as SimpleCounter[] const all = await PluginAPI.getAllSimpleCounters();
getSimpleCounter(id) Get one by id (returns undefined if not found) const counter = await PluginAPI.getSimpleCounter('my-id');
updateSimpleCounter(id, updates) Partial update (e.g., { title: 'New Title', countOnDay: { '2025-11-17': 10 } }) await PluginAPI.updateSimpleCounter('my-id', { isEnabled: false });
toggleSimpleCounter(id) Toggle isOn state (throws if not found) await PluginAPI.toggleSimpleCounter('my-id');
setSimpleCounterEnabled(id, isEnabled) Set enabled state await PluginAPI.setSimpleCounterEnabled('my-id', true);
deleteSimpleCounter(id) Delete by id await PluginAPI.deleteSimpleCounter('my-id');
setSimpleCounterToday(id, value) Set today's value (YYYY-MM-DD) await PluginAPI.setSimpleCounterToday('my-id', 10);
setSimpleCounterDate(id, date, value) Set value for specific date (validates YYYY-MM-DD) await PluginAPI.setSimpleCounterDate('my-id', '2025-11-16', 5);

Example:

// Create/update a habit counter
await PluginAPI.updateSimpleCounter('habit-streak', {
  title: 'Daily Streak',
  type: 'ClickCounter',
  isEnabled: true,
  countOnDay: { '2025-11-17': 1 }, // Today's count
});
await PluginAPI.toggleSimpleCounter('habit-streak');
const counter = await PluginAPI.getSimpleCounter('habit-streak');
console.log(`Streak on: ${counter.isOn}`);

UI Operations

Notifications

// Show snackbar notification
PluginAPI.showSnack({
  msg: 'Operation completed!',
  type: 'SUCCESS', // SUCCESS, ERROR, INFO, WARNING
  ico: 'check', // Optional Material icon
  actionStr: 'Undo', // Optional action button
  actionFn: () => console.log('Undo clicked'),
});

// System notification
PluginAPI.notify({
  title: 'Task Complete',
  body: 'Great job!',
  ico: 'done',
});

Dialogs

// Open a dialog
const result = await PluginAPI.openDialog({
  title: 'Confirm Action',
  htmlContent: '<p>Are you sure?</p>',
  buttons: [{ label: 'No' }, { label: 'Yes', color: 'primary', raised: true }],
});

if (result === 'Yes') {
  // Continue with the confirmed action
}

openDialog() resolves with the clicked button label. If the user dismisses the dialog without clicking a button, it resolves with undefined. The legacy content, okBtnLabel, and cancelBtnLabel fields are still accepted, but new plugins should use htmlContent and buttons.

Registration Methods (plugin.js only)

Header Button

PluginAPI.registerHeaderButton({
  id: 'my-header-btn', // Optional unique ID
  label: 'Click Me',
  icon: 'star', // Material icon name
  onClick: () => {
    console.log('Header button clicked');
  },
});

Menu Entry

PluginAPI.registerMenuEntry({
  label: 'My Plugin Action',
  icon: 'extension',
  onClick: () => {
    console.log('Menu item clicked');
  },
});

Side Panel Button

PluginAPI.registerSidePanelButton({
  label: 'My Panel',
  icon: 'dashboard',
  onClick: () => {
    PluginAPI.showIndexHtmlAsView();
  },
});

Keyboard Shortcut

PluginAPI.registerShortcut({
  keys: 'ctrl+shift+p',
  label: 'My Plugin Shortcut',
  action: () => {
    console.log('Shortcut triggered');
  },
});

Hooks

// Available hooks
const hooks = {
  TASK_COMPLETE: 'taskComplete',
  TASK_UPDATE: 'taskUpdate',
  TASK_DELETE: 'taskDelete',
  CURRENT_TASK_CHANGE: 'currentTaskChange',
  FINISH_DAY: 'finishDay',
  LANGUAGE_CHANGE: 'languageChange',
  PERSISTED_DATA_CHANGED: 'persistedDataChanged',
  ACTION: 'action',
};

PERSISTED_DATA_CHANGED fires whenever this plugin's persisted data changes — local writes, remote sync deliveries, and bulk imports — after the host has finished its initial boot load. The handler receives no payload; re-call loadSyncedData(key?) for any key your plugin tracks to get fresh data. There is no replay-on-register and no guaranteed ordering across rapid changes, so handlers must be idempotent. The typical pattern is: call loadSyncedData() once on plugin init, then subscribe to this hook for subsequent updates.

// Register hook listener
PluginAPI.registerHook(PluginAPI.Hooks.TASK_COMPLETE, (taskId) => {
  console.log(`Task ${taskId} completed!`);
});

// Listen to Redux actions
PluginAPI.registerHook(PluginAPI.Hooks.ACTION, (action) => {
  if (action.type === 'ADD_TASK_SUCCESS') {
    console.log('New task added:', action.payload);
    // Bonus: Increment a counter on task add
    PluginAPI.incrementCounter('tasks-added-today');
  }
});

Data Persistence

You can persist data that will also be synced via the persistDataSynced and loadSyncedData APIs. Host-side plugin.js code can use localStorage for data that should stay local. Iframe plugins should prefer the synced persistence APIs because sandboxed iframe origins may not have reliable access to browser storage.

// Save plugin data
await PluginAPI.persistDataSynced(JSON.stringify({ count: 42 }));

// Load saved data
const data = await PluginAPI.loadSyncedData();
console.log(data); // '{ count: 42 }'

Secret Storage

For credentials — IMAP/SMTP passwords, API tokens, app passwords — use setSecret / getSecret / deleteSecret. Secrets are stored local-only: they are never synced, exported, or included in backups, and each plugin can only read its own keys.

// Store a credential (key must be a non-empty string)
await PluginAPI.setSecret('imapPassword', 'app-password-123');

// Read it back when you need to connect
const pw = await PluginAPI.getSecret('imapPassword'); // string | null

// Remove it (e.g. when the user disconnects)
await PluginAPI.deleteSecret('imapPassword');

Rules of thumb:

  • Never put a credential in persistDataSynced or in issue-provider config — those sync to the server and land in exports/backups. Keep only non-secret connection details there (host, port, username, filters) and put the password/token in secret storage.
  • Secrets are per-device: a value set on desktop is not available on mobile, so prompt the user to enter the credential on each device. (This matches how IMAP app-passwords are typically used anyway.)
  • Secrets are stored unencrypted at rest today (the same as plugin OAuth tokens); the guarantee is "stays on this device, never synced," not hardware-level encryption. Don't store anything you wouldn't accept living in the app's local profile.
  • All secrets for a plugin are purged automatically when the plugin is uninstalled.

Secrets in issue-provider plugins

Issue-provider plugins get the same secret API (an issue provider is a normal plugin that also calls registerIssueProvider). Your definition callbacks (getHeaders, getById, searchIssues, …) run in your plugin's context, so they can read secrets directly:

PluginAPI.registerIssueProvider({
  // Declare only NON-secret fields here — their values are stored in the
  // synced issue-provider config:
  configFields: [
    { key: 'host', type: 'text', label: 'Host' },
    { key: 'username', type: 'text', label: 'Username' },
  ],
  // getHeaders may return a Promise, so read the credential from secret
  // storage instead of from `config`:
  async getHeaders(config) {
    const token = await PluginAPI.getSecret('apiToken');
    return token ? { Authorization: `Bearer ${token}` } : {};
  },
  async getById(issueId, config, http) {
    /* ... http call uses the headers above ... */
  },
  // ...
});

The host passes only the synced config into these callbacks — there is no secret parameter, and the declarative configFields form always writes to the synced config. So collect the secret through your own UI (a config dialog registered via registerConfigHandler, or a side panel) and store it with setSecret there; do not add the credential as a configFields entry.

Best Practices

1. Performance

  • Lazy load resources: Don't load everything on plugin initialization
  • Be responsive with using resources: Avoid heavy operations and don't save excessive amounts of data.
  • Keep it lightweight: Super Productivity is not the only app on the users system and your plugin is not the only plugin.

2. User Experience

  • Provide feedback: Show loading states and confirmations
  • Be non-intrusive: Don't spam notifications
  • Follow the app's design: Use the injected theme variables and try to keep styles minimal.
  • Respect user preferences: Check dark mode, and language settings (if possible or stick to english if not)

3. Security

  • Request minimal permissions: Only what you need

Node.js Script Execution

Plugins with "permissions": ["nodeExecution"] can run Node.js scripts in the Electron desktop app after the user allows the desktop permission prompt.

Both built-in and uploaded (community) plugins may request nodeExecution. The grant is issued by the Electron main process after a native consent dialog and is bound to the plugin id. For uploaded plugins the app cannot verify the manifest, so the dialog flags the plugin as unverified third-party code with full machine access that Super Productivity cannot sandbox, and defaults to Deny — only allow plugins whose source you trust. If the user denies, the plugin stays enabled but its node calls fail until it is re-enabled.

Consent handling differs by plugin type:

  • Uploaded (community) plugins: consent is remembered once per plugin in a main-owned, local-only store (Allow is not asked again on the next launch). The consent is never synced — granting on one device does not auto-grant on another; the other device prompts afresh on first node use. Consent is automatically cleared (forcing a fresh prompt) when you disable, uninstall, or re-upload the plugin, so replacing a plugin's code under the same id always re-asks. To revoke access without removing the plugin, simply disable it.
  • Built-in plugins (e.g. sync-md) keep the per-session prompt and are not persisted.

Plugin id constraints (for nodeExecution): the consent grant keys on your manifest id, so it must be a single safe token — no whitespace, control/bidi characters, :, path separators (/, \), and at most 100 characters. Lowercase kebab-case is recommended; dots and uppercase are accepted.

Security note: a granted nodeExecution plugin can run any program with full access to your files and system. The file/IPC channel a plugin uses to talk to a companion process is an open local channel — treat any data it reads as untrusted input (never eval/require its contents).

const result = await plugin.executeNodeScript({
  script: `
    const os = require('os');
    return os.hostname();
  `,
  timeout: 5000,
});

if (result.success) {
  console.log('Hostname:', result.result);
}

Important — use plugin.onReady() for startup calls:

executeNodeScript requires the Electron IPC bridge to be available. On cold boot this bridge may not be ready when plugin.js first runs. Always put executeNodeScript calls (and any other startup init code) inside plugin.onReady():

// ❌ May fail on cold boot
const result = await plugin.executeNodeScript({ script: 'return true' });

// ✅ Correct — fires after the bridge is confirmed available
plugin.onReady(async () => {
  const result = await plugin.executeNodeScript({ script: 'return true' });
});

plugin.onReady(fn) fires after plugin.js has fully evaluated and the app has confirmed the Node.js IPC bridge is responding (with automatic retry). If the bridge is unavailable after retries, an error is shown in the plugin management UI and onReady does not fire.

You can also use onReady for any other startup work that should run after the plugin script has finished setting up its hooks and registrations — not just for nodeExecution.

Iframe plugins: PluginAPI.onReady() is available inside index.html. It fires on the next microtask after the callback is registered — without an IPC bridge ping. This is fine in practice because iframe plugins are rendered on user navigation (well after host startup). Iframe API calls still go through the host bridge when they are made; cold-boot bridge pings are only performed for host-side plugin code.

Clean up with plugin.onUnload():

Code-based plugins (plugin.js) run directly in the app's renderer, so timers and listeners they create are not cleaned up automatically when the plugin is disabled, reloaded, or uninstalled — a setInterval started by your plugin keeps firing until the app is fully reloaded. Register a teardown callback to clear them yourself:

const intervalId = setInterval(doWork, 60000);

plugin.onUnload(() => {
  clearInterval(intervalId);
  // also: removeEventListener, speechSynthesis.cancel(), close connections, …
});

The host invokes the callback at the start of plugin teardown, while the Plugin API is still usable for calls like persisting data — but don't register new hooks or listeners from inside it (the plugin is going away; re-registering onUnload there is ignored). The returned promise is not awaited — do synchronous cleanup (clearInterval etc.) before any await, since teardown continues immediately. Registering again replaces the previous callback, so register once and do all cleanup there. Errors thrown by the callback are logged and do not block teardown.

Plugins distributed independently of the app should feature-detect it (if (plugin.onUnload) { ... }) — hosts predating the hook don't provide it.

Iframe plugins: onUnload exists but is a no-op — the host unmounts the iframe on unload, which takes its timers and listeners with it. Don't rely on it for unload-time persistence in iframes; persist when the data changes instead.

4. Don't spam the logs

console.logs should be kept to a minimum.

5. Iframe plugins: keep assets self-contained

  1. Prefer self-contained HTML: inline CSS, JavaScript, and small assets are the most portable option for iframe plugins
<!-- Portable: Everything needed by the iframe is in index.html -->
<!DOCTYPE html>
<html>
  <head>
    <style>
      /* All styles here */
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script>
      // All JavaScript here
    </script>
  </body>
</html>

Security Considerations

Execution model & trust

Plugins are not strongly sandboxed from the host — installing a plugin means trusting its code with your data:

  • JavaScript (plugin.js) plugins run in the host app's renderer via new Function, in the same context as the app. They can reach privileged host APIs (including, on desktop, window.ea).
  • Iframe plugins render with the allow-same-origin sandbox flag (required so the UI paints on the packaged file:// desktop build). Being same-origin, they can read window.parent.ea directly, so the postMessage bridge is a convenience, not a hard security boundary.
  • Filesystem/process access on desktop goes through executeNodeScript(), which stays gated by an explicit main-process consent prompt (nodeExecution permission). This is the only sanctioned way for a plugin to run native code.
  • There is no window.ea.exec(): the old IPC that ran arbitrary shell commands via child_process.exec (reachable by any plugin/iframe/XSS, bypassing the nodeExecution consent) was removed. Legacy COMMAND task attachments no longer execute.

Only install plugins from sources you trust, and read the code first.

Iframe API Surface

Iframe plugins receive a filtered window.PluginAPI object injected into index.html. The iframe can use the injected task/project/tag APIs, dialog and notification APIs, navigation helpers, persistence helpers, counters, action dispatch, registerHook(), and registerWorkContextHeaderButton(). Callback-heavy registration methods such as registerHeaderButton(), registerMenuEntry(), registerSidePanelButton(), registerShortcut(), and registerConfigHandler() must be registered from host-side plugin.js code. APIs not injected into the iframe are unavailable, even if they exist on the host-side plugin bridge.

executeNodeScript() is proxied through the host bridge for iframe plugins when the desktop app grants the plugin nodeExecution permission.

Iframe Boundary

  • Iframe plugins render with allow-same-origin (required so the UI paints on the packaged file:// desktop build; an opaque-origin iframe stays blank — see #8467)
  • Because they are same-origin, iframe plugins can read window.parent.ea directly; the filtered postMessage bridge is the intended API, not an enforced boundary
  • Remote assets depend on the app/runtime CSP and should not be relied on
  • Restoring opaque-origin isolation (serving the renderer from an app:// scheme) is tracked separately

Testing Your Plugin

1. Local Development

  1. Use "Load Plugin from Folder" to test your plugin
  2. Open DevTools (F12 or Ctrl+Shift+i) to see console logs
  3. Use the API Test Plugin as reference

2. Debugging Tips

// Add debug logging
const DEBUG = true;

function log(...args) {
  if (DEBUG) {
    console.log('[MyPlugin]', ...args);
  }
}

// Test API methods
async function testAPI() {
  log('Testing getTasks...');
  const tasks = await PluginAPI.getTasks();
  log('Tasks:', tasks);

  log('Testing showSnack...');
  PluginAPI.showSnack({
    msg: 'API test successful!',
    type: 'SUCCESS',
  });
}

3. Common Issues

Plugin not loading:

  • Check manifest.json syntax
  • Verify minSupVersion compatibility
  • Look for errors in console

API methods failing:

  • Check if method is available in current context
  • Verify permissions in manifest
  • If executeNodeScript fails on startup or cold boot, wrap your init code in plugin.onReady(async () => { ... }) — this ensures the Node.js bridge is ready before your code runs

Iframe not displaying:

  • Check that all resources are inlined
  • Verify no external dependencies
  • Look for CSP violations in console

Resources

Contributing

If you create a useful plugin, consider:

  1. Posting on reddit or GitHub discussions about it
  2. Submitting a PR to add it to the community plugins list (coming soon)

Happy plugin development! 🚀

Bonus: Vibe Coding your Plugins

Tips

  • Don't test on your real world data! Use a test instance! (you can use https://test-app.super-productivity.com/ if you don't know how get one)
  • Be as specific as possible
  • Outline what APIs your plugin should use
  • Test for errors (Ctrl+Shift+i opens the console) and iterate until it works. Don't expect that everything works on your first try.
  • Read the code! Don't trust it blindly.

Example

Can you you write me a plugin for Super Productivity that plays a beep sound every time i click on a header button (You need to add a header button via PluginAPI.registerHeaderButton).

Here are the docs: https://github.com/super-productivity/super-productivity/blob/master/docs/plugin-development.md

Don't use any PluginAPI methods that are not listed in the guide.

Please give me the output as flat zip file to download.