Files
kandev/docs/public/plugins-manifest.md
T
2026-08-02 21:39:02 +01:00

284 lines
22 KiB
Markdown

---
title: "Plugin Manifest Reference"
description: "Complete field-by-field reference for a kandev plugin's manifest.yaml, including the full event-subscription vocabulary."
status: experimental
---
# Plugin Manifest Reference
`manifest.yaml` is the authoritative description of a plugin — identity,
runtime executables, capabilities, webhooks, config schema, and
optional UI bundle. Kandev parses and validates it **before any plugin code
runs**. See [Authoring a plugin](plugins-authoring.md) for the build
workflow and [Plugins](plugins.md) for install/operate.
## Quick path
1. Start from the annotated example.
2. Set identity, version, runtime executable, and only the capabilities the plugin needs.
3. Add config, webhooks, events, or UI fields only when the plugin uses them.
4. Validate the manifest before packaging.
## Annotated example
```yaml
id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$
api_version: 1 # must be 1 (the only supported value)
version: "1.0.0"
display_name: "Slack Notifications"
description: "Post to Slack on task events, relay messages to agents"
author: "kandev"
categories: ["connector"] # connector | automation | tools | analytics
repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins
runtime:
type: binary # only supported value today
executables:
linux-amd64: server/plugin-linux-amd64
linux-arm64: server/plugin-linux-arm64
darwin-amd64: server/plugin-darwin-amd64
darwin-arm64: server/plugin-darwin-arm64
windows-amd64: server/plugin-windows-amd64.exe
# any subset; the host's <goos>-<goarch> key
# is required at install time
min_kandev_version: "0.78.0" # optional
capabilities:
events: ["task.created", "task.state_changed", "agent.completed"]
api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs
api_write: ["tasks"] # gates Tasks().Create/Update
state: true
secrets: true
agent_invoke: true # gates Host.InvokeUtilityAgent
auth: true # gates external (OIDC/SAML) login — see ADR 0050
webhooks:
- key: "slack-events"
description: "Slack Events API webhook"
method: "POST" # informational only, not enforced
auth_providers: # login buttons (needs capabilities.auth)
- id: "google"
display_name: "Google"
initiate: "login-start" # names a webhook key above; the button navigates there
config_schema:
type: object
properties:
bot_token: { type: string, secret: true, title: "Bot Token", description: "Slack bot OAuth token" }
default_channel: { type: string, description: "Default channel for notifications" }
notify_on_task_created: { type: boolean, default: true }
utility_agent: { type: string, format: utility-agent, title: "Utility Agent", description: "Agent used for plugin LLM calls" }
required: ["bot_token", "default_channel", "utility_agent"]
ui: # optional native frontend plugin
bundle: "/ui/bundle.js" # root-relative
styles: ["/ui/plugin.css"] # optional, root-relative
keybindings: # optional, requires ui.bundle
- id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$
default: "mod+shift+j" # combo grammar, see field reference
description: "Open the Acme panel"
```
## Field reference
> **Security:** `capabilities.auth` lets a plugin assert external login identities. Grant it only to trusted plugins whose identity provider verifies email ownership; a spoofed email claim can take over an account.
<details>
<summary>Complete field reference and validation rules</summary>
| Field | Required | Type | Notes |
|---|---|---|---|
| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. |
| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. |
| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins/<id>/<version>/`). |
| `display_name` | no | string | Shown in Settings > Plugins. |
| `description` | no | string | Shown in Settings > Plugins. |
| `author` | no | string | Free-form. |
| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. |
| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. |
| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. |
| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. |
| `runtime.executables` | required when `runtime.type: binary` | map\<string,string\> | Key is `<goos>-<goarch>` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. |
| `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. |
| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. |
| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. |
| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |
| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid enabled selection returns gRPC `FailedPrecondition`. See ADR 0048. |
| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. |
| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. |
| `webhooks[].description` | no | string | Free-form. |
| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. |
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. |
| `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). |
| `ui.pages[].title` | yes* | string | Display title. |
| `ui.pages[].path` | yes* | string | Route path for the page. |
| `ui.pages[].surface` | yes* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. |
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
| `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list — not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. |
| `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. |
| `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
`ui.pages` is declarative manifest metadata only and is not currently rendered
by the frontend. A native bundle's runtime nav items, icons, routes, named
slots, and per-route title-bar chrome (`registerNavItem`, `registerRoute`, and
`registerComponent`) are the supported JS SDK surface with no corresponding
`manifest.yaml` field — see [Authoring a plugin](plugins-authoring.md).
## Managed vs. legacy manifests
Setting `runtime.type: binary` makes a manifest **runtime-managed**: kandev
spawns and supervises the declared executable itself. A managed manifest
must **not** set `base_url` or an `endpoints` block (`health`/`events`/
`webhooks` paths on a remote service) — those describe the old
remote/operator-hosted tier, and validation rejects a managed manifest that
sets them.
A manifest with an empty `runtime.type` still *parses* as a legacy remote
manifest (with `base_url`/`endpoints` instead) and passes
`manifest.Validate()` on its own — but the **installer** rejects it: `pkgtar
.Install` requires `manifest.IsManaged()` to be true (`runtime.type:
binary`), so a legacy manifest can never actually be installed via `POST
/api/plugins/install` or a filesystem sideload. The remote tier is
effectively removed in practice, even though the manifest schema still
recognizes its shape.
## Host data API resource vocabulary
`capabilities.api_read` gates the read-only Host data accessors (ADR 0043,
ADR 0047): each entry must be one of `tasks`, `sessions`, `messages`,
`workspaces`, `workflows`, `agent_profiles`, `repositories`. Declaring a
resource grants the matching `Host` accessor (`Tasks()`, `Sessions()`,
`Messages()`, `Workspaces()`, `Workflows()`, `AgentProfiles()`,
`Repositories()` — see [Authoring a plugin](plugins-authoring.md)); calling an
accessor for an undeclared resource returns gRPC `PermissionDenied`.
`capabilities.api_write` gates the current Host data writes: `tasks` gates
`Tasks().Create`/`Update`, and `messages` gates `Messages().Send`. A plugin may
declare a read resource without its write capability, or vice versa. Calling a
write without the matching declaration returns gRPC `PermissionDenied`.
`messages` reads historical **conversation content** (`Messages().List`):
one user/agent message per row (`id`, `session_id`, `task_id`, `turn_id`,
`author_type`, `content`, `type`, `created_at`), filterable by session ids,
task ids, a `created_at` time range (`since` inclusive / `until` exclusive,
RFC3339), and message `types`. Content is sanitized — kandev-injected
`<kandev-system>` blocks are stripped, exactly like the `message.added` bus
event, so raw system prompts are never exposed. `author_type` is `user` or
`agent` (there is no `system` author).
## Config schema validation and secret fields
`config_schema` is not an arbitrary, purely descriptive JSON Schema — kandev
validates submitted config against a specific subset of it before
persisting:
- `required` (an array of property names) is enforced — a `PATCH` missing a
required property is rejected.
- `type` (`string`, `boolean`, `number`, or `integer`) is checked against
the submitted value.
- `enum` membership is checked when present.
- A string property with `format: utility-agent` is rendered as a picker of
configured built-in and custom utility agents. The UI displays agent names
but persists the selected agent's stable ID. Add the property to `required`
when the plugin must always have a selection; optional fields include a
**Not set** choice.
- A property with `secret: true`, or `format: "password"`, is treated as a
**secret field** and must be `type: string` (or untyped) — a non-string
secret is rejected. Secret values are moved into kandev's encrypted vault;
`GET /api/plugins/{id}/config` returns the literal mask `"********"` in
their place, and resubmitting that mask unchanged is treated as "keep the
stored value" rather than overwriting it with the literal string.
- `title` is read by the settings-page renderer as a display label
override for the property (falling back to the property name); it has no
backend validation effect.
The plugin process itself always sees real, unmasked values (secrets
included) via the `GetConfig` Host RPC — masking only applies to the
operator-facing API/UI.
## Event subscription vocabulary
`capabilities.events` entries are bus subjects (e.g. `task.created`) or
wildcard patterns using `*` as a **single dot-segment** wildcard (e.g.
`task.*`, `agent.*`, `github.*`). A pattern segment of `*` matches exactly
one subject segment; every other segment must match literally; and the
**pattern and subject must have the same number of dot-separated segments**
to match at all. `task.*` matches `task.created` (2 segments each) but does
**not** match a three-segment subject such as `shell.output.<sessionId>`
(`shell.*` would not match it either — 2 vs. 3 segments).
Any subject kandev publishes on its internal event bus is a valid
subscription target — this is not a closed list scoped to any one feature
area. The table below groups every subject defined in
`internal/events/types.go` by domain (some are further suffixed per-session,
e.g. `shell.output.<sessionId>`, `git.event.<sessionId>` — subscribe to the
literal wildcard segment count that matches, e.g. `shell.output.*`).
A delivered event's `event_type` (`pluginsdk.Event.EventType`) is the
**concrete subject it was published on**, i.e. the same string your pattern
matched — so a plugin subscribed to `shell.output.*` reads the session id off
`event_type` as its last segment. For an unsuffixed subject that string is
just the subject itself (`task.created`).
| Domain | Events |
|---|---|
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` |
| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` |
| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` |
| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` |
| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` |
| Task sessions | `task_session.state_changed` |
| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` |
| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` |
| Session turns | `turn.started`, `turn.completed` |
| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` |
| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` |
| Users | `user.settings.updated` |
| System | `system.job.update` |
| Environments | `environment.created`, `environment.updated`, `environment.deleted` |
| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` |
| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` |
| Agent stream | `agent.stream` (per-session: `agent.stream.<sessionId>`), `agent.turn.message_saved` |
| Agent prompts | `permission_request.received` (per-session: `permission_request.received.<sessionId>`) |
| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` |
| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) |
| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) |
| Dev server I/O | `process.output` (per-session), `process.status` (per-session) |
| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) |
| Automations | `automation.triggered`, `automation.run.created` |
| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` |
| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` |
| Jira | `jira.new_issue` |
| Linear | `linear.new_issue` |
| Sentry | `sentry.new_issue` |
| Office (autonomous agents) | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.skill.created`, `office.skill.updated`, `office.project.created`, `office.project.updated`, `office.approval.created`, `office.approval.resolved`, `office.comment.created`, `office.cost.recorded`, `office.run.queued`, `office.run.processed`, `office.run.event_appended` (per-run), `office.routine.triggered`, `office.inbox.item`, `office.task.status_changed`, `office.task.updated`, `office.task.decision_recorded`, `office.task.review_requested`, `office.provider.health_changed`, `office.route_attempt.appended`, `office.routing.settings_updated` |
| Cross-plugin | `plugin.<plugin_id>.<name>` — published by `Host.EmitEvent`; subscribe with `plugin.<other-plugin-id>.*` to react to another plugin's events. |
> Event list current as of 2026-07-16; regenerate from
> `apps/backend/internal/events/types.go` if this page drifts from the code.
## Runtime-managed fields (do not author these)
Once installed, kandev writes several fields onto the stored record
alongside the parsed manifest. These are **kandev-owned runtime state, not
author-supplied manifest fields** — do not include them in `manifest.yaml`;
they have no effect there and are overwritten on install:
| Field | Meaning |
|---|---|
| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. |
| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins/<id>/<version>/`). |
| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). |
| `installed_at` | Install timestamp. |
| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. |
Related: [Plugins](plugins.md), [Authoring a plugin](plugins-authoring.md).
</details>