docs: add plugin system guide, authoring tutorial, and manifest reference
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"web-development",
|
||||
"testing",
|
||||
"extending-kandev",
|
||||
"plugins",
|
||||
"plugins-authoring",
|
||||
"plugins-manifest",
|
||||
"add-agent-cli",
|
||||
"release-process",
|
||||
"---Protocol Reference---",
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user