Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 KiB
title, description
| title | description |
|---|---|
| Authoring a Plugin | Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance. |
Authoring a Plugin
This is a build tutorial for a kandev plugin: a Go backend spawned by kandev
over gRPC, with an optional native frontend bundle. See Plugins
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, an agent tool, and a webhook.kandev-plugin-github— a connector PoC that shells out to theghCLI 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 areplacedirective:// go.mod module my-plugin require github.com/kandev/kandev v0.0.0-00010101000000-000000000000 replace github.com/kandev/kandev => ../kandev/apps/backendAdjust the relative path to wherever you've checked out the kandev repo. This is what both example plugins do; see their
go.modfor 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.
// 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
// InvokeTool handles an agent-invoked tool call declared in tools: in
// manifest.yaml.
InvokeTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error)
// HandleWebhook handles an inbound request relayed from
// POST /api/plugins/{id}/webhooks/{key}.
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
}
Embed pluginsdk.UnimplementedPlugin and override only the methods you need
— it's a no-op base that also implements HostSetter, so Serve injects a
live Host into your plugin once the broker connection back to kandev is
established (retrieve it later via p.Host()).
The Host API
A plugin calls back into kandev through the injected Host:
type Host interface {
GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error)
SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error
DeleteState(ctx context.Context, scope, scopeID, key string) error
ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error)
RevealSecret(ctx context.Context, ref string) (string, error)
EmitEvent(ctx context.Context, name string, payload map[string]any) error
}
scope is one of instance, workspace, task, agent (scopeID empty
for instance). EmitEvent publishes plugin.<your-plugin-id>.<name> on
kandev's internal event bus for delivery to any subscriber (including other
plugins).
Capability gating. Every Host RPC is checked against your manifest's
capabilities before the handler runs: GetState/SetState/DeleteState/
ListState require capabilities.state: true, RevealSecret requires
capabilities.secrets: true. Calling one without the declared capability
returns gRPC PermissionDenied with message capability '<name>' not declared — declare what you use.
Writable data directory. Kandev injects KANDEV_PLUGIN_DATA_DIR into
every spawned plugin subprocess — a per-plugin writable directory
(~/.kandev/plugins/<id>/data) for anything you'd rather keep on disk than
in Host state.
func (p *myPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error {
host := p.Host()
if host == nil {
return nil // broker dial still in progress
}
value, found, err := host.GetState(ctx, "instance", "", "count")
// ...
return host.SetState(ctx, "instance", "", "count", map[string]any{"n": 1})
}
Optional: native UI
A plugin may ship ui.bundle in its manifest: a hand-written, no-build
plain-JS ES module. There is no bundler step — kandev serves the file
verbatim from the extracted package directory, so you edit the bundle and
repackage.
The single entry point: the bundle, once evaluated, calls
window.registerKandevPlugin(id, { initialize(registry, host), destroy?() }).
Kandev's frontend host imports the bundle on boot (or on runtime enable),
then calls initialize(registry, host). On disable/uninstall it calls
destroy?.() and bulk-revokes every registration the plugin made.
Registry surface (registry: PluginRegistry, passed to initialize):
interface PluginRegistry {
// Top-level SPA route, exact-match against window.location path.
registerRoute(path: string, Component: React.ComponentType): void;
// Sidebar/main nav entry, rendered by <PluginNavItems/>.
registerNavItem(item: { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" }): void;
// Route under /settings/plugins/{id}/..., rendered inside the settings shell.
registerSettingsRoute(path: string, Component: React.ComponentType): void;
// Named slot injection. Initial slots: "task-sidebar", "settings-nav", "main-nav-footer".
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
// WS action handler, bridged into the existing lib/ws dispatch.
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
}
Host API (host: PluginHostApi, passed to initialize):
interface PluginHostApi {
pluginId: string;
React: typeof import("react"); // shared host React instance — MUST use this, never bundle your own React
jsx: typeof React.createElement; // convenience alias
store: { // kandev's live app store (read-only surface)
getState(): AppState;
setState(partial): void;
subscribe(listener): () => void;
};
api: {
// fetch scoped to /api/plugins/{id}/...; relayed to your webhook handler.
fetch(path: string, init?: RequestInit): Promise<Response>;
};
ui: Record<string, unknown>; // curated @kandev/ui subset (Button, Card, Badge, ... TaskCreateDialog)
theme: "light" | "dark";
}
A plugin bundle must render with host.React / host.jsx — bundling your
own React copy breaks hook identity against the host tree. host.ui is a
curated @kandev/ui subset plus one piece of first-party app UI,
TaskCreateDialog, so a plugin can hand off task creation to kandev's real
create-task flow (repo/branch/agent pickers, validation) instead of POSTing
directly.
Three integration patterns
Both example repos are full, working plugins — read them rather than copy-pasting fragments:
-
Event handling with Host state (
kandev-plugin-hello/server/plugin.go).OnEventreceivestask.createddeliveries (declared incapabilities.events) and increments a persistent counter viaHost.GetState/SetState, scopedinstance. Demonstrates the full Host state round trip and idempotent-by-design event handling. -
Live host-store reads from native UI (
kandev-plugin-hello/ui/bundle.js,useKanbanTasks). A plugin page subscribes directly tohost.storeand 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. -
Webhook-as-relay +
TaskCreateDialog(kandev-plugin-github/server/plugin.goandui/bundle.js). The backend declares aprswebhook and, onHandleWebhook, shells out to theghCLI (inheriting the plugin subprocess's own environment for GitHub auth) and returns JSON. The UI page callshost.api.fetch("webhooks/prs")— which kandev relays over gRPCHandleWebhookto the same handler — to use an external CLI as a same-origin data source without a webhook secret. Each PR row openshost.ui.TaskCreateDialogprefilled from the PR, readingworkspaceId/workflowId/stepsfrom 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-hostfor 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.Installfails withErrVersionExists(HTTP 409) if~/.kandev/plugins/<id>/<version>/already exists. To pick up a rebuilt package during iteration, either bumpversioninmanifest.yamlor uninstall the plugin first.
Gotchas
ui.bundleandui.stylesare root-relative paths, e.g./ui/bundle.js— notui/bundle.js. Kandev serves them fromGET /api/plugins/{id}/bundleandGET /api/plugins/{id}/ui/*, resolved against the extracted package directory.- Plugin pages are "unknown" SPA routes, but still get the full app
shell.
registerRoutepaths 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
.exesuffix in both the built file name and theruntime.executablesmanifest value (e.g.windows-amd64: server/plugin-windows-amd64.exe) — kandev does not append it for you.
Related: Plugins, Plugin manifest reference.