Plugin System
Extend Daintree with custom panels, toolbar buttons, menu items, and IPC channels using the experimental plugin system.
name values now require a scoped publisher.name format (for example acme.linear-context). Bare names like my-plugin are rejected at manifest load. If you're migrating from v0.5 or v0.6: - Rename your plugin's
nameto scoped form and update everyactionIdthat referenced the old name. - Add an
engines.daintreesemver range so the loader can skip your plugin cleanly on incompatible versions (see Version Compatibility). - Remove any
rendererfield. It still parses for backward compatibility but the loader ignores it and logs a deprecation warning.
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.
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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Scoped 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. |
version | string | Yes | Plugin version string (any format, minimum 1 character). |
displayName | string | No | Human-readable name shown in UI contexts. |
description | string | No | Short description of what the plugin does. |
engines | object | No | Host 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. |
main | string | No | Relative path to a Node.js entry file that runs in the Electron main process. |
renderer | string | No | Deprecated. 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. |
contributes | object | No | Container 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") | Example | Valid | Reason |
|---|---|---|
acme.linear-context | Yes | Publisher and name segments, hyphen inside the name segment. |
daintreehq.dev-tools | Yes | Standard scoped form. |
daintree-hq.my-cool-plugin | Yes | Hyphens allowed in both segments. |
a.b | Yes | Minimum valid form: one character per segment. |
my-plugin | No | Bare name with no publisher. Add a scope (for example acme.my-plugin). |
Acme.linear-context | No | Uppercase letters are not allowed in either segment. |
acme.team.tools | No | Only one dot separator is allowed. Pick a two-segment form. |
acme.-foo / acme.foo- | No | Segments cannot start or end with a hyphen. |
acme..tools | No | Empty segment between dots. |
acme_tools / acme/tools | No | Underscores 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.
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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | Panel kind identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters. | |
name | string | Yes | Display name shown in the panel palette and tab headers. | |
iconId | string | Yes | Icon identifier string used in the palette and tab. | |
color | string | Yes | Accent color for the panel (hex string). | |
hasPty | boolean | No | false | Whether the panel uses a PTY process. |
canRestart | boolean | No | false | Whether the restart button is shown in the panel header. |
canConvert | boolean | No | false | Whether the panel can be converted to other panel types. |
showInPalette | boolean | No | true | Whether 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.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | Button identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters. | |
label | string | Yes | Tooltip text and overflow menu label. | |
iconId | string | Yes | Icon identifier string. | |
actionId | string | Yes | Action ID dispatched when the button is clicked. Can be a plugin-defined action ID. | |
priority | 1–5 | No | 3 | Overflow 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.
| Field | Type | Required | Description |
|---|---|---|---|
label | string | Yes | Display label in the menu. |
actionId | string | Yes | Action ID dispatched on selection (prefixed with plugin: by the app menu). |
location | string | Yes | Which menu to add the item to: terminal, file, view, or help. |
accelerator | string | No | Keyboard 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.
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
renderer field is deprecated as of v0.7. It still passes manifest validation so older plugins don't fail to parse, but the loader ignores it and logs Plugin "..." uses deprecated 'renderer' field. This field is no longer supported and will be ignored. Renderer-side plugin scripts are no longer supported. Move all plugin logic into the main process entry and drive the UI through IPC.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:
| Method | Description |
|---|---|
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. |
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.
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:
- Validate manifest by parsing
plugin.jsonagainst the Zod schema. Invalid manifests (including names that don't match the scopedpublisher.namepattern) cause the plugin to be skipped entirely. - Check
engines.daintreeagainst the running app version. Incompatible plugins are skipped with a user-visible error toast. Plugins without the field load with a console warning. - Warn on deprecated fields. If the manifest still sets
renderer, the loader logs a deprecation warning and ignores the field. - Resolve entry paths for
main, checking they don't escape the plugin directory. - Register panel kinds from
contributes.panelsinto the panel kind registry. - Register toolbar buttons from
contributes.toolbarButtonsinto the toolbar button registry. - Register menu items from
contributes.menuItemsinto the menu registry. - Import main entry via dynamic
import(). If this throws, contributions from steps 5 through 7 remain active. - Validate action IDs once the renderer's action registry populates. Unknown
actionIdvalues 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.onsubscriber 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
rendererfield is deprecated and ignored at load time. All plugin logic runs in the main process and drives the renderer through IPC.