Skip to main content

Building Project Plugins

Build project-level Daintree plugins with the source checkout CLI or plain ESM, commit runtime output, develop with hot reload, and diagnose failures with doctor.

Reviewed

A project plugin is ready for teammates when a fresh checkout contains everything Daintree needs to load it. This guide covers creating that artifact, developing it in place, and diagnosing failures. For choosing what to build, start with Project-level Plugins.

Get the authoring tools

The five plugin packages returned public npm registry 404s when checked on September 5, 2026. Use a local Daintree checkout for the SDK, Vite preset, test helpers, and CLI. Follow Build from Source to install the app repository's prerequisites and dependencies, then run these commands in that checkout:

npm run packages:build
node packages/daintree-plugin/dist/cli.js --help

The CLI operates on the current working directory. Invoke its built file by absolute path when working elsewhere. For example, from inside the project that should own the plugin:

node /path/to/daintree/packages/daintree-plugin/dist/cli.js new dashboard --publisher acme --template view --project --yes

Replace /path/to/daintree with your actual source checkout. The documented daintree-plugin command and npx daintree-plugin forms in other material describe this same CLI; public npm cannot currently supply it.

The scaffold's package dependencies use package names. Before installing a standalone scaffold, resolve @daintreehq/plugin-sdk from packages/plugin-sdk/ and @daintreehq/plugin-vite from packages/plugin-vite/ in the built checkout, or use tarballs packed from those directories. Replace both registry dependency entries before installing; building the app packages alone does not make those names available to an unrelated repository. Keep machine-specific dependency paths out of the shared plugin's published setup instructions; explain your team's reproducible bootstrap in its README. The actual templates are the reference for the files and dependency names generated by this version.

What project scaffolding creates

--project walks upward to the nearest ancestor containing .daintree/ or .git, including the .git file used by worktrees. It refuses to guess when neither exists. The name is still the positional argument; --project is a boolean flag.

<projectRoot>/.daintree/
├── plugins/acme.dashboard/
│   ├── plugin.json
│   ├── dist/                  # commit loadable output
│   ├── src/
│   ├── package.json
│   ├── vite.config.ts
│   ├── .gitignore
│   └── README.md
└── recipes/                   # generated build-watcher recipe

Compared with an installed scaffold, it adds "scope": "project", provides a vite build --watch development script, keeps dist/ in Git, omits the archive packaging script and .dntrignore, and writes a recipe that starts the watcher. The recipe is a normal repository recipe, not a forbidden contributes.recipes declaration.

Choose command or view. The CLI refuses mcp and full with --project because both declare an MCP server that project scope cannot host. The scaffold refuses to overwrite an existing plugin directory or watcher recipe.

The CLI may find an agent's worktree as its nearest root, but Daintree loads plugins from the registered project root. Move the reviewed commit into that checkout to exercise its version. A worktree selected in the sidebar is not another discovery root.

A small plugin without a build toolchain

A small plugin can consist of plugin.json, .gitignore, and hand-written JavaScript in dist/. Use dist/index.mjs for a Node worker entry that stays ESM even in a CommonJS repository. A raw browser view can import React through Daintree's import map and use createElement; TypeScript and JSX need compilation.

For the complete four-file starting point, see the zero-build skeleton in the app repository. Read its code alongside the current SDK types: the surrounding brief still contains older notes about identity helpers, local settings, and the trust dialog. The current helpers are host.pluginInfo and host.panelKindId(); local settings exist, and trust uses a banner.

Raw views use window.electron.plugin.invoke and the matching event subscriptions. The SDK's /react hooks must be bundled into a compiled view; they are not bare modules in the host import map. Do not assume plugin:// is an HTTP API endpoint: it serves contained plugin assets.

Wire the manifest, worker, and view

PartAuthoring ruleWhy
ManifestDeclare scope: "project"; panels need id, name, iconId, and color.Discovery rejects missing required fields before loading code.
ViewMatch its id to a declared panel and default-export a React component.The matching panel supplies its name, icon, and runtime kind.
Lazy commandDeclare it in contributes.commands and register the same bare id through host.registerAction during activation.The declaration makes it discoverable before activation; the registration gives it an implementation.
Channel handlerRead (ctx, args) in a registerHandler callback.The first argument is host context; the second is the view's payload. Action handlers receive (args) instead.
Runtime identityPass the provided pluginId back to the bridge. Use host.panelKindId("overview") when opening an owned panel.Manifest names, runtime instance ids, and panel kind ids serve different purposes.
Initial dataPull when the view mounts, then listen for pushes.An activation-time broadcast can arrive before the view exists.
CleanupRegister and await setup inside activate(); return cleanup for resources you own.Activation has a five-second budget and registration methods close after it resolves.

Register subscriptions and handlers during activation, then start slow scans and polling without awaiting their completion inside that five-second window. For ongoing view updates, use host.postToPanel(channel, payload, panelId); broadcastToRenderer is only valid during activation. Match a targeted push with onPanel or usePluginPanelEvent in the view; omitting the third argument broadcasts to on or usePluginEvent subscribers. A mounted view should first invoke a channel to obtain its initial state.

For a navigation action, declare requires: [] so unrelated write or process capabilities do not elevate it to confirmation. A mutation should declare its real intent and handle host permission failures. This metadata does not sandbox your Node code.

Use an engine range representing actual compatibility. The scaffold currently emits >=0.11.0; change the minimum when you depend on newer APIs. A caret on a pre-1.0 minor expires at the next minor. The project doctor treats that pattern as an error, even though manifest validation accepts it. Neither an open-ended range nor a successful manifest validation proves runtime compatibility.

Develop in place

After resolving and installing the authoring dependencies, run npm run dev in the plugin directory, or launch its generated watcher recipe. This builds dist/ on edits. The generated npm run validate script also expects daintree-plugin on your command path; use the absolute CLI invocation shown below when you have only built the source checkout. Project plugins do not use the installed-plugin daintree-plugin dev symlink loop.

Daintree watches plugin.json and dist/. It does not watch src/, compile source, or run package scripts. After a settled burst, it reloads only the changed plugin directories, preserving the other plugins. Creating the plugins folder while the project is open is also detected.

The watcher debounces for about 200 ms, waits for Git's index lock to clear for up to 30 seconds, and briefly retries unreadable manifests before unloading a persistently invalid one. Removing a plugin directory unloads it. Every reload uses the same trust and staging rules as opening a project.

The project reload replaces the worker and view module generation. Expect React state and module variables to reset. Settings, storage, and accepted persistState updates survive. The manual Project settings → Plugins → Re-scan plugins folder action provides a recovery path if a filesystem watcher misses a change.

Prove that a clone will work

Commit the manifest, runtime output, source, and build instructions together. Include imported chunks and assets, not just the entry file. The host never installs missing runtime dependencies on a teammate's behalf.

!dist/
!dist/**

Keep both ignore negations in the plugin's .gitignore. If an ancestor ignores .daintree/ or the plugin directory itself, fix that ancestor rule too: Git cannot read a nested exception inside an excluded directory.

Run the source-built CLI from the plugin directory for schema validation, then check the whole repository from any directory:

node /path/to/daintree/packages/daintree-plugin/dist/cli.js validate
node /path/to/daintree/packages/daintree-plugin/dist/cli.js doctor /path/to/project --offline

doctor checks project-origin validation, engine advisories, declared entry files, ESM syntax and CommonJS export mistakes, Git tracking, and ignore rules. Without --offline, it also asks a running Daintree about the project's trust and plugin states. An unavailable app is reported separately from artifact checks. This is a diagnostic, not an execution test or proof that dependencies and business logic work.

For a manual Git check, use git check-ignore --no-index <path> and inspect its exit status: 0 means ignored; 1 means not ignored. Use -v only to explain a rule, since a printed negation is not evidence the file is excluded. git ls-files --error-unmatch <path> confirms tracking. A file existing locally proves neither fact.

Test handler logic against @daintreehq/plugin-testing, then exercise the built plugin in Daintree: open its command and panel, switch projects and worktrees, reload, temporarily hide its view, and reopen it. Check the task-specific result, not just whether a panel appeared. Review the generated dist/ diff before sharing.

Troubleshooting

SymptomCheck
No plugin appearsVerify the registered root, direct subdirectory, readable plugin.json, and project scope. Re-scan the folder.
Off or StagedInspect folder trust and the plugin's Run here switch. A new id needs Activate plugin.
UnreadableRead the field-specific manifest error. Common causes are a missing panel color, unsupported contribution, unknown field, and mismatched view id.
Panel appears but command failsCheck the compiled entry exists, the matching action is registered, and channel payloads use the second argument.
Edits do not appearCompare the loaded root with your editing checkout; confirm the build watcher updates dist/.
File or process call failsInspect PERMISSION_REQUIRED:, PATH_NOT_ALLOWED:, and the first-use consent prompt. Use explicit project or worktree paths where context matters.
Works only on the author's machineRun doctor; inspect ignored output, untracked assets, bare imports, and machine-specific paths.

Read doctor's checks, the watcher implementation, and the reload integration tests for exact behavior at the audited revision.