docs: add plugin system guide, authoring tutorial, and manifest reference (#1774)

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update plugin docs for host data API, config, and nav additions

Cherry-picked plugin docs (d853b66b8) were written before four follow-up
PRs merged: typed capability-gated Host data API (#1753, ADR 0043),
richer frontend SDK (#1759), per-plugin settings pages (#1761), and
sidebar Integrations nav items (#1767). Re-verify against the merged
code and bring the docs current:

- plugins.md: per-plugin settings/config page, expanded Host RPC list,
  updated capability vocabulary (api_read/api_write), sidebar nav note.
- plugins-authoring.md: GetConfig/GetSecret/SetSecret/DeleteSecret and
  the six Host data readers, registerRoute options/PluginPageChrome,
  NavItem icon/integrations section, host.api.baseUrl/navigate, and the
  full host.ui component list.
- plugins-manifest.md: corrected api_read/api_write semantics (now
  enforced, not reserved), config_schema validation/secret-masking
  behavior, and new Host data API / config-schema sections.
- configuration.md, feature-status.md: document the KANDEV_FEATURES_PLUGINS
  flag alongside the existing Office flag.
- coverage.json: new plugin-system coverage area so the public-docs
  validator accounts for the three new pages.

* docs: fix plugin config route, signing claim, and data-dir layout

Address automated review findings on PR #1774:
- plugins-manifest.md: PATCH route for config is /api/plugins/{id}, not
  /api/plugins/{id}/config (only GET uses the /config suffix).
- plugins-authoring.md: host.store is not read-only — setState is
  genuinely exposed and writable; correct the misleading inline comment.
- plugins.md: signature verification is not currently wired up in
  production (pkgtar.VerifySignature is nil, never assigned outside
  tests), so every package installs and is reported as unsigned today —
  correct the 'kandev verifies it' claim in both the install pipeline
  step and the signed/unsigned section.
- plugins.md: KANDEV_PLUGIN_DATA_DIR is <id>/data, a sibling of the
  <version>/ directory (shared across versions), not nested inside it —
  fix the on-disk layout diagram.

* docs: mark plugin docs as experimental

The plugin system is feature-flagged and off by default in production
(KANDEV_FEATURES_PLUGINS). Mark all three plugin pages with
status: experimental frontmatter, matching the k8s.md convention for a
page that is wholly about an experimental feature.

* docs: add plugin UI screenshots and a capture spec

Embed four operator-facing screenshots in the plugin docs (the Plugins
list, the install dialog, a per-plugin settings page with its manifest
card, and a native plugin route). Captured from an isolated e2e backend
with the kandev-plugin-hello reference example installed.

Add e2e/tests/plugins/plugins-docs-screenshots.spec.ts to regenerate them:
it reuses the plugins e2e harness (isolated backend, plugins flag on) and
is skipped unless CAPTURE_DOCS_MEDIA=1, so it adds no CI cost. Defaults to
the repo's own e2e fixture package; override DOCS_PLUGIN_* to point at the
polished hello example (as the committed captures were taken).

* docs: fix plugin screenshot paths for the landing publisher

The Cloudflare preview build failed because markdown images under
docs/public/media/ compile to build-time module imports that the docs
site can't resolve — media/ is reserved for <DocsVideo> assets. The
landing publisher only rewrites and copies images referenced under
screenshots/ (README convention). Move the four plugin captures to
docs/screenshots/plugin-*.png and reference them as ../screenshots/*.png,
and update the capture spec to write there.

* docs: convert plugin how-it-works diagram to mermaid

The hand-drawn ASCII pipeline rendered with broken alignment and stray
syntax highlighting on the docs site. Replace it with a mermaid flowchart
(same convention as architecture.md), which renders as a clean SVG.

* docs: give plugins its own nav section

* docs: clarify plugin tools are declared/listed, not agent-invocable yet

Plugin tools[] are validated and exposed via GET /api/plugins/tools, and
the gRPC InvokeTool transport is implemented and tested, but nothing in
production calls it — no agent, MCP bridge, or HTTP route. The docs
overstated this as agent-invocable. Correct the Plugin interface comment,
the manifest tools[] rows, the how-it-works diagram, and the example
plugin description, and add a note in both the authoring and manifest
pages that InvokeTool stays unused until the agent-tool wiring ships.

* docs: clarify webhook relay in how-it-works diagram

The diagram collapsed two hops into one node, reading as if the plugin
receives the external HTTP POST directly. Split it: an external caller
POSTs to kandev's webhook route, which relays the request to the plugin
over the gRPC HandleWebhook method (the plugin serves no HTTP itself).
Also label the DeliverEvent/InvokeTool/HandleWebhook edges by their real
proto method names.

* docs: note the plugin backend is any go-plugin gRPC executable, Go SDK only

* docs: remove plugin tools from the docs

Plugin tools[] overlap with MCP (kandev's established agent-tool surface)
and the invocation path was never wired to agents. Rather than document a
half-feature that implies plugins are an alternate MCP, drop it entirely:
the Plugin interface (InvokeTool), the manifest tools[] block and field
rows, the how-it-works diagram branch, and the example-plugin mention.
Removal from the backend code is tracked separately.

* docs: replace how-it-works mermaid with a designed diagram image

The mermaid flowchart rendered poorly. Replace it with a hand-authored
HTML/CSS architecture figure (install pipeline + the runtime gRPC
interactions), rendered to docs/screenshots/plugin-architecture.png. The
diagram source lives in the docs-screenshot capture spec (skipped unless
CAPTURE_DOCS_MEDIA=1) so it regenerates deterministically at 2x.

* docs: make the how-it-works figure a real box-and-arrow diagram

The previous figure read as a list of rows. Replace it with a hand-drawn
SVG architecture diagram: the install pipeline feeds a spawned plugin
subprocess, then kandev and the plugin communicate over one gRPC
connection (DeliverEvent, HandleWebhook, and the plugin's Host API
callbacks), with the external webhook caller and the browser SPA as
edge nodes. Source lives in the capture spec's ARCH_HTML.

* docs: make the how-it-works diagram a clean monochrome flowchart

Drop the multi-color component diagram for a calm top-down flowchart
(mermaid's shape, drawn properly): install → verify → extract → spawn,
branching into event delivery and webhook relay, converging on the Host
API callbacks, with an optional native-UI step. Grayscale, no accent
colors. Source stays in the capture spec's ARCH_HTML.

* docs: dark plugin diagram + space out the two page images

- Make the how-it-works flowchart a dark rounded card (matches the docs
  page in dark mode; reads as an embedded card in light). Rendered with
  omitBackground so the corners are transparent.
- Move the Settings > Plugins list screenshot out of the intro (where it
  sat right before the diagram) down into the "Installing a plugin"
  section, so text separates the two images.

* docs: update install-dialog screenshot for aligned footer actions

Recaptured after fix(plugins): align install-dialog footer actions
(#1780) — Cancel and Install now sit together in one right-aligned
footer row instead of the Install button being stranded under the
dropzone.

* docs: account for /settings/system/storage in coverage.json

The storage settings route (added by #1699) was never recorded in the
public-docs coverage inventory — the docs validator is path-filtered to
docs/public/** so it didn't run for that PR. This docs PR is the first to
trip it. Add the route to system-health-and-maintenance.

* docs: review fixes — remove stale tools refs, tighten accuracy

Review pass against the now-merged main (tools removed in #1776):
- extending-kandev.md: drop "serves agent-invocable tools" from the plugin
  overview (the feature no longer exists).
- plugins-manifest.md: the legacy endpoints block is health/events/webhooks
  only (no tools path); and the `signed` field is always false today since
  signature verification isn't wired.
- plugins.md: install validation covers the categories/UI-surface enums (not
  a "capability vocabulary"); AutoMTLS secures the channel on all platforms,
  not just Windows; and status vs signing are separate badges.

* docs: explain what Host state is (GetState/SetState)

The authoring guide listed the state RPCs but never said what 'state'
means. Add a plain-language explanation: a per-plugin, kandev-persisted
key/value store keyed by (scope, scopeID, key) holding JSON objects,
namespaced per plugin, scoped to instance/workspace/task/agent, and how it
differs from the writable data dir. Make the example a real increment.

* docs: note NavItem section:'settings' is not rendered

A NavItem with section:'settings' is accepted by the type but no sidebar
section renders it (plugin-nav-items renders main/default, the Integrations
sections render 'integrations'); annotate that settings pages use
registerSettingsRoute. Addresses repeated review feedback.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-19 00:56:32 +01:00
committed by GitHub
parent 7e386561f2
commit 370cee8f7f
14 changed files with 1051 additions and 4 deletions
@@ -0,0 +1,154 @@
/**
* Docs media generator — NOT a CI assertion. Skipped unless
* CAPTURE_DOCS_MEDIA=1 is set, so it never runs in the normal e2e shards.
*
* When enabled, it installs a real gRPC plugin into the worker's isolated
* backend (same harness as tests/plugins/plugins.spec.ts) and screenshots
* the operator-facing surfaces straight into docs/screenshots/plugin-*.png,
* which the public plugin docs embed as ../screenshots/plugin-*.png (the
* landing publisher only rewrites/copies images under screenshots/; media/
* is reserved for <DocsVideo> assets).
*
* Regenerate (from apps/web), pointing at the polished kandev-plugin-hello
* example so the captures match the docs prose (display name "Hello Plugin",
* "Hello World" nav item, /hello-world route):
*
* CAPTURE_DOCS_MEDIA=1 \
* DOCS_PLUGIN_PACKAGE=/abs/path/to/kandev-plugin-hello-1.5.0.tar.gz \
* DOCS_PLUGIN_ID=kandev-plugin-hello \
* DOCS_PLUGIN_NAV_ID=hello-world \
* DOCS_PLUGIN_ROUTE=/hello-world \
* pnpm e2e --project=chromium --workers=1 tests/plugins/plugins-docs-screenshots.spec.ts
*
* With no DOCS_PLUGIN_* overrides it falls back to the repo's own e2e fixture
* package (apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz), so the spec is
* always self-runnable even without the external example checked out.
*/
import fs from "node:fs";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
const CAPTURE = process.env.CAPTURE_DOCS_MEDIA === "1";
const PACKAGE_PATH =
process.env.DOCS_PLUGIN_PACKAGE ??
path.resolve(__dirname, "../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz");
const PLUGIN_ID = process.env.DOCS_PLUGIN_ID ?? "kandev-plugin-e2e";
const NAV_ITEM_ID = process.env.DOCS_PLUGIN_NAV_ID ?? "e2e-hello";
const PLUGIN_ROUTE = process.env.DOCS_PLUGIN_ROUTE ?? "/plugins/e2e-hello";
const SCREENSHOTS_DIR = path.resolve(__dirname, "../../../../../docs/screenshots");
const VIEWPORT = { width: 1280, height: 860 };
// Hand-authored "How it works" flowchart (SVG), rendered to
// docs/screenshots/plugin-architecture.png (plugins.md embeds it). A clean
// dark top-down flowchart (mermaid's shape, drawn properly) that matches the
// docs page; the render uses omitBackground so the rounded card is transparent.
const ARCH_HTML = `<!doctype html><html><head><meta charset="utf-8" /><style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:transparent; }
svg { font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Arial, sans-serif; }
.card { fill:#0f172a; stroke:#1e293b; stroke-width:1; }
.node { fill:#1e293b; stroke:#334155; stroke-width:1.4; }
.node-dash { fill:#1e293b; stroke:#334155; stroke-width:1.4; stroke-dasharray:4 4; }
.title { font-size:13px; font-weight:650; fill:#e2e8f0; }
.sub { font-size:11px; fill:#94a3b8; }
.edge { stroke:#64748b; stroke-width:1.4; fill:none; }
.edge-dash { stroke:#64748b; stroke-width:1.4; fill:none; stroke-dasharray:4 4; }
.lbl { font-size:11px; fill:#cbd5e1; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; paint-order:stroke; stroke:#0f172a; stroke-width:5px; stroke-linejoin:round; }
.opt { font-size:10px; fill:#64748b; paint-order:stroke; stroke:#0f172a; stroke-width:4px; stroke-linejoin:round; }
</style></head><body>
<svg id="diagram" width="760" height="694" viewBox="0 0 760 694">
<defs><marker id="ah" markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 z" fill="#64748b"/></marker></defs>
<rect x="0.5" y="0.5" width="759" height="693" rx="16" class="card"/>
<rect x="250" y="20" width="260" height="56" rx="10" class="node"/><text x="380" y="44" text-anchor="middle" class="title">Install</text><text x="380" y="62" text-anchor="middle" class="sub">URL · upload · filesystem sync</text>
<line x1="380" y1="76" x2="380" y2="114" class="edge" marker-end="url(#ah)"/>
<rect x="250" y="116" width="260" height="56" rx="10" class="node"/><text x="380" y="140" text-anchor="middle" class="title">Verify</text><text x="380" y="158" text-anchor="middle" class="sub">checksums.txt · validate manifest.yaml</text>
<line x1="380" y1="172" x2="380" y2="210" class="edge" marker-end="url(#ah)"/>
<rect x="250" y="212" width="260" height="56" rx="10" class="node"/><text x="380" y="236" text-anchor="middle" class="title">Extract</text><text x="380" y="254" text-anchor="middle" class="sub">~/.kandev/plugins/&lt;id&gt;/&lt;version&gt;/</text>
<line x1="380" y1="268" x2="380" y2="306" class="edge" marker-end="url(#ah)"/>
<rect x="250" y="308" width="260" height="56" rx="10" class="node"/><text x="380" y="332" text-anchor="middle" class="title">Spawn subprocess</text><text x="380" y="350" text-anchor="middle" class="sub">go-plugin · gRPC over unix socket · AutoMTLS</text>
<path d="M380,364 V388 H214 V406" class="edge" marker-end="url(#ah)"/><path d="M380,364 V388 H546 V406" class="edge" marker-end="url(#ah)"/>
<text x="297" y="382" text-anchor="middle" class="lbl">DeliverEvent</text><text x="463" y="382" text-anchor="middle" class="lbl">HandleWebhook</text>
<rect x="64" y="410" width="300" height="64" rx="10" class="node"/><text x="214" y="436" text-anchor="middle" class="title">kandev delivers bus events</text><text x="214" y="455" text-anchor="middle" class="sub">at-least-once · buffered while unhealthy</text>
<rect x="396" y="410" width="300" height="64" rx="10" class="node"/><text x="546" y="436" text-anchor="middle" class="title">kandev relays a webhook</text><text x="546" y="455" text-anchor="middle" class="sub">POST/GET /api/plugins/{id}/webhooks/{key}</text>
<path d="M214,474 V496 H300 V514" class="edge" marker-end="url(#ah)"/><path d="M546,474 V496 H460 V514" class="edge" marker-end="url(#ah)"/>
<rect x="170" y="516" width="420" height="64" rx="10" class="node"/><text x="380" y="542" text-anchor="middle" class="title">Plugin calls back — Host API</text><text x="380" y="561" text-anchor="middle" class="sub">state · config · secrets · EmitEvent · read-only data</text>
<line x1="380" y1="580" x2="380" y2="616" class="edge-dash" marker-end="url(#ah)"/><text x="398" y="601" class="opt">optional</text>
<rect x="210" y="618" width="340" height="56" rx="10" class="node-dash"/><text x="380" y="642" text-anchor="middle" class="title">SPA loads the native UI bundle</text><text x="380" y="660" text-anchor="middle" class="sub">routes · nav · slots · WS handlers</text>
</svg></body></html>`;
/** Let transient success toasts auto-dismiss so they don't sit in captures. */
async function waitForToastsGone(page: Page): Promise<void> {
await page
.locator("[data-sonner-toast]")
.first()
.waitFor({ state: "detached", timeout: 8_000 })
.catch(() => undefined);
}
async function shot(page: Page, name: string): Promise<void> {
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
await waitForToastsGone(page);
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `plugin-${name}.png`) });
}
test.describe("Plugin docs screenshots", () => {
test.skip(!CAPTURE, "docs media generator — set CAPTURE_DOCS_MEDIA=1 to run");
test("captures the operator-facing plugin surfaces", async ({ testPage, apiClient }) => {
test.setTimeout(120_000);
await testPage.setViewportSize(VIEWPORT);
// --- Install dialog (upload tab), captured before we actually upload ---
await testPage.goto("/settings/plugins");
await testPage.getByTestId("install-plugin-trigger").click();
await expect(testPage.getByTestId("install-plugin-dialog")).toBeVisible();
await testPage.getByTestId("install-plugin-tab-upload").click();
await shot(testPage, "install-dialog");
// --- Install the package, land on the list with an Active row ---
await testPage.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
await testPage.getByTestId("install-plugin-upload-submit").click();
const pluginRow = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
await expect(pluginRow).toBeVisible({ timeout: 15_000 });
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible();
await expect(testPage.getByTestId("install-plugin-dialog")).toBeHidden();
await shot(testPage, "settings-list");
// --- Per-plugin settings page: config_schema form + manifest card ---
await testPage.getByTestId(`plugin-row-link-${PLUGIN_ID}`).click();
await expect(testPage).toHaveURL(new RegExp(`/settings/plugins/${PLUGIN_ID}$`));
await expect(testPage.getByTestId(`plugin-detail-${PLUGIN_ID}`)).toBeVisible();
await expect(testPage.getByTestId("plugin-manifest-card")).toBeVisible();
// Capture the pristine first-open form (schema-driven fields at their
// defaults + the manifest card) — no edits, so no unsaved-changes banner.
await shot(testPage, "settings-page");
// --- The plugin's own native route + its sidebar nav item ---
await testPage.goto("/");
await testPage.reload();
const navItem = testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`);
await expect(navItem).toBeVisible({ timeout: 15_000 });
await navItem.click();
await expect(testPage).toHaveURL(new RegExp(`${PLUGIN_ROUTE}$`));
await shot(testPage, "native-page");
// Clean up so a repeat run reinstalls from a clean slate.
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("renders the how-it-works architecture diagram", async ({ browser }) => {
const ctx = await browser.newContext({ deviceScaleFactor: 2 });
const page = await ctx.newPage();
await page.setContent(ARCH_HTML, { waitUntil: "networkidle" });
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
await page.locator("#diagram").screenshot({
path: path.join(SCREENSHOTS_DIR, "plugin-architecture.png"),
omitBackground: true,
});
await ctx.close();
});
});
+5 -2
View File
@@ -107,7 +107,7 @@ The Docker socket is effectively root-equivalent on many hosts. Do not publish i
The launcher starts `agentctl`, performs a one-time nonce handshake, and supplies the resulting per-launch token internally. Do not persist or proxy its bootstrap/auth state. Agent command, model, environment, permission, and MCP configuration belongs in agent profiles rather than this section.
### Authentication, Office, voice, and feature flags
### Authentication, Office, Plugins, voice, and feature flags
| YAML key | Environment variable | Default | Current behavior |
|---|---|---|---|
@@ -116,6 +116,7 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie
| `office.jwtSigningKey` | `KANDEV_OFFICE_JWTSIGNINGKEY` | random per start | HMAC key for Office agent-runtime JWTs. Set a stable secret when Office tasks must survive restarts. |
| `voice.openAIApiKey` | `KANDEV_VOICE_OPENAI_API_KEY` | empty | Server-side transcription fallback when browser speech recognition is unavailable. Empty disables the fallback and its endpoint returns unavailable. |
| `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | `false` in production | Extensible plugin system: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges. |
Do not infer security from `auth.jwtSecret`: setting it currently does not turn the local server into an authenticated public service. Office's JWT key has a narrower, active purpose. Store both active secrets and third-party API keys in your deployment secret manager; never commit them in `config.yaml`.
@@ -273,17 +274,19 @@ voice:
features:
office: false
plugins: false
```
Copying this entire file is unnecessary and can freeze old defaults in a deployment. Keep only deliberate overrides. On Windows, do not copy the Unix Docker host/path literals from this example.
## Runtime feature toggles
**Settings → System → Feature Toggles** manages two startup-time flags:
**Settings → System → Feature Toggles** manages three startup-time flags:
| Key | Environment lock | Production default | Effect |
|---|---|---|---|
| `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. |
| `features.plugins` | `KANDEV_FEATURES_PLUGINS` | off | Extensible plugin system: install/manage plugins and load their backends and native UI bundles. |
| `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` (also locked by explicit legacy/debug-message vars) | off | High-risk diagnostic endpoints and ACP frame logging. |
UI changes are persisted in the database and require a restart. An explicitly set environment value wins and locks the UI control. Otherwise a database override wins over the embedded profile/default. Resetting a toggle removes its database override.
+8
View File
@@ -265,6 +265,14 @@
"sources": ["apps/backend/internal/office/routes.go", "apps/web/src/office-routes.tsx"],
"tests": ["apps/backend/internal/office/onboarding/service_test.go", "apps/web/src/office-routes.test.ts"]
},
{
"id": "plugin-system",
"audiences": ["operator", "contributor"],
"stability": "experimental",
"docs": ["plugins", "plugins-authoring", "plugins-manifest"],
"sources": ["apps/backend/internal/plugins/handlers.go", "apps/backend/pkg/pluginsdk/host.go", "apps/web/lib/plugins/host-api.ts"],
"tests": ["apps/backend/internal/plugins/service_test.go", "apps/backend/internal/plugins/manifest/manifest_test.go", "apps/web/lib/plugins/host-api.test.ts"]
},
{
"id": "websocket-protocol",
"audiences": ["contributor", "integrator"],
+23 -1
View File
@@ -1,6 +1,6 @@
---
title: "Extending Kandev"
description: "Add agents, executors, integrations, workflow behavior, MCP tools, settings, or workbench surfaces at their real ownership boundaries."
description: "Add agents, executors, integrations, workflow behavior, MCP tools, plugins, settings, or workbench surfaces at their real ownership boundaries."
---
# Extending Kandev
@@ -65,6 +65,28 @@ A new relayed tool normally requires:
Inject task/session identity from server context instead of trusting arguments. Enforce task/workspace reachability, confirmation for destructive actions, pagination, concurrency behavior, and least-privilege credentials. The backend's external MCP routes currently have no Kandev user-auth middleware; deployment network controls are part of the security boundary.
## 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 and relays
external webhooks, calling back into kandev through a capability-gated Host
RPC service (state, secrets, read-only data, 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 settings, flags, or workbench UI
Create backend ownership and validation before a web form. Web settings require the appropriate domain client/hook, `src/settings-routes.tsx`, page/components, and sidebar or general navigation. Add a global state slice only when multiple surfaces or WebSocket hydration require it.
+2 -1
View File
@@ -98,8 +98,9 @@ Released builds can lag this page. Check **Settings > System > About** or `kande
| Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Execution-environment metrics depend on executor/runtime support. |
| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. Retention, free space, file permissions, and off-host copies remain operator responsibilities. |
| Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. |
| Feature Toggle settings | Supported | The page exposes Office and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. |
| Feature Toggle settings | Supported | The page exposes Office, Plugins, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. |
| Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. |
| Plugin system | In progress | Production defaults `KANDEV_FEATURES_PLUGINS=false`; development and E2E enable it for implementation/testing. Loaded plugin code runs with backend privileges; manifest schema, capabilities, and host API may change between releases. |
| Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. |
## Publication boundary
+4
View File
@@ -41,6 +41,10 @@
"extending-kandev",
"add-agent-cli",
"release-process",
"---Plugins---",
"plugins",
"plugins-authoring",
"plugins-manifest",
"---Office---",
"office-provider-routing",
"---Protocol Reference---",
+438
View File
@@ -0,0 +1,438 @@
---
title: "Authoring a Plugin"
description: "Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance."
status: experimental
---
# 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, 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.
> **Language.** The backend is any native executable that speaks the
> hashicorp/go-plugin gRPC protocol (magic-cookie handshake, AutoMTLS, the
> `kandev.plugin.v1` services) — kandev just spawns the platform binary and
> talks to it over that wire. Go is the only SDK kandev ships today, so this
> guide is Go; another language means implementing the handshake, mTLS, and
> proto yourself.
```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
// 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)
// GetConfig returns the plugin's own operator-editable config (set via
// Settings > Plugins > <plugin>, generated from manifest config_schema).
// Ungated — always readable, secret values included. Kandev restarts a
// running plugin when its config changes, so re-read at startup.
GetConfig(ctx context.Context) (map[string]any, error)
// GetSecret/SetSecret/DeleteSecret manage a plugin-owned secret in
// kandev's encrypted vault, namespaced to this plugin. Require the
// `secrets` capability.
GetSecret(ctx context.Context, key string) (value string, found bool, err error)
SetSecret(ctx context.Context, key, value string) error
DeleteSecret(ctx context.Context, key string) error
// RevealSecret resolves an operator-provided secret reference (e.g. a
// config value pointing at a shared kandev secret) to its cleartext
// value. For secrets the plugin itself owns, use GetSecret/SetSecret.
RevealSecret(ctx context.Context, ref string) (string, error)
EmitEvent(ctx context.Context, name string, payload map[string]any) error
// Tasks/Sessions/Workspaces/Workflows/AgentProfiles/Repositories return
// read-only accessors for the Host data API (ADR 0043), each gated on
// its own capabilities.api_read entry (e.g. "tasks", "sessions").
// Write RPCs (capabilities.api_write) are reserved and not yet
// implemented.
Tasks() TaskReader
Sessions() SessionReader
Workspaces() WorkspaceReader
Workflows() WorkflowReader
AgentProfiles() AgentProfileReader
Repositories() RepositoryReader
}
```
**Host state** is a small key/value store kandev keeps for your plugin in
its own database. Each entry is addressed by a `(scope, scopeID, key)`
triple and holds a JSON object (`map[string]any`): `SetState` upserts one,
`GetState` reads it back (`found` is `false` when the key was never set),
`DeleteState` removes it, and `ListState` returns every entry under a
`(scope, scopeID)`. Values are JSON objects, not bare scalars — wrap a
number as `map[string]any{"n": 3}`. State is namespaced per plugin (kandev
injects your plugin id server-side, so no plugin can read or write
another's), survives restarts, is captured by kandev's database backups, and
is deleted when the plugin is uninstalled. Reach for it when you want kandev
to durably remember something small and structured for you; use the writable
data directory below for arbitrary files you'd rather manage yourself.
`scope` partitions that store by what a value belongs to: `instance` (the
whole kandev instance; `scopeID` empty), or `workspace` / `task` / `agent`
with `scopeID` set to that entity's id. A per-task counter, for example, is
`SetState(ctx, "task", taskID, "count", map[string]any{"n": 3})` — kept
separate from every other task's.
`EmitEvent` publishes `plugin.<your-plugin-id>.<name>` on kandev's internal
event bus for delivery to any subscriber (including other plugins).
The data-reader accessors return typed, paginated readers — e.g.
`host.Tasks().List(ctx, TaskFilter{...}, Page{Limit: 50})` returns
`([]Task, *PageInfo, error)` with an opaque `PageInfo.NextCursor` for the
next page. See `pkg/pluginsdk/data_types.go` for the full `Task`,
`Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`, `Repository`,
`Session`, and filter/page types.
**Capability gating.** Every Host RPC except `GetConfig` and `EmitEvent` is
checked against your manifest's `capabilities` before the handler runs:
`GetState`/`SetState`/`DeleteState`/`ListState` require
`capabilities.state: true`; `GetSecret`/`SetSecret`/`DeleteSecret`/
`RevealSecret` require `capabilities.secrets: true`; each data-reader
accessor requires its resource in `capabilities.api_read` (e.g. `tasks`,
`sessions`, `workspaces`, `workflows`, `agent_profiles`, `repositories`).
Calling one without the declared capability returns gRPC `PermissionDenied`
with a message naming the missing capability — 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
}
// Read the current count (found == false the first time), then write it back + 1.
value, _, err := host.GetState(ctx, "instance", "", "count")
if err != nil {
return err
}
n, _ := value["n"].(float64) // JSON numbers decode as float64
return host.SetState(ctx, "instance", "", "count", map[string]any{"n": 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.
![A native plugin route rendered inside kandev: a sidebar nav item the plugin registered, the host's page title bar, and page content reading live task data from the shared app store.](../screenshots/plugin-native-page.png)
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. The host
// wraps the page in kandev's title bar by default; configure or opt out
// via options.topbar.
registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void;
// Sidebar/main nav entry, rendered by <PluginNavItems/>.
registerNavItem(item: NavItem): 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;
}
interface NavItem {
id: string;
label: string;
path: string;
// Curated icon name (see lib/plugins/icons.ts); unknown names render the puzzle glyph.
icon?: string;
// "main" (default): top-level sidebar entry. "integrations": renders inside
// the sidebar's Integrations section alongside first-party integration links.
// "settings" is accepted by the type but not rendered by any sidebar section
// today — for a settings page use registerSettingsRoute instead.
section?: "main" | "settings" | "integrations";
}
interface PluginRouteOptions {
// Kandev-style title bar above the page. Default: enabled with a derived
// title (from the matching nav item, else the plugin's display name).
// Pass a PluginPageChrome to configure it, or `false` for a full-bleed
// page that owns its own chrome (e.g. with host.ui.PageTopbar).
topbar?: boolean | PluginPageChrome;
}
interface PluginPageChrome {
title?: string;
subtitle?: string;
icon?: string; // same curated set as NavItem.icon
backHref?: string; // default "/"
backLabel?: string; // default "Kandev"
actions?: React.ComponentType; // rendered on the right side of the topbar
}
```
**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 access is the common
getState(): AppState; // case; setState is exposed but writes affect the whole SPA
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>;
// Backend API origin ("" when the SPA and API share an origin) — lets a
// plugin reach first-party kandev REST endpoints directly.
baseUrl: string;
};
ui: Record<string, unknown>; // curated @kandev/ui subset — see below
theme: "light" | "dark";
// Soft SPA navigation (history push/replace), same as the app's own router.
navigate(href: string, options?: { replace?: boolean }): void;
}
```
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 — Alert, Badge, Button, Card,
Checkbox, Dialog, DropdownMenu, Input, Label, Pagination, ScrollArea, Select,
Separator, Sheet, Skeleton, Spinner, Switch, Table, Tabs, Textarea, Tooltip
(each with their compound sub-parts, e.g. `DialogContent`, `TableRow`) — plus
first-party app UI: `Combobox` (the app's picker), `PageTopbar` (the title
bar a route gets by default via `registerRoute`'s `options.topbar`, exposed
here for routes that opt out and render their own chrome), and
`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. See `apps/web/lib/plugins/host-api.ts` for the exact current list.
## 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).
+224
View File
@@ -0,0 +1,224 @@
---
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.
## 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", "agent_profiles"] # gates the Host data-reader RPCs
api_write: ["tasks"] # reserved, no Host RPC enforces this yet
state: true
secrets: true
webhooks:
- key: "slack-events"
description: "Slack Events API webhook"
method: "POST" # informational only, not enforced
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 }
required: ["bot_token", "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[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `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[] | **Reserved for future Host RPCs.** Declared but not enforced by anything today — no Host RPC currently writes kandev's own data. |
| `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`. |
| `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 | 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 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. |
`ui.pages` is declarative manifest metadata only. A native bundle's runtime
nav items, icons, and per-route title-bar chrome (`registerNavItem`,
`registerRoute`'s `options.topbar`) are a separate JS SDK surface with no
`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):
each entry must be one of `tasks`, `sessions`, `workspaces`, `workflows`,
`agent_profiles`, `repositories`. Declaring a resource grants the matching
`Host` accessor (`Tasks()`, `Sessions()`, `Workspaces()`, `Workflows()`,
`AgentProfiles()`, `Repositories()` — see [Authoring a
plugin](plugins-authoring.md)); calling an accessor for an undeclared
resource returns gRPC `PermissionDenied`. `capabilities.api_write` reserves
the same resource names for a future write path — no write RPC exists yet,
so declaring it currently has no effect.
## 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 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.*`).
| 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).
+193
View File
@@ -0,0 +1,193 @@
---
title: "Plugins"
description: "Install and manage kandev plugins: Go backends kandev spawns and supervises, with an optional native frontend bundle."
status: experimental
---
# 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
![Plugin lifecycle: install, verify, extract, and spawn a go-plugin gRPC subprocess; then, over one supervised gRPC connection, kandev delivers bus events and relays external webhooks to the plugin, the plugin calls back into the Host API, and the SPA optionally loads the native UI bundle.](../screenshots/plugin-architecture.png)
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.
A native UI bundle can register a nav item that renders as a top-level
sidebar entry or, when it declares itself part of the Integrations section,
alongside kandev's first-party integration links in the main sidebar's
**Integrations** section — expect new entries to appear there once such a
plugin is installed and active.
## 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 Install plugin dialog with From URL and Upload file tabs and a drag-and-drop area for a .tar.gz package.](../screenshots/plugin-install-dialog.png)
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. Check for `checksums.txt.sig`. Signature verification is not currently
wired up, so every package — signed or not — installs and is reported
as unsigned today (see "Signed vs. unsigned packages" below).
3. Parse and validate `manifest.yaml` **before any code runs**: schema, `id`
pattern, the `categories` and UI-surface enums, 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.
Once installed, the plugin appears in the list with its category, a status
badge (`active`), a signing badge (`unsigned` today), and **Disable** and
**Uninstall** actions:
![The Settings > Plugins page listing an installed, active plugin with its category, an unsigned badge, and Disable/Uninstall actions.](../screenshots/plugin-settings-list.png)
## 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 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.
## Per-plugin settings
A plugin can declare `config_schema` in its manifest, generating a settings
form at **Settings > Plugins > `<plugin>`** (also `GET /api/plugins/{id}/config`
and `PATCH /api/plugins/{id}`). Fields marked `secret: true` or
`format: "password"` (for example a GitHub PAT) are never returned in
cleartext to the operator UI — reads show a masked placeholder, and
resubmitting the form unchanged leaves the stored secret alone. The plugin
process itself receives the real values via the `GetConfig` Host RPC.
Saving config **restarts the running plugin** so it re-reads its config.
`<id>.config.yml` on disk is written with mode `0600` and may hold vault
references rather than cleartext for secret fields.
![A plugin's settings page: a schema-driven form with a masked API-token field, a toggle, and a text field, above a Manifest card showing the plugin's id, version, signing status, and capabilities.](../screenshots/plugin-settings-page.png)
## Signed vs. unsigned packages
Every package's `checksums.txt` is verified at install time — this integrity
gate is always enforced. Signing (`checksums.txt.sig`, an ed25519 signature
over `checksums.txt`) is a separate, optional layer, and its verification
hook is not currently wired up in the shipped product: no signature is
cryptographically checked today, so every install — signed tarball or not —
is currently treated and reported as unsigned. A signed package installs
identically to an unsigned one; 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 — shared across versions
```
## Security posture
- **Auth is the spawn relationship.** Kandev spawns the plugin subprocess
itself over a unix domain socket (macOS/Linux) or loopback TCP (Windows) —
never a routable network address — and secures it in both cases with the
go-plugin handshake plus AutoMTLS. There is no `api_key`, `webhook_secret`,
or HMAC signing anywhere in the contract.
- **Capability-based access control.** A plugin can only call the Host RPCs
it declared in its manifest: `state` gates the state RPCs, `secrets` gates
the plugin-owned secret RPCs, and each read-only data accessor (tasks,
sessions, workspaces, workflows, agent profiles, repositories) is gated
individually via `api_read:<resource>` (write access, `api_write:<resource>`,
is reserved but not yet implemented). An undeclared capability returns gRPC
`PermissionDenied` with a message naming the missing capability, checked by
a server interceptor before the handler runs. `GetConfig` and `EmitEvent`
are the only ungated RPCs — a plugin can always read its own config
(secrets included) and emit events.
- **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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB