Skip to main content

Plugin System

Extend Daintree with custom panels, toolbar buttons, menu items, and IPC channels using the experimental plugin system.

Updated
Reviewed

Overview

Daintree's plugin system lets you extend the app with custom panels, toolbar buttons, menu items, and IPC channels. Plugins are loaded from the filesystem at startup. Each plugin is a directory containing a plugin.json manifest that declares metadata, entry points, and contributions.

There is no plugin marketplace or in-app management UI. You install plugins by placing them in the right directory and restarting Daintree. The system is designed for developers who want to build custom tooling on top of Daintree's workspace.

Installation

Plugins live in ~/.daintree/plugins/. Each plugin gets its own subdirectory containing at minimum a plugin.json manifest file:

~/.daintree/plugins/
└── acme.my-plugin/
    ├── plugin.json
    └── main.js        # optional main process entry

To install a plugin, create its directory under ~/.daintree/plugins/ and add the manifest. Then quit and relaunch Daintree. If the ~/.daintree/plugins/ directory doesn't exist, Daintree silently skips plugin loading with no errors.

Note
Plugins load once at startup. There is no hot-reload. Every time you change a plugin's manifest or code, you need to fully quit and relaunch Daintree to pick up the changes.

Manifest Reference

The plugin.json manifest is the plugin's declaration. It defines who the plugin is, what it contributes, and where its code lives. Here's a complete example showing all available fields:

{
  "name": "acme.my-plugin",
  "version": "1.0.0",
  "displayName": "My Plugin",
  "description": "A sample plugin demonstrating all contribution types.",
  "engines": {
    "daintree": "^0.7.0"
  },
  "main": "main.js",
  "contributes": {
    "panels": [
      {
        "id": "viewer",
        "name": "Custom Viewer",
        "iconId": "eye",
        "color": "#8B5CF6"
      }
    ],
    "toolbarButtons": [
      {
        "id": "open-viewer",
        "label": "Open Viewer",
        "iconId": "eye",
        "actionId": "acme.my-plugin.open-viewer",
        "priority": 3
      }
    ],
    "menuItems": [
      {
        "label": "Open Custom Viewer",
        "actionId": "open-viewer",
        "location": "view",
        "accelerator": "CommandOrControl+Shift+V"
      }
    ]
  }
}

Root Fields

FieldTypeRequiredDescription
namestringYesScoped plugin identifier in publisher.name format. Must match /^[a-z0-9]+(?:-[a-z0-9]+)*.[a-z0-9]+(?:-[a-z0-9]+)*$/, max 64 characters. Lowercase segments only, hyphens allowed within a segment, exactly one dot separator. Used as the namespace prefix for all contributed IDs. See Plugin ID Format.
versionstringYesPlugin version string (any format, minimum 1 character).
displayNamestringNoHuman-readable name shown in UI contexts.
descriptionstringNoShort description of what the plugin does.
enginesobjectNoHost version constraints. Currently supports a single field, engines.daintree, which accepts any valid semver range (for example ^0.7.0 or >=0.7.0 <0.9.0). Incompatible plugins are skipped with a user-visible toast. Plugins without this field still load but log a warning. See Version Compatibility.
mainstringNoRelative path to a Node.js entry file that runs in the Electron main process.
rendererstringNoDeprecated. Still parsed for backward compatibility but ignored at load time. The loader logs a deprecation warning and continues. Remove it from new manifests. See Renderer Entry.
contributesobjectNoContainer for all contribution arrays (panels, toolbarButtons, menuItems). Defaults to empty.

Plugin ID Format

Plugin name values use a scoped publisher.name format. The publisher segment identifies the author or organization, and the name segment identifies the plugin. Both segments are lowercase, may contain digits, and may use hyphens internally. A single dot separates them, and the whole string is capped at 64 characters.

The validation regex is /^[a-z0-9]+(?:-[a-z0-9]+)*.[a-z0-9]+(?:-[a-z0-9]+)*$/. Manifests that fail this check are rejected at load with:

Plugin name must be in publisher.name format (e.g. "acme.linear-context")
ExampleValidReason
acme.linear-contextYesPublisher and name segments, hyphen inside the name segment.
daintreehq.dev-toolsYesStandard scoped form.
daintree-hq.my-cool-pluginYesHyphens allowed in both segments.
a.bYesMinimum valid form: one character per segment.
my-pluginNoBare name with no publisher. Add a scope (for example acme.my-plugin).
Acme.linear-contextNoUppercase letters are not allowed in either segment.
acme.team.toolsNoOnly one dot separator is allowed. Pick a two-segment form.
acme.-foo / acme.foo-NoSegments cannot start or end with a hyphen.
acme..toolsNoEmpty segment between dots.
acme_tools / acme/toolsNoUnderscores and slashes are not part of the pattern.

The plugin's name is used as the namespace prefix for every contributed ID. A plugin called acme.my-plugin contributing a panel with id: "viewer" registers as panel kind acme.my-plugin.viewer. Contribution IDs inside the manifest (panel id, toolbar button id) still use the looser safe-ID pattern because they're namespaced under the plugin name.

Version Compatibility

The optional engines.daintree field declares which Daintree versions a plugin supports. It accepts any valid semver range (^0.7.0, ~0.7.2, >=0.7.0 <0.9.0, *). Invalid range strings fail the Zod schema validation step (step 1 of the loading lifecycle) and cause the manifest to be rejected entirely, the same way a bad name value does. That's separate from the version-incompatibility skip at step 2, which applies when the range is valid but doesn't satisfy the running Daintree version.

On startup, the loader evaluates semver.satisfies(appVersion, range, { includePrerelease: true }). If the current Daintree version satisfies the range, the plugin loads normally. If it doesn't, the plugin is skipped entirely and the user sees a toast:

Plugin "My Plugin" requires Daintree ^0.7.0 but current version is 0.6.4.

Plugins that omit engines.daintree still load, but the loader logs a warning recommending the field. Treat that warning as a todo: every plugin should declare a compatibility range before shipping.

One subtle edge case: with includePrerelease: true, a prerelease version like 0.7.1-rc.1 satisfies ^0.7.0, but a prerelease below a range's floor (0.7.0-rc.1 against >=0.7.0) does not satisfy. If you need prerelease support, declare it explicitly in the range.

Tip
Set engines.daintree to the Daintree version you develop against, using a caret range to allow compatible updates (for example ^0.7.0). This gives users a clear "tested against" signal and lets the loader skip cleanly on older installs instead of your plugin silently misbehaving.

Panels

Panel contributions register new panel kinds that appear in the panel palette (Cmd+N). Each contributed panel becomes a selectable type alongside Daintree's built-in panels. The final panel kind ID is namespaced as {pluginName}.{id}, so a plugin named acme.my-plugin with a panel id: "viewer" registers as acme.my-plugin.viewer.

FieldTypeRequiredDefaultDescription
idstringYesPanel kind identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters.
namestringYesDisplay name shown in the panel palette and tab headers.
iconIdstringYesIcon identifier string used in the palette and tab.
colorstringYesAccent color for the panel (hex string).
hasPtybooleanNofalseWhether the panel uses a PTY process.
canRestartbooleanNofalseWhether the restart button is shown in the panel header.
canConvertbooleanNofalseWhether the panel can be converted to other panel types.
showInPalettebooleanNotrueWhether the panel kind appears in the panel palette. Set to false for panel types that should only be created programmatically.

For more on how panels work in Daintree, see Terminals & Panels.

Toolbar Buttons

Toolbar button contributions add buttons to Daintree's main toolbar. Plugin buttons participate in the same overflow system as built-in buttons. Users can show or hide plugin buttons in Settings > Toolbar. The final button ID is namespaced as plugin.{pluginName}.{id}, so acme.my-plugin's open-viewer button registers as plugin.acme.my-plugin.open-viewer.

FieldTypeRequiredDefaultDescription
idstringYesButton identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters.
labelstringYesTooltip text and overflow menu label.
iconIdstringYesIcon identifier string.
actionIdstringYesAction ID dispatched when the button is clicked. Can be a plugin-defined action ID.
priority1–5No3Overflow priority. 1 = last to overflow (stays visible longest), 5 = first to overflow.

For details on toolbar overflow behavior and priority tiers, see UI Layout.

Menu Items

Menu item contributions add entries to Daintree's application menu. Each item specifies which menu it belongs to and an action ID to dispatch when selected. The action is dispatched with a plugin: prefix, so an actionId of "open-viewer" fires as plugin:open-viewer.

FieldTypeRequiredDescription
labelstringYesDisplay label in the menu.
actionIdstringYesAction ID dispatched on selection (prefixed with plugin: by the app menu).
locationstringYesWhich menu to add the item to: terminal, file, view, or help.
acceleratorstringNoKeyboard shortcut in Electron accelerator format (e.g. CommandOrControl+Shift+P).

Main Process Entry

The main field in the manifest points to a Node.js file that runs in Electron's main process at startup. This is where you register IPC handlers, set up background tasks, or interact with system APIs. The entry is loaded via dynamic import() after all manifest contributions (panels, buttons, menus) have been registered.

The primary API available to main process plugins is registerPluginHandler, which creates custom IPC channels that the renderer can call into:

// main.js
// registerPluginHandler is injected by Daintree's plugin runtime; do not import it

registerPluginHandler('acme.my-plugin', 'get-data', async (arg1, arg2) => {
  // This runs in the main process with full Node.js access
  const result = await fetchSomething(arg1, arg2);
  return result;
});

registerPluginHandler('acme.my-plugin', 'save-state', async (state) => {
  await fs.promises.writeFile('/tmp/state.json', JSON.stringify(state));
  return { ok: true };
});

Channel names must not contain colons. If you register the same (pluginId, channel) pair twice, the second handler silently replaces the first.

Note
The main entry runs with full Node.js access in Electron's main process. Plugin code has the same capabilities as any Node.js module: filesystem access, network requests, child processes. Treat plugins the same way you'd treat any npm dependency you install.

If the main entry throws during import, the error is logged but the plugin's manifest contributions (panels, toolbar buttons, menu items) remain registered. The plugin is partially functional: its UI contributions work, but its runtime logic does not.

Renderer Entry

Plugins continue to interact with the renderer through window.api.plugin. Use plugin.invoke to call into your main-process handlers and plugin.list to inspect what's loaded. The resolvedRenderer field that older documentation referenced no longer exists on the returned plugin info.

Renderer API

The renderer communicates with plugin main-process code through window.api.plugin:

MethodDescription
plugin.list()Returns an array of loaded plugin info including manifest data and directory path.
plugin.invoke(pluginId, channel, ...args)Calls a registered main-process handler and returns the result. This is the primary renderer-to-main communication channel.
plugin.on(pluginId, channel, callback)Subscribes to events on a plugin channel. Returns an unsubscribe function.
plugin.toolbarButtons()Returns the list of toolbar button configurations registered by all plugins.
plugin.menuItems()Returns the list of menu item configurations registered by all plugins.
Note
The plugin.on method sets up a subscriber, but there is currently no built-in API for pushing events from main to renderer. For now, use plugin.invoke for request/response patterns. Main-to-renderer push support may be added in a future release.

Panel State Persistence

Panel instances have an extensionState field: an opaque Record<string, unknown> that plugins can use to store arbitrary data. This state persists through session save/restore cycles and project switches, so your plugin's per-panel data survives across restarts.

The setter API for writing extension state is not yet finalised in the experimental system. The field is readable from panel instances and the data round-trips correctly through save/restore. How plugins write back to it will be documented once the API stabilises.

Extension state is also available at the project level via extensionState on the project object, giving plugins a place to store project-scoped data that isn't tied to a specific panel.

Actions and Keyboard Shortcuts

Daintree's ActionId and KeyAction types are open unions, meaning plugins can define custom action identifiers beyond the built-in set. Any string works as an action ID. This means plugin-defined actions integrate with Daintree's action palette and keybinding system.

Toolbar button actionId values are dispatched directly when clicked. Menu item actionId values are dispatched with a plugin: prefix. Both can be bound to keyboard shortcuts through Settings > Keyboard.

Action ID Validation

After the renderer's action registry finishes populating, it pushes the full list of known action IDs back to the main process. Daintree then checks every actionId referenced by a plugin's toolbar buttons and menu items against that set. Anything unrecognized triggers a console warning:

[Plugin] Unknown actionId "acme.my-plugin.opne-viewer" on toolbar button "open-viewer" (plugin: acme.my-plugin)
[Plugin] Unknown actionId "acme.my-plugin.missing" on menu item "Open Viewer" (plugin: acme.my-plugin)

This check runs once per session, so multi-window setups don't double-log. Non-array payloads are ignored defensively.

Note
Action ID warnings are diagnostic only. The plugin still loads, its contributions still register, and the button or menu item still renders. Check the DevTools console during development to catch typos and stale references before shipping a release.

Security

Daintree validates that the main path doesn't escape the plugin's directory. It's resolved with path.resolve and checked against the plugin directory prefix. If the path escapes, the entry is silently dropped but the plugin's other contributions still load. The plugin name field is constrained to the scoped lowercase publisher.name pattern, which rules out path separators, whitespace, and uppercase characters in the namespace prefix.

Beyond path validation, there is no sandboxing. Main process plugin code runs with full Node.js capabilities in Electron's main process. Only install plugins you trust. This is the same trust model as installing an npm package or a VS Code extension.

Loading Lifecycle

When Daintree starts, the plugin loader processes each subdirectory in ~/.daintree/plugins/ through these steps in order:

  1. Validate manifest by parsing plugin.json against the Zod schema. Invalid manifests (including names that don't match the scoped publisher.name pattern) cause the plugin to be skipped entirely.
  2. Check engines.daintree against the running app version. Incompatible plugins are skipped with a user-visible error toast. Plugins without the field load with a console warning.
  3. Warn on deprecated fields. If the manifest still sets renderer, the loader logs a deprecation warning and ignores the field.
  4. Resolve entry paths for main, checking they don't escape the plugin directory.
  5. Register panel kinds from contributes.panels into the panel kind registry.
  6. Register toolbar buttons from contributes.toolbarButtons into the toolbar button registry.
  7. Register menu items from contributes.menuItems into the menu registry.
  8. Import main entry via dynamic import(). If this throws, contributions from steps 5 through 7 remain active.
  9. Validate action IDs once the renderer's action registry populates. Unknown actionId values on toolbar buttons and menu items log a console warning. The plugin is never blocked.

The entire process runs once at startup. Plugins are loaded in parallel using Promise.allSettled, so one failing plugin doesn't block others from loading. Daintree can also unload a plugin cleanly at runtime through an internal unloadPlugin(pluginId) service, which removes the plugin's IPC handlers and unregisters its panel kinds, toolbar buttons, and menu items. This is used by Daintree's own lifecycle management; there's no IPC hook for plugins to trigger their own unload yet.

Limitations

The plugin system is in early development. Current constraints to be aware of:

  • No hot-reload. Any change to a plugin requires a full restart of Daintree.
  • No sandbox. Main process plugins run with full Node.js access. There is no permission model or capability restriction.
  • No marketplace. Plugins are installed manually by placing files in ~/.daintree/plugins/.
  • No user-facing disable/unload. A clean unload path exists internally (unloadPlugin) and Daintree uses it during its own lifecycle, but there's no in-app control or IPC surface for disabling a plugin. The only way to remove a plugin today is to delete its directory and restart.
  • No main-to-renderer push. The plugin.on subscriber exists but there is no built-in mechanism for the main process to push events to the renderer.
  • Duplicate plugin names. If two plugin directories use the same name, the last one loaded wins. A warning is logged, but both sets of contributions may already be registered.
  • No renderer-side entry points. The renderer field is deprecated and ignored at load time. All plugin logic runs in the main process and drives the renderer through IPC.