Plugin System
Extend Canopy with custom panels, toolbar buttons, menu items, and IPC channels using the experimental plugin system.
Overview
Canopy'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 Canopy. The system is designed for developers who want to build custom tooling on top of Canopy's workspace.
Installation
Plugins live in ~/.canopy/plugins/. Each plugin gets its own subdirectory containing at minimum a plugin.json manifest file:
~/.canopy/plugins/
└── my-plugin/
├── plugin.json
├── main.js # optional main process entry
└── renderer.js # optional renderer entry To install a plugin, create its directory under ~/.canopy/plugins/ and add the manifest. Then quit and relaunch Canopy. If the ~/.canopy/plugins/ directory doesn't exist, Canopy 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": "my-plugin",
"version": "1.0.0",
"displayName": "My Plugin",
"description": "A sample plugin demonstrating all contribution types.",
"main": "main.js",
"renderer": "renderer.js",
"contributes": {
"panels": [
{
"id": "viewer",
"name": "Custom Viewer",
"iconId": "eye",
"color": "#8B5CF6"
}
],
"toolbarButtons": [
{
"id": "open-viewer",
"label": "Open Viewer",
"iconId": "eye",
"actionId": "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 | Unique plugin identifier. Must match /^[a-zA-Z0-9._-]+$/, max 64 characters. Used as the namespace prefix for all contributed IDs. |
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. |
main | string | No | Relative path to a Node.js entry file that runs in the Electron main process. |
renderer | string | No | Relative path to a renderer-side script. Not auto-injected; see Renderer Entry. |
contributes | object | No | Container for all contribution arrays (panels, toolbarButtons, menuItems). Defaults to empty. |
Panels
Panel contributions register new panel kinds that appear in the panel palette (Cmd+N). Each contributed panel becomes a selectable type alongside Canopy's built-in panels. The final panel kind ID is namespaced as {pluginName}.{id}, so a plugin named my-plugin with a panel id: "viewer" registers as 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 Canopy, see Terminals & Panels.
Toolbar Buttons
Toolbar button contributions add buttons to Canopy'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}.
| 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 Canopy'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 Canopy's plugin runtime — do not import it
registerPluginHandler('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('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
The renderer field points to a script intended to run in Canopy's renderer process. Unlike the main entry, renderer scripts are not auto-injected. Instead, the resolved path is available through the plugin API, and the script must be loaded manually.
The typical bootstrap pattern is to call window.api.plugin.list(), find your plugin's entry, and dynamically import it:
// Bootstrap pattern for loading a plugin's renderer entry
const plugins = await window.api.plugin.list();
const self = plugins.find(p => p.manifest.name === 'my-plugin');
if (self?.resolvedRenderer) {
await import(self.resolvedRenderer);
} 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, directory path, and resolved renderer 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
Canopy'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 Canopy'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.
Security
Canopy validates that main and renderer paths don't escape the plugin's directory. Paths are resolved with path.resolve and checked against the plugin directory prefix. If a path escapes, the entry is silently dropped but the plugin's other contributions still load. The plugin name field is also validated against a safe character pattern to prevent path traversal through the namespace.
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 Canopy starts, the plugin loader processes each subdirectory in ~/.canopy/plugins/ through these steps in order:
- Validate manifest by parsing
plugin.jsonagainst the Zod schema. Invalid manifests cause the plugin to be skipped entirely. - Resolve entry paths for
mainandrenderer, 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 3 through 5 remain active.
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.
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 Canopy.
- 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
~/.canopy/plugins/. - No disable/unload. The only way to disable a plugin is to remove 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. - Renderer scripts not auto-injected. The
rendererfield provides a path, but the script must be loaded manually through the plugin API.