Contribution Points
Every slot a Daintree plugin can fill: commands, keybindings, menus, context menus, toolbar buttons, panels, views, settings, MCP servers, skills, agents, process tools, forge providers, and file decorations. Each comes with its schema and an example.
A contribution point is a slot in Daintree that a plugin can fill. All of them are declared in the contributes object of plugin.json. There are fourteen.
Daintree reads contributes eagerly at startup, so everything on this page shows up in the palette, the menus, and the toolbar before any plugin code runs. Your entry module is imported later, the first time one of these contributions is actually used. Every bare id below is namespaced at runtime as {pluginId}.{id}.
Filtering needs JavaScript. The contents list works without it, and so does your browser's find-in-page — every contribution point on this page is in the document.
Commands
Commands are callable actions. They appear in the command palette and can be bound to keys, toolbar buttons, menu items, and context menus.
"commands": [
{
"id": "plan-from-issue",
"title": "Plan From Issue",
"description": "Turn a Linear issue into a branch and agent session.",
"category": "Linear Planner",
"kind": "command",
"danger": "confirm"
}
] | Field | Required | Notes |
|---|---|---|
id | yes | Bare command id, up to 64 characters. |
title | yes | Palette entry label. |
description | yes | One-line summary shown under the title in the palette. |
category | yes | Grouping label. Free-form; mirror your display name. |
kind | yes | "command" or "query". |
danger | yes | "safe" or "confirm". "restricted" is rejected: a plugin cannot self-register a restricted action. |
keywords | no | Extra palette search terms. |
inputSchema | no | JSON schema validated against the dispatched args. |
requires | no | The capabilities this command uses. See below. |
requires: per-action capability intent
By default the host derives a command's effective danger from your plugin's entire capabilities list. Declare shell:exec for one command and every other command in the plugin inherits the confirmation dialog, including a no-argument "open the panel". Dropping the capability is not an honest fix. requires is.
{
"id": "open-panel",
"title": "Open Panel",
"description": "Opens the tools panel.",
"category": "Flutter Tools",
"kind": "command",
"danger": "safe",
"requires": []
} - Omit it and nothing changes: the whole manifest is consulted, as before. Existing plugins need no migration.
[]declares that the command exercises no capability, so it stays one click even in a plugin holdingshell:exec.["git:read"]consults only those capabilities, for both the high-risk set and the compound lattice.
Three things it does not do. It grants no access: host APIs still gate on the manifest's capabilities at call time, so listing one here neither adds nor removes runtime authority. It cannot lower a self-declared "danger": "confirm". And every entry must also appear in capabilities; naming one you did not declare fails the command's registration outright rather than falling back, so a typo surfaces at load instead of quietly reverting.
Binding a handler
Two styles, and the choice is not stylistic: it decides whether your handler can reach the host API.
Filesystem convention. A command with id plan-from-issue looks for src/plan-from-issue.js or src/plan-from-issue.mjs, probed in that order, under the plugin directory. Its default export is the handler. The module is not imported until the command is first dispatched, so twenty declared commands cost nothing at activation.
// src/plan-from-issue.js
export default async function planFromIssue(args) {
// handler body
} args only: there is no host argument, so it cannot call host.showQuickPick, host.settings.get, or anything else on the host. This is structural: the host is scoped to activate() and revoked when activation returns, long before a command is first dispatched. The handler must also ship as JavaScript: .ts and .tsx files are not probed. Compile to src/{id}.js.Imperative registration is the escape hatch for dynamic commands, and the only way to reach host APIs from a handler, because the handler closes over the live host:
export async function activate(host) {
await host.registerAction(
{
id: "plan-from-issue",
title: "Plan From Issue",
description: "Turn a Linear issue into a branch and agent session.",
category: "Linear Planner",
kind: "command",
danger: "confirm"
},
async (args) => {
// handler body, with `host` in scope
}
);
} An imperative registration for the same id supersedes the convention file, so you can start with a manifest-declared stub and graduate the moment you need host access. A declared command with neither a matching file nor an imperative override produces a visible toast when run: Command "{pluginId}.{id}" has no handler. That is deliberate: the manifest entry alone is enough to make the command appear so you can wire it up incrementally.
A command whose resolved id collides with a built-in Daintree action is rejected at load and does not register. Pick a different id. See Host API for the full registerAction signature.
Keybindings
"keybindings": [
{
"actionId": "acme.linear-planner.plan-from-issue",
"combo": "Cmd+Shift+P",
"scope": "global",
"when": "!terminalFocused && !modalOpen"
}
] | Field | Required | Notes |
|---|---|---|
actionId | yes | Fully qualified, usually one your plugin declared. |
combo | yes | Same normalized format as Daintree's own bindings. Chords such as "Cmd+K Cmd+S" work. |
scope | no | "global" (default), "portal", "worktreeGrid", or "dev-preview". Unknown scopes are rejected. The former terminal, modal, and worktreeList scopes were removed. Use a when condition instead. |
description | no | What the binding does. |
when | no | Context expression, evaluated live on each keydown. |
The when grammar
Expressions support &&, ||, !, ==, !=, and single-quoted string literals. There are seven context keys:
| Key | Type | Meaning |
|---|---|---|
terminalFocused | boolean | Keyboard focus is inside a terminal. |
modalOpen | boolean | A modal dialog is open. |
paletteOpen | boolean | Any palette is open. |
paletteId | string | Identifier of the open palette, or "" when none. |
fleetArmed | boolean | At least one terminal is armed for fleet broadcast. |
fleetWaiting | boolean | At least one armed terminal's agent is waiting. |
sidebarVisible | boolean | The worktree sidebar is visible. |
!modalOpne is always true and your binding fires everywhere. Spell-check the identifiers; nothing else will.Bindings register when the plugin loads and unregister on unload. Plugin bindings are low priority and yield to user overrides and to conflicting bindings resolved by Daintree's keybinding service. See Keyboard Shortcuts for the built-in map.
Menu items
Entries in Daintree's application menus.
"menuItems": [
{
"label": "Plan from Linear…",
"actionId": "acme.linear-planner.plan-from-issue",
"location": "view",
"accelerator": "Cmd+Shift+L"
}
] | Field | Required | Notes |
|---|---|---|
label | yes | Menu entry label. |
actionId | yes | Fully qualified action to dispatch. |
location | yes | "terminal", "file", "view", or "help". |
accelerator | no | Platform-neutral shortcut: "Cmd+Shift+L" becomes Ctrl+Shift+L on Windows and Linux. |
when | no | Evaluated once at menu build time against an empty context, so only constant expressions are useful. For a live condition, gate a keybinding instead. |
Context menus
"contextMenus": [
{
"actionId": "acme.linear-planner.link-issue",
"location": "worktree",
"label": "Link to Linear issue…"
}
] Locations are worktree, terminal, and file. Items are appended below Daintree's own entries.
The file location is the interesting one: it is mounted on every file row Daintree renders: the Review Hub's changed-file rows, the worktree card's changed-files list, the File Browser's tree and listing, and the Diff Viewer's file sidebar. One contribution reaches all four with no per-surface work, and the action is dispatched with the clicked file's context: { path, worktreePath, status }. path is always absolute; worktreePath and status are each omitted when the row has no worktree root or no git status, so an unchanged file in the File Browser arrives as { path } alone.
Two built-in actions pair well here. file.openDiff opens the side-by-side diff for the dispatched file, and panel.openPluginPanel spawns or focuses one of your panels, passing initialArgs straight through to the view, so a context-menu item can open your panel already scoped to the file the user clicked.
Toolbar buttons
"toolbarButtons": [
{
"id": "plan-button",
"label": "Plan",
"iconId": "list",
"actionId": "acme.linear-planner.plan-from-issue",
"priority": 3
}
] | Field | Required | Notes |
|---|---|---|
id | yes | Bare id, namespaced at runtime. |
label | yes | Hover tooltip. |
iconId | yes | One of the shared plugin icon IDs below. Agent brand ids do not resolve here. |
actionId | yes | Fully qualified. Built-in actions such as terminal.new work too. |
priority | no | 1–5, lower sorts earlier within your plugin's tray group. Default 3. |
The plugin tray
Contributed buttons do not each claim a top-level toolbar slot. They collect into a single plugin tray button, grouped by owning plugin. From the tray a user promotes an individual button to its own slot (hover the row and click the pin, press P, or use Settings > Toolbar), and a promoted button keeps its tray row as well.
This is deliberate. Placement is the user's call, not the manifest's: there is no field that requests a top-level slot, so five installed plugins cannot between them consume the entire toolbar. See UI Layout for the toolbar itself.
Panels
A panel is a full-sized workspace in Daintree's grid, alongside terminals, viewers, and the Review Hub. The panel entry declares the slot; the view below provides the component that fills it.
"panels": [
{
"id": "dashboard",
"name": "Cost Dashboard",
"iconId": "gauge",
"color": "hsl(150 60% 55%)",
"showInPalette": true
}
] | Field | Required | Notes |
|---|---|---|
id | yes | Bare id, namespaced at runtime as the panel kind. |
name | yes | Label in the panel header and palette. |
iconId | yes | A shared plugin icon ID, or a built-in agent id to render that agent's brand mark. |
color | yes | Accent color for the panel tab. |
hasPty | no | Defaults to false. true is reserved and not available to plugins: a PTY-backed kind renders through the terminal pane and cannot host a plugin module. |
dockable | no | Dockable unless you declare false. Declaring false alongside hasPty: true is a manifest error, because the opt-out could never be honored. |
canRestart | no | Show a restart control in the panel header. |
canConvert | no | Allow conversion between compatible kinds. Rarely useful for a plugin. |
showInPalette | no | Include in the new-panel palette. Default true. |
Icon IDs
One shared set backs every surface that renders a plugin icon (the panel palette, panel headers, tabs, the dock, toolbar buttons, and the toolbar overflow), so an id looks the same everywhere:
terminal, package, puzzle, globe, monitor, monitor-play, file-text, file-diff, folder-tree, git-branch, git-pull-request, sticky-note, gauge, list, sparkles, layout-panel-top, daintree
An unrecognized id falls back to a generic glyph rather than failing the load, so a manifest written against a newer host still works. daintree-plugin validate warns about ids your installed host does not know.
Live panel-title badges
A plugin can put a badge in its panel's title chrome at runtime with host.setPanelBadge(panelId, badge), and clear it by passing null. Two shapes:
await host.setPanelBadge(panelId, { kind: "dot", color: "warning", tooltip: "3 checks failing" });
await host.setPanelBadge(panelId, { kind: "label", text: "12", color: "success" }); color is default, success, warning, or error; tooltip is optional and capped at 200 characters. A label whose text exceeds the length cap is rejected rather than truncated, so it can never overflow the header. This is a live push, not a manifest field: it is how a long-running build or review-status plugin reports state without opening its panel.
Views
A view is the React component that renders inside a panel. It binds to a panel slot by matching its bare id; at load, that panel kind gains a componentPath resolved to a plugin:// URL, and the renderer host lazy-imports the module under an error boundary.
"panels": [
{ "id": "dashboard", "name": "Cost Dashboard", "iconId": "gauge", "color": "#5b8def" }
],
"views": [
{ "id": "dashboard", "componentPath": "./dist/dashboard.js", "location": "panel" }
] | Field | Required | Notes |
|---|---|---|
id | yes | Must match a panels entry's id. A view with no matching panel is a manifest error: it could never be shown. |
componentPath | yes | POSIX-relative path to a pre-built ESM module inside the plugin, whose default export is a React component. Absolute paths, URL schemes, and .. segments are rejected. |
location | yes | "panel". "sidebar" is rejected at validation: the sidebar host does not exist yet, so accepting it would validate a view the runtime cannot render. |
iconId | no | Accepted for compatibility but ignored at runtime. The matching panel owns the rendered icon. |
Views ship as pre-built ESM. Nothing compiles TypeScript or JSX at load time. The view schema carries no name or description: the panel is the single source of truth for display metadata. The full component contract (the props, the React hooks, and how module generations defeat the renderer's ESM cache) is on Host API.
Temporary unmount is not teardown
This is the distinction that most often bites. A panel outlives its views, and a view receives two abort signals that mean different things:
disposeSignalis the lifetime of this mounted view attempt. It aborts on unmount, on "Try again" after a render error, and when the host drops the panel kind. Crucially it also aborts on a temporary unmount: maximizing a sibling pane, leaving a dock tab, or backgrounding a project view.panelRemovedSignalis the lifetime of the panel record. The same object is handed to every mount of a given panel, so it survives remounts, retries, trash-then-restore, and plugin upgrades. It aborts exactly once, when the panel is permanently removed.
Deciding deletion from disposeSignal alone is how a plugin ends up killing a running dev-server session because someone maximized the pane next door. Tie view-scoped work (in-flight fetches, DOM observers, timers driving the UI) to disposeSignal. Tie anything that should survive being backgrounded but not survive the panel to panelRemovedSignal. Keep genuinely durable resources such as spawned processes in the plugin worker and release them from host.onDidChangePanelLifecycle on the "removed" phase, because the worker observes the panel across every remount and the view cannot.
Settings schema
Declares user-configurable settings. Daintree generates the form; you read values back through the host API.
"settings": [
{
"id": "linear.apiToken",
"type": "secret",
"scope": "user",
"label": "Linear API Token",
"description": "Personal API token from linear.app/settings/api"
},
{
"id": "linear.defaultTeam",
"type": "string",
"scope": "project",
"label": "Default team",
"default": ""
}
] | Field | Required | Notes |
|---|---|---|
id | yes | Limited to letters, digits, ., -, and _, so it can always be referenced by a ${settings:id} token. |
type | no | One of nine (see below). Defaults to string. |
label / description | no | Field label and help text. |
default | no | Default value. |
scope | no | "user" (global) or "project" (per project). Defaults to "user". |
options | no | Non-empty string array. Required when type is enum. |
min / max | no | Bounds for number. min may not exceed max. |
mustExist | no | For the path types: flag a stored path that no longer resolves. Advisory: it never blocks saving. |
extensions | no | For file only, no leading dot: ["json", "md"]. Rejected on any other type. |
secret | no | Legacy boolean. true normalizes to type: "secret". Prefer the type. |
The nine types are string, number, boolean, enum, json, secret, path, directory, and file.
secretis encrypted at rest through the OS keychain when one is available, transparently to the plugin, and the form discloses which storage tier is in use. See Trust & Capabilities for the caveats.pathanddirectoryrender a read-only input plus a native folder chooser;fileopens a single-file chooser narrowed byextensions. The stored value is an absolute path, read back like any other setting.
Settings appear on the plugin's Settings tab in the Plugin Manager, and changes fire a subscription callback, so a plugin never needs to reactivate to pick up an edit.
const token = await host.settings.get("linear.apiToken"); MCP servers
Model Context Protocol servers the plugin ships. Their tools become available to any agent running in Daintree through the same surface user-configured servers use. See MCP Server.
"mcpServers": [
{
"id": "linear",
"name": "Linear MCP",
"command": "node",
"args": ["./dist/mcp/linear-server.js"],
"env": { "LINEAR_API_KEY": "${settings:linear.apiToken}" }
}
] command is an executable: node, python, npx, or an absolute path. args and env are optional. Values in all three may reference a declared setting with ${settings:id}; an id naming no declared setting is a manifest error, not a runtime surprise. Transport is stdio only: remote transports, explicit transport types, per-server working directories, and restart policies are all deliberately absent.
Daintree supervises the process. It is lazily spawned on first tool use, hard-killed when Daintree exits, and on an unexpected crash it transitions to crashed and rejects tool calls until you restart it by hand: there is no automatic retry or backoff. Discovery is two-tier: a cheap tool list first, full schemas fetched on demand. Tool calls run through a consent, permission, and audit subsystem, with the reachable danger tier capped against the plugin's declared capabilities.
Changing a user-scope setting automatically restarts every running server that references it, debounced by about a second so a burst of edits coalesces into one respawn. Servers that were never started stay stopped: a settings change never eagerly boots one.
Skills
Markdown instruction or knowledge files that extend Daintree's built-in MCP server. Agents discover and load them through its skills.search and skills.load tools.
"skills": [
{
"id": "tdd-workflow",
"name": "TDD Workflow",
"path": "./skills/tdd-workflow.md",
"triggers": ["test-driven", "tdd", "red-green-refactor"]
}
] path is a relative path inside the plugin, realpath-contained at read time. triggers is an optional array of up to 50 search terms the agent uses to find the skill. The file's content is returned verbatim when an agent loads it. Skills are inert declarative content and require no capability.
Agents
Teaches Daintree about a launchable agent CLI it does not ship, so it appears as a named, selectable agent rather than a generic shell. Requires the agent:register capability, which the manifest gate enforces.
"capabilities": ["agent:register"],
"contributes": {
"agents": [
{
"id": "acme",
"name": "Acme Agent",
"command": "acme",
"args": ["--interactive"],
"color": "#3366ff",
"iconId": "claude",
"supportsContextInjection": true
}
]
} | Field | Required | Notes |
|---|---|---|
id | yes | Additive for new ids only. Colliding with a built-in agent is a manifest error, and built-ins always shadow plugin entries. Cross-plugin conflicts resolve first-registered-wins. |
name | yes | Display label. |
command | yes | Binary to launch. No shell metacharacters. Supports ${settings:id}. |
args | no | Up to 20 default launch arguments. Also supports ${settings:id}. |
color | yes | Brand color as a 6-digit hex. |
iconId | yes | A different namespace from panel and toolbar icons. It must name a built-in agent id such as claude or codex, because agents render bundled brand marks. A panel icon id like terminal does not resolve; unrecognized values fall back to the Claude mark. Shipping a custom icon asset is not supported. |
supportsContextInjection | no | Whether copy-tree context injection targets this agent. Default false. |
${settings:id} tokens in command and args resolve at spawn time against the plugin's user-scope setting of that id; project scope is never read. If the setting is unset the launch fails with a clear error rather than spawning the agent with a literal token or a silently blanked value, so a missing credential surfaces as a spawn error instead of an opaque auth failure inside the agent. Unlike MCP server tokens, these are not validated at parse time.
detection
Without a detection block a plugin agent runs as a plain named terminal whose working and waiting state Daintree does not track. Declare one and it joins the same agent-state machinery as a built-in. The block is strict, so a typo'd field is a loud manifest error rather than a silently ignored tuning knob.
| Field | Required | Notes |
|---|---|---|
primaryPatterns | yes | Non-empty array of regex strings, each of which must compile, marking the agent as working. A detection block without it is rejected. |
fallbackPatterns, bootCompletePatterns, promptPatterns, promptHintPatterns, completionPatterns | no | Regex arrays for the corresponding detection tiers. |
scanLineCount, promptScanLineCount | no | Line-window bounds for the matcher, 1–1000. |
debounceMs, promptFastPathMinQuietMs | no | Timings in milliseconds, 0–600000. |
primaryConfidence, fallbackConfidence, promptConfidence, completionConfidence | no | Confidence weights in the range 0 to 1 for a matched tier. |
titleStatePatterns | no | { working, waiting } string arrays matched against the terminal title, up to 50 entries each. |
Process tools
Teaches Daintree to recognize a CLI running inside a terminal pane, so the tab shows your icon instead of the generic terminal glyph. Detection normally runs off a fixed built-in list (npm, Vite, Docker and friends), and this is how a plugin that ships or wraps its own CLI gets the same treatment. Inert declarative data; no capability required.
"processTools": [
{ "command": "acme-cli", "iconId": "sparkles" },
{ "command": "acmec", "iconId": "sparkles" }
] command is a bare executable name, lowercase, starting with a letter or digit and otherwise limited to letters, digits, ., -, and _. Omit the extension: write acme, not acme.exe or acme.py, because detection strips launcher and script suffixes before matching, so a suffixed form would never fire and is rejected. Lowercase is enforced rather than normalized for the same reason.
Entries are additive for new commands only. Colliding with a built-in tool command or a built-in agent CLI is a manifest error, as is declaring the same command twice in one manifest. So are shells and launcher wrappers (sh, bash, sudo, env, xargs, timeout and the rest), because they name the process that runs a tool, so sudo vite would report your plugin instead of Vite. The package-manager exec subcommands exec, dlx, and x are rejected for the same reason. A collision with another plugin resolves first-registered-wins with a warning.
One entry per alias, each pointing at the same iconId. Plugin detections rank at the same tier as named built-in tools, so npm exec acme-cli reports your CLI rather than npm. Detections register at plugin load and are mirrored into the pty-host process where detection actually runs, surviving a pty-host restart; unloading or disabling the plugin removes them, and a terminal already running the command keeps its icon until the next detection pass reclassifies it.
Forge providers
Registers a forge backend (issues, pull or merge requests, reviews, CI roll-up, releases, and auth) for a platform that sits on top of git. See Code Forge.
parseRemote and URL builders are synchronous and cannot cross the async MessagePort that user-installed plugins run behind, so registerForgeProvider is a no-op out of process. The manifest entry validates and the descriptor registers, but the implementation never binds. Daintree's own GitHub plugin works because built-ins activate in-process."forgeProviders": [
{
"id": "gitea",
"name": "Gitea",
"matches": ["gitea.io", "gitea.example.com"],
"capabilities": ["issues", "pulls", "required-checks"]
}
] | Field | Required | Notes |
|---|---|---|
id | yes | Must match the descriptor id passed at runtime. |
name | yes | Display label in the forge settings. |
matches | yes | Exact hostnames. The host extracts the hostname from the project's git remote (HTTPS, SSH, and SCP-form URLs all handled), lowercases it, and matches for exact string equality. No globs, no suffix matching. List every hostname your forge serves. First match wins. |
capabilities | no | Informational hints driving the "supports…" display only. Behavior gates on the runtime implementation, not on this list. |
credentialFields | no | { id, label, type, placeholder?, helpText? } entries driving the generated credential form. |
settingsScopeRef | no | A declared setting id used to group provider settings. A dangling reference is a manifest error. |
viewRefs | no | Ids of views shown under this provider's section. Each must resolve to a declared view. |
The manifest entry is read eagerly so the provider populates the settings UI and the remote-routing table before any plugin code runs; the implementation binds lazily in activate().
File decorations
Registers a provider that decorates file rows with status badges, colors, and tooltips. The manifest declares which scopes the provider answers for, so the renderer can route decoration pulls before the plugin's code has run.
"fileDecorationProviders": [
{ "id": "worktree-diff-review", "scopes": ["worktree-diff:*"] }
] id must match the id passed when the provider binds at runtime; scopes is a non-empty list of patterns. There are two host-routed scopes, and keeping them distinct is the point:
| Scope | Surface | Typical use |
|---|---|---|
worktree-diff:<worktreePath> | Review Hub changed-file rows: base-branch diff and linked PR. | Badging PR review state, as the built-in GitHub plugin does. |
worktree-files:<worktreePath> | The local file-change list on the worktree card. | Local-only signals: lint status, leaked secrets, format drift. No PR or remote required. |
A provider registered for worktree-diff:* is not invoked on worktree-files:* and vice versa, so PR-review badges never leak onto the plain change list. The paths the host passes, and that your provider keys its returned map by, are the changed-file paths as the surface renders them.
Two providers declaring the same exact scope string merge first-writer-wins per field in load order, so the second one's badge for a shared path can be dropped; the host logs a warning naming both plugins so the collision is detectable. A broad provider coexisting with a narrow one (worktree-files:* alongside worktree-files:/some/path) is intentional and not warned about.
A provider that takes longer than 3000 ms to answer a pull is skipped and logged, so one slow provider cannot stall the file list. From your subscriptions and timers, call host.invalidateFileDecorations(scope, paths?) to tell any renderer showing that scope to re-pull.
What is deliberately not a contribution point
- Agent provider SDKs. Registering a launchable CLI is a contribution point; a full model-provider SDK is not. Pointing Daintree at a different OpenAI-compatible base URL covers what nearly everyone actually needs.
- Agent lifecycle hooks. Ship an MCP server instead: it can refuse or annotate tool calls, which is simpler than a dedicated hook API and reuses an ecosystem that already exists.
- Subagents. Daintree spawns fresh agents natively. Compose them with skills and MCP rather than a subagent contribution.
- Status bar items, tree views, editor decorations. Daintree is not an editor; these surfaces do not map onto what it renders.
- Themes. A
themescontribution is designed but not shipped: it needs a theme registry surface that does not exist yet. See Themes.