docs: add plugin system guide, authoring tutorial, and manifest reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-07-16 18:10:07 +01:00
parent b969e023b6
commit d853b66b87
5 changed files with 735 additions and 1 deletions
+24 -1
View File
@@ -5,7 +5,7 @@ description: "Add an agent, executor, integration, workflow capability, MCP tool
# Extending Kandev
Kandev has extension seams for agents, executors, integrations, workflow behavior, MCP tools, and UI settings. An extension is complete only when discovery/configuration, runtime behavior, failure handling, tests, and public documentation agree.
Kandev has extension seams for agents, executors, integrations, workflow behavior, MCP tools, plugins, and UI settings. An extension is complete only when discovery/configuration, runtime behavior, failure handling, tests, and public documentation agree.
## Add an agent CLI
@@ -48,6 +48,29 @@ Task/config MCP handlers and server registration live under `internal/mcp/handle
Do not trust caller-supplied task identity when the server can inject it. Consider destructive-action confirmation, relationship reachability, pagination, and concurrent task state. Update [Automation and MCP](automation-and-mcp.md) when user capability changes.
## Build a plugin
Plugins are a peer extension mechanism to the seams above, aimed at
extensions that should ship and version independently of a kandev release.
A plugin backend is a Go binary that kandev spawns and supervises as a
subprocess, communicating over a strict typed gRPC protocol
(`internal/plugins/`, `pkg/pluginsdk`) — it receives bus events, serves
agent-invocable tools, and relays external webhooks, calling back into
kandev through a capability-gated Host RPC service (state, secrets,
cross-plugin events). A plugin may additionally ship an optional **native
frontend bundle** that the SPA loads at boot to register real routes, nav
items, slot components, and WebSocket handlers, sharing kandev's own React
instance and app store.
Plugins are distributed as a signed-or-unsigned tarball and installed by URL,
manual upload, or filesystem sideload/sync — there is no manifest-paste
registration step and no credentials to issue. The whole system sits behind
the `plugins` feature flag (Settings > System > Feature Toggles), off by
default in production. See [Plugins](plugins.md) for the operator-facing
install/operate flow, [Authoring a plugin](plugins-authoring.md) for the
build tutorial and SDK reference, and the [Plugin manifest
reference](plugins-manifest.md) for the complete `manifest.yaml` schema.
## Add a settings or workbench surface
Backend configuration should have a typed API and durable ownership before a web form is added. Web settings route through `apps/web/src/settings-routes.tsx`; task surfaces live in the relevant workbench components and state slices.
+3
View File
@@ -37,6 +37,9 @@
"web-development",
"testing",
"extending-kandev",
"plugins",
"plugins-authoring",
"plugins-manifest",
"add-agent-cli",
"release-process",
"---Protocol Reference---",
+328
View File
@@ -0,0 +1,328 @@
---
title: "Authoring a Plugin"
description: "Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance."
---
# Authoring a Plugin
This is a build tutorial for a kandev plugin: a Go backend spawned by kandev
over gRPC, with an optional native frontend bundle. See [Plugins](plugins.md)
for the operator-facing install/enable/disable flow, and the [Plugin manifest
reference](plugins-manifest.md) for the full `manifest.yaml` schema.
Two working example plugins accompany this guide:
- `kandev-plugin-hello` — a nav item, native route, sidebar slot component, a
WS-driven counter, a Host-state-backed event handler, an agent tool, and a
webhook.
- `kandev-plugin-github` — a connector PoC that shells out to the `gh` CLI
from a webhook handler used as a request relay, reads live kanban data from
the host store, and opens kandev's real create-task dialog
(`host.ui.TaskCreateDialog`) prefilled from a pull request.
## Prerequisites
- Go (matching the version in `apps/backend/go.mod`).
- The kandev plugin SDK, `github.com/kandev/kandev/pkg/pluginsdk`. It is
**not yet published as a standalone Go module** — it lives inside the
kandev monorepo (`apps/backend/pkg/pluginsdk`), so a plugin repo develops
against a local checkout via a `replace` directive:
```go
// go.mod
module my-plugin
require github.com/kandev/kandev v0.0.0-00010101000000-000000000000
replace github.com/kandev/kandev => ../kandev/apps/backend
```
Adjust the relative path to wherever you've checked out the kandev repo.
This is what both example plugins do; see their `go.mod` for the exact
pinned dependency versions.
## Backend: minimal plugin
A plugin backend implements `pluginsdk.Plugin` and calls `pluginsdk.Serve`,
which owns the entire go-plugin/gRPC transport — handshake, plugin map, and
Host injection. There is no HTTP server, no listen address, and no
credentials to configure.
```go
// main.go
package main
import "github.com/kandev/kandev/pkg/pluginsdk"
func main() {
pluginsdk.Serve(&myPlugin{})
}
```
```go
// plugin.go
package main
import (
"context"
"github.com/kandev/kandev/pkg/pluginsdk"
)
type myPlugin struct {
pluginsdk.UnimplementedPlugin // embed to get HostSetter + no-op defaults
}
var _ pluginsdk.Plugin = (*myPlugin)(nil)
```
`pluginsdk.Plugin` is the interface kandev calls into:
```go
type Plugin interface {
// OnEvent handles a single bus event delivery. A non-nil error causes
// kandev to retry (3 retries, 5s/15s/45s backoff).
OnEvent(ctx context.Context, e *Event) error
// InvokeTool handles an agent-invoked tool call declared in tools: in
// manifest.yaml.
InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error)
// HandleWebhook handles an inbound request relayed from
// POST /api/plugins/{id}/webhooks/{key}.
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
}
```
Embed `pluginsdk.UnimplementedPlugin` and override only the methods you need
— it's a no-op base that also implements `HostSetter`, so `Serve` injects a
live `Host` into your plugin once the broker connection back to kandev is
established (retrieve it later via `p.Host()`).
## The Host API
A plugin calls back into kandev through the injected `Host`:
```go
type Host interface {
GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error)
SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error
DeleteState(ctx context.Context, scope, scopeID, key string) error
ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error)
RevealSecret(ctx context.Context, ref string) (string, error)
EmitEvent(ctx context.Context, name string, payload map[string]any) error
}
```
`scope` is one of `instance`, `workspace`, `task`, `agent` (`scopeID` empty
for `instance`). `EmitEvent` publishes `plugin.<your-plugin-id>.<name>` on
kandev's internal event bus for delivery to any subscriber (including other
plugins).
**Capability gating.** Every Host RPC is checked against your manifest's
`capabilities` before the handler runs: `GetState`/`SetState`/`DeleteState`/
`ListState` require `capabilities.state: true`, `RevealSecret` requires
`capabilities.secrets: true`. Calling one without the declared capability
returns gRPC `PermissionDenied` with message `capability '<name>' not
declared` — declare what you use.
**Writable data directory.** Kandev injects `KANDEV_PLUGIN_DATA_DIR` into
every spawned plugin subprocess — a per-plugin writable directory
(`~/.kandev/plugins/<id>/data`) for anything you'd rather keep on disk than
in `Host` state.
```go
func (p *myPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error {
host := p.Host()
if host == nil {
return nil // broker dial still in progress
}
value, found, err := host.GetState(ctx, "instance", "", "count")
// ...
return host.SetState(ctx, "instance", "", "count", map[string]any{"n": 1})
}
```
## Optional: native UI
A plugin may ship `ui.bundle` in its manifest: a **hand-written, no-build**
plain-JS ES module. There is no bundler step — kandev serves the file
verbatim from the extracted package directory, so you edit the bundle and
repackage.
The single entry point: the bundle, once evaluated, calls
`window.registerKandevPlugin(id, { initialize(registry, host), destroy?() })`.
Kandev's frontend host imports the bundle on boot (or on runtime enable),
then calls `initialize(registry, host)`. On disable/uninstall it calls
`destroy?.()` and bulk-revokes every registration the plugin made.
**Registry surface** (`registry: PluginRegistry`, passed to `initialize`):
```ts
interface PluginRegistry {
// Top-level SPA route, exact-match against window.location path.
registerRoute(path: string, Component: React.ComponentType): void;
// Sidebar/main nav entry, rendered by <PluginNavItems/>.
registerNavItem(item: { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" }): void;
// Route under /settings/plugins/{id}/..., rendered inside the settings shell.
registerSettingsRoute(path: string, Component: React.ComponentType): void;
// Named slot injection. Initial slots: "task-sidebar", "settings-nav", "main-nav-footer".
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
// WS action handler, bridged into the existing lib/ws dispatch.
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
}
```
**Host API** (`host: PluginHostApi`, passed to `initialize`):
```ts
interface PluginHostApi {
pluginId: string;
React: typeof import("react"); // shared host React instance — MUST use this, never bundle your own React
jsx: typeof React.createElement; // convenience alias
store: { // kandev's live app store (read-only surface)
getState(): AppState;
setState(partial): void;
subscribe(listener): () => void;
};
api: {
// fetch scoped to /api/plugins/{id}/...; relayed to your webhook handler.
fetch(path: string, init?: RequestInit): Promise<Response>;
};
ui: Record<string, unknown>; // curated @kandev/ui subset (Button, Card, Badge, ... TaskCreateDialog)
theme: "light" | "dark";
}
```
A plugin bundle must render with `host.React` / `host.jsx` — bundling your
own React copy breaks hook identity against the host tree. `host.ui` is a
curated `@kandev/ui` subset plus one piece of first-party app UI,
`TaskCreateDialog`, so a plugin can hand off task creation to kandev's real
create-task flow (repo/branch/agent pickers, validation) instead of POSTing
directly.
## Three integration patterns
Both example repos are full, working plugins — read them rather than
copy-pasting fragments:
1. **Event handling with Host state** (`kandev-plugin-hello/server/plugin.go`).
`OnEvent` receives `task.created` deliveries (declared in
`capabilities.events`) and increments a persistent counter via
`Host.GetState`/`SetState`, scoped `instance`. Demonstrates the full
Host state round trip and idempotent-by-design event handling.
2. **Live host-store reads from native UI**
(`kandev-plugin-hello/ui/bundle.js`, `useKanbanTasks`). A plugin page
subscribes directly to `host.store` and re-renders on every store change
— reading the exact same live state the first-party kanban UI renders
from, no polling and no extra API calls.
3. **Webhook-as-relay + `TaskCreateDialog`**
(`kandev-plugin-github/server/plugin.go` and `ui/bundle.js`). The backend
declares a `prs` webhook and, on `HandleWebhook`, shells out to the `gh`
CLI (inheriting the plugin subprocess's own environment for GitHub auth)
and returns JSON. The UI page calls `host.api.fetch("webhooks/prs")` —
which kandev relays over gRPC `HandleWebhook` to the same handler — to use
an external CLI as a same-origin data source without a webhook secret.
Each PR row opens `host.ui.TaskCreateDialog` prefilled from the PR,
reading `workspaceId`/`workflowId`/`steps` from the live host store.
## Packaging
A plugin ships as `<id>-<version>.tar.gz`:
```
manifest.yaml # authoritative; read BEFORE any code runs
server/plugin-<goos>-<goarch>[.exe] # any subset of platforms declared in runtime.executables
ui/bundle.js # optional
ui/*.css / assets/icon.svg # optional
checksums.txt # generated — do not author this file
checksums.txt.sig # optional ed25519 signature
```
Cross-compile one executable per platform you declare in
`runtime.executables`, then pack the staged directory with
`github.com/kandev/kandev/cmd/plugin-pack`:
```
plugin-pack -dir <staged-dir> -out <id>-<version>.tar.gz [-platform-only]
```
`plugin-pack` walks the directory, requires `manifest.yaml` to be present,
and **generates `checksums.txt`** covering every other file — pre-supplying
either checksum file yourself is a packaging error. `-platform-only`
restricts the package to the current host's platform, for faster local
iteration than a full multi-platform build.
A sample `Makefile` (trimmed from `kandev-plugin-hello/Makefile`):
```makefile
STAGE := .build/stage
VERSION := 1.0.0
PKG_OUT := my-plugin-$(VERSION).tar.gz
package:
rm -rf $(STAGE)
mkdir -p $(STAGE)/server
cp manifest.yaml $(STAGE)/manifest.yaml
cp -r ui $(STAGE)/ui
GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server
GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server
GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server
GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server
GOOS=windows GOARCH=amd64 go build -o $(STAGE)/server/plugin-windows-amd64.exe ./server
go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT)
rm -rf $(STAGE)
# Faster local loop: host platform only.
package-host:
rm -rf $(STAGE)
mkdir -p $(STAGE)/server
cp manifest.yaml $(STAGE)/manifest.yaml
cp -r ui $(STAGE)/ui
go build -o $(STAGE)/server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE) ./server
go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only
rm -rf $(STAGE)
```
Install the resulting tarball via **Settings > Plugins** (upload) or:
```bash
curl -F "package=@my-plugin-1.0.0.tar.gz" http://localhost:38429/api/plugins/install
```
## Iterate loop
- **UI-only or backend-only change:** edit the source, repackage
(`make package-host` for a fast host-only build), then reinstall. There is
no kandev rebuild needed for plugin-only changes — the extracted package
and running subprocess are entirely separate from the kandev binary.
- **`registry`/`host.ui`/host-core changes:** these live inside kandev
itself (`apps/web/lib/plugins/`), not the plugin package — changing them
requires a kandev build (`make build-web` / `make build-backend`), not a
plugin repackage.
- **Reinstalling the same version is rejected.** `pkgtar.Install` fails with
`ErrVersionExists` (HTTP 409) if `~/.kandev/plugins/<id>/<version>/`
already exists. To pick up a rebuilt package during iteration, either bump
`version` in `manifest.yaml` or uninstall the plugin first.
## Gotchas
- **`ui.bundle` and `ui.styles` are root-relative paths**, e.g.
`/ui/bundle.js` — not `ui/bundle.js`. Kandev serves them from
`GET /api/plugins/{id}/bundle` and `GET /api/plugins/{id}/ui/*`, resolved
against the extracted package directory.
- **Plugin pages are "unknown" SPA routes, but still get the full app
shell.** `registerRoute` paths are resolved after every static/nested
first-class route and before the kanban catch-all — a plugin can't shadow
a built-in route, but its page still renders inside the normal boot-payload-hydrated
app shell (workspaces, settings, theme), not a bare unstyled page.
- **Version bump or uninstall required to reinstall** — see "Iterate loop"
above.
- **Windows executables need the `.exe` suffix** in both the built file name
and the `runtime.executables` manifest value (e.g.
`windows-amd64: server/plugin-windows-amd64.exe`) — kandev does not append
it for you.
Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md).
+196
View File
@@ -0,0 +1,196 @@
---
title: "Plugin Manifest Reference"
description: "Complete field-by-field reference for a kandev plugin's manifest.yaml, including the full event-subscription vocabulary."
---
# Plugin Manifest Reference
`manifest.yaml` is the authoritative description of a plugin — identity,
runtime executables, capabilities, tools, 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.
## 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
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", "agents"] # reserved, no Host RPC enforces this yet
api_write: ["tasks", "comments"] # reserved, no Host RPC enforces this yet
state: true
secrets: true
tools:
- name: "slack_send_message"
display_name: "Send Slack Message"
description: "Posts a message to a Slack channel"
input_schema:
type: object
properties:
channel: { type: string }
text: { type: string }
required: ["channel", "text"]
webhooks:
- key: "slack-events"
description: "Slack Events API webhook"
method: "POST" # informational only, not enforced
config_schema:
type: object
properties:
bot_token_secret: { type: string, description: "Secret reference for Slack bot token" }
default_channel: { type: string, description: "Default channel for notifications" }
notify_on_task_created: { type: boolean, default: true }
required: ["bot_token_secret", "default_channel"]
ui: # optional native frontend plugin
bundle: "/ui/bundle.js" # root-relative
styles: ["/ui/plugin.css"] # optional, root-relative
```
## Field reference
| 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. |
| `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[] | **Reserved for future Host RPCs.** Declaring values today has no effect — no Host RPC reads kandev's own data (tasks, agents, comments) yet. |
| `capabilities.api_write` | no | string[] | **Reserved for future Host RPCs.** Same status as `api_read` — not enforced by anything today. |
| `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`. Calling it without this set to `true` returns gRPC `PermissionDenied`. |
| `tools[].name` | yes | string | Must be unique within the manifest — a duplicate tool name is a validation error. |
| `tools[].display_name` | no | string | Shown to operators/agents. |
| `tools[].description` | no | string | Shown to operators/agents. |
| `tools[].input_schema` | no | object | Arbitrary JSON Schema describing the tool's input. |
| `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. |
| `config_schema` | no | object | Arbitrary JSON Schema for the operator-editable config exposed via `PATCH /api/plugins/{id}`. |
| `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 page metadata. Secondary to `ui.bundle` — a native bundle registers its own routes/nav 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 | Where the page mounts. Enum, one of: `settings` · `task-panel` · `main-nav`. Any other value is a validation error. |
## 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`/
`tools`/`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.
## 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.*`).
| 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 installed package included a verified `checksums.txt.sig`. |
| `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).
+184
View File
@@ -0,0 +1,184 @@
---
title: "Plugins"
description: "Install and manage kandev plugins: Go backends kandev spawns and supervises, with an optional native frontend bundle."
---
# Plugins
Plugins extend kandev without forking core: a plugin ships a **Go backend**
that kandev spawns and supervises as a subprocess over a strict typed gRPC
protocol, and can optionally ship a **native frontend bundle** that kandev
loads into the SPA. This page covers what plugins are, how to install and
manage them, and the current security posture. For building a plugin, see
[Authoring a plugin](plugins-authoring.md). For the manifest schema, see
[Plugin manifest reference](plugins-manifest.md).
Plugins are an operator-level, instance-wide capability — there is no
per-user plugin access. They are gated behind the `plugins` feature flag
(**Settings > System > Feature Toggles**), off by default in production
builds and on by default in dev/e2e builds. Enabling the flag requires a
backend restart and surfaces **Settings > Plugins** in the sidebar.
## How it works
```
install (URL / upload / filesystem sync)
│
▼
verify checksums.txt, validate manifest.yaml
│
▼
extract to ~/.kandev/plugins/<id>/<version>/
│
▼
spawn platform executable as a subprocess (hashicorp/go-plugin)
│
┌───────────────┼────────────────────────┐
▼ ▼ ▼
gRPC DeliverEvent gRPC InvokeTool gRPC HandleWebhook
(bus events, at- (agent tool calls) (external webhook
least-once, relayed via
buffered while POST /api/plugins/
unhealthy) {id}/webhooks/{key})
│ │ │
└───────────────┴────────────┬────────────┘
▼
plugin calls back over the
same connection: Host.GetState /
SetState / DeleteState / ListState /
RevealSecret / EmitEvent
│
▼
(optional) SPA loads ui.bundle at boot,
registers native routes/nav/slots/WS handlers
```
Kandev owns the whole process lifecycle: it extracts the package, spawns the
binary, completes the go-plugin handshake, health-checks it (`Ping` every
30s), and restarts it on crash or repeated health-check failure (backoff,
max 5 attempts). There is no separate operator-managed plugin process to run
or babysit — install a package and kandev does the rest.
## Installing a plugin
Open **Settings > Plugins** and click **Install plugin**. You can install
from a URL (kandev downloads the tarball) or by uploading a `.tar.gz` file
directly. No credentials are ever shown or copied — installing a plugin has
nothing to reveal, unlike a webhook-secret/API-key registration flow.
The same operations are available over HTTP:
```bash
# Install from a URL
curl -X POST http://localhost:38429/api/plugins/install \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.com/acme-tools-1.0.0.tar.gz"}'
# Install by uploading a local tarball
curl -X POST http://localhost:38429/api/plugins/install \
-F "package=@acme-tools-1.0.0.tar.gz"
```
Either path runs the same pipeline:
1. Verify `checksums.txt` covers every other file in the tarball and every
hash matches (always enforced).
2. If `checksums.txt.sig` is present, verify the signature; if absent,
install proceeds with a surfaced "unsigned plugin" warning.
3. Parse and validate `manifest.yaml` **before any code runs**: schema, `id`
pattern, capability vocabulary, and that `runtime.executables` contains
an entry for the host's OS/arch.
4. Extract to `~/.kandev/plugins/<id>/<version>/` and record the
installation.
5. Spawn the platform-matched binary and complete the go-plugin handshake.
Status is `registered` while this is pending, `active` once the
handshake succeeds, or `error` if spawn/handshake fails (the operator can
retry via **Enable**).
A successful install that failed to spawn returns HTTP 201 with a
`warning` field rather than failing outright — the package is installed,
just not yet running.
## Filesystem sideload and Sync
Besides install-by-URL/upload, an operator with shell access to the host can
place plugin content directly under `~/.kandev/plugins/` without going
through the install endpoint. The **Sync** button in Settings > Plugins (and
`POST /api/plugins/sync`) reconciles kandev's registry with what is actually
on disk:
- **A dropped directory** — `~/.kandev/plugins/<id>/<version>/manifest.yaml`
placed manually with no existing record — is validated and registered
with status **`disabled`**, never auto-enabled. Directory sideloads skip
the checksum/integrity gate the URL/upload pipeline runs, so an operator
must explicitly inspect and enable one. If more than one version
directory exists for the same unregistered id, the lexically greatest
version is registered and the others are reported as skipped.
- **A dropped tarball** — any `*.tar.gz` sitting directly in
`~/.kandev/plugins/` — is run through the same verified install pipeline
`POST /api/plugins/install` uses. On success the tarball file is deleted;
on failure it is left in place and the failure is reported.
- **A missing install** — a registered record whose `install_path` no
longer exists on disk — is stopped (if running) and marked `error`.
At boot, kandev runs only the directory-sideload and missing-install steps
(never the tarball-install step), as part of resuming plugins that were
already active. This is conservative by design: starting up never spawns a
binary an operator hasn't explicitly approved via install or Sync.
## Enable, disable, uninstall
- **Disable** stops the subprocess. Config and state are preserved; no
events, tools, or webhooks are delivered while disabled.
- **Enable** respawns the subprocess and re-completes the handshake.
- **Uninstall** stops the subprocess and deletes the plugin's package,
registration record, and all persisted state — there is no grace period.
## Signed vs. unsigned packages
Every package's `checksums.txt` is verified at install time — this integrity
gate is always enforced. Signing is a separate, optional layer: if a package
includes a `checksums.txt.sig` (an ed25519 signature over `checksums.txt`),
kandev verifies it and marks the plugin signed. An unsigned package still
installs, with a surfaced warning — signing is not required in v1.
## On-disk layout
```
~/.kandev/plugins/
├── <id>.yml # registration record (status, install_path, signed, ...)
├── <id>.config.yml # operator-editable config (PATCH /api/plugins/{id})
└── <id>/
└── <version>/ # extracted package (InstallPath)
├── manifest.yaml
├── server/plugin-<goos>-<goarch>[.exe]
├── ui/bundle.js # optional
└── data/ # KANDEV_PLUGIN_DATA_DIR for this plugin
```
## Security posture
- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess
itself over a unix domain socket (macOS/Linux) or loopback TCP with
AutoMTLS (Windows) — never a routable network address. There is no
`api_key`, `webhook_secret`, or HMAC signing anywhere in the contract; the
go-plugin handshake plus AutoMTLS authenticate the channel.
- **Capability-based access control.** A plugin can only call the Host RPCs
it declared in its manifest (`state`, `secrets`); an undeclared capability
returns gRPC `PermissionDenied` with a message naming the missing
capability, checked by a server interceptor before the handler runs.
- **Native UI plugins run in-origin with full app-store access.** This is an
accepted tradeoff, not an oversight: a plugin bundle shares the kandev
React instance and Zustand store so it can build UI indistinguishable from
first-party pages. In v1 only **active, operator-installed** plugins load;
a failing bundle or `initialize` is caught and never breaks boot; slot
components render behind error boundaries. Hard sandboxing (a worker or
realm boundary) is explicit future work — see below.
- **Package integrity is always checked; signing is optional.** See
"Signed vs. unsigned packages" above.
- **No plugin marketplace.** Install-by-URL/upload is a manual, single-plugin
action; there is no central catalog, one-click install, or automatic
discovery.
Related: [Authoring a plugin](plugins-authoring.md), [Plugin manifest
reference](plugins-manifest.md), and [Extending Kandev](extending-kandev.md).