Files
kandev/docs/public/plugins-authoring.md
T
João Salavisa 370cee8f7f 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>
2026-07-19 00:56:32 +01:00

19 KiB

title, description, status
title description status
Authoring a Plugin Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance. 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 for the operator-facing install/enable/disable flow, and the Plugin manifest reference 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.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.

// main.go
package main

import "github.com/kandev/kandev/pkg/pluginsdk"

func main() {
	pluginsdk.Serve(&myPlugin{})
}
// 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:

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:

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.

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.

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):

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):

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):

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:

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, Plugin manifest reference.