863 lines
57 KiB
Markdown
863 lines
57 KiB
Markdown
---
|
|
title: "Authoring a Plugin"
|
|
description: "Discover the current Kandev plugin contract, choose a recipe, build a package, and smoke-test it safely."
|
|
status: experimental
|
|
---
|
|
|
|
# Authoring a Plugin
|
|
|
|
This is the canonical developer entry point for Kandev runtime plugins. Use it
|
|
to choose a supported surface, edit manifest.yaml, implement the backend or
|
|
native UI, package it, and test it against a disposable development instance.
|
|
The [plugin manifest reference](plugins-manifest.md) owns field-by-field schema
|
|
details; [Plugins](plugins.md) covers operator installation and lifecycle.
|
|
|
|
The official scaffold is
|
|
[kdlbs/kandev-plugin-template](https://github.com/kdlbs/kandev-plugin-template).
|
|
Production plugins live in their own repositories. The in-tree fixture is test
|
|
support, not a starter repository.
|
|
|
|
## Quick workflow
|
|
|
|
1. Choose the closest recipe in this page.
|
|
2. Copy the template and edit manifest.yaml: identity, runtime executable, UI
|
|
paths, capabilities, events, webhooks, and config.
|
|
3. Implement only through the Go pluginsdk and the frontend registry/Host API.
|
|
4. Run the plugin repository tests, vet/lint, and build.
|
|
5. Stage the package, run plugin-pack, inspect the archive and generated
|
|
checksums, then install it in a disposable Kandev instance.
|
|
6. Smoke-test every declared hook and capability, then bump the version for
|
|
the next iteration.
|
|
|
|
## Lifecycle
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
A[Package install] --> B[Manifest and package validation]
|
|
B --> C[Safe extraction]
|
|
C --> D[Runtime subprocess]
|
|
D <-->|Host gRPC over go-plugin| E[Go plugin backend]
|
|
C --> F[Frontend bundle and styles]
|
|
F --> G[Browser bundle loading]
|
|
G --> H[registerKandevPlugin initialize]
|
|
```
|
|
|
|
Installation validates the manifest, archive paths, checksums, managed runtime,
|
|
and the current host executable before extraction. Kandev then supervises the
|
|
declared subprocess and injects a Host connection. The UI bundle is static
|
|
package content loaded by the browser; it does not run inside the backend
|
|
subprocess. On disable or uninstall, the host calls destroy when present and
|
|
bulk-revokes registrations, styles, routes, handlers, and navigation. Reloads and
|
|
updates are generation-safe: a slow or failed initialization is not treated as a
|
|
definitive revocation, while a successfully ready generation can remove panels it
|
|
no longer registers.
|
|
|
|
## Package and data layout
|
|
|
|
```text
|
|
<id>-<version>.tar.gz
|
|
├── manifest.yaml # required, validated first
|
|
├── server/
|
|
│ └── plugin-<goos>-<goarch>[.exe] # one or more declared binaries
|
|
├── ui/
|
|
│ ├── bundle.js # optional native ES module
|
|
│ ├── plugin.css # optional styles
|
|
│ └── assets/ # optional files referenced by UI
|
|
├── assets/ # optional manifest icon/assets
|
|
├── checksums.txt # generated by plugin-pack
|
|
└── checksums.txt.sig # optional signature
|
|
```
|
|
|
|
runtime.executables maps a platform key such as linux-amd64 to a clean
|
|
package-relative path such as server/plugin-linux-amd64. Windows values must
|
|
end in .exe. The host platform's key must be present at install time. A
|
|
UI-focused plugin still ships a managed backend executable because the current
|
|
installer requires runtime.type: binary; that executable can be a no-op
|
|
pluginsdk.UnimplementedPlugin server.
|
|
|
|
Kandev injects KANDEV_PLUGIN_DATA_DIR into the subprocess. It is the durable,
|
|
per-plugin directory for arbitrary files or a plugin-owned database:
|
|
|
|
```text
|
|
~/.kandev/plugins/<id>/data/
|
|
```
|
|
|
|
The directory is shared across installed versions, survives restarts and
|
|
upgrades, and is removed on uninstall. Keep small structured values in Host
|
|
state instead, and never write arbitrary files elsewhere in the Kandev data
|
|
directory.
|
|
|
|
## Choose a plugin shape
|
|
|
|
| Shape | Package contents | Typical contract | Minimal capability set |
|
|
| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend |
|
|
| UI-focused plugin | no-op managed binary, ui/bundle.js, optional styles/assets | routes, nav, named slots, WebSocket handlers, keybindings, shared store | ui.bundle; add ui.keybindings when declaring shortcuts |
|
|
| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege |
|
|
|
|
There is no separate HTTP server to launch. pluginsdk.Serve owns the
|
|
go-plugin/gRPC handshake and Host injection. The backend implements
|
|
pluginsdk.Plugin (OnEvent and/or HandleWebhook) and embeds
|
|
pluginsdk.UnimplementedPlugin for no-op defaults.
|
|
|
|
## Security and capabilities
|
|
|
|
Plugins are privileged installed code. The capability list gates Host RPCs; it
|
|
is not an operating-system sandbox. A backend subprocess inherits the Kandev
|
|
process environment, and a UI bundle runs as same-origin JavaScript with the
|
|
curated React, UI, and app-store surface.
|
|
|
|
- Plugins do not receive direct database access. Use typed Host readers/writers,
|
|
Host state, secrets, events, and the data directory. Do not import Kandev
|
|
internal/... packages or call undocumented REST endpoints.
|
|
- state gates Host state; secrets gates RevealSecret and plugin-owned secret
|
|
methods; each api_read resource gates its reader; api_write gates
|
|
task/message mutations; agent_invoke gates utility-agent calls; and
|
|
capabilities.events controls event delivery.
|
|
- GetConfig and EmitEvent are ungated. GetConfig returns this plugin's own
|
|
config, including cleartext secret fields, so do not log or commit it.
|
|
- Declared webhook keys default to public. Set `webhooks[].access: authenticated`
|
|
for browser UI and billable operations. Kandev does not enforce
|
|
`webhooks[].method`; public integrations must still validate the provider
|
|
signature and replay/timestamp rules before side effects.
|
|
- capabilities.auth is the highest-risk capability. A webhook response may
|
|
assert a verified external identity with X-Kandev-Auth-Login; only assert an
|
|
email the IdP verified as owned by the subject. See [ADR 0050](../decisions/0050-plugin-external-auth-capability.md).
|
|
|
|
## Storage decision table
|
|
|
|
| Need | Use | Scope/lifecycle | Capability or rule |
|
|
| ----------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars |
|
|
| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId |
|
|
| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess |
|
|
| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values |
|
|
| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations |
|
|
|
|
Host state is not host.storage: Host state is plugin-scoped backend state with
|
|
no transaction primitive, so design updates to be idempotent; host.storage
|
|
(below) is per-user, per-scope frontend storage with an optional
|
|
ifUnmodifiedSince compare-and-swap.
|
|
|
|
## Authoritative contracts
|
|
|
|
The guide summarizes the contract for discovery. These files remain the source
|
|
of truth and must be updated together when the contract changes:
|
|
|
|
- Frontend contract pair: docs/plans/plugins/PLUGIN-API.md and apps/web/lib/plugins/types.ts.
|
|
- Concrete frontend Host exports: apps/web/lib/plugins/host-api.ts.
|
|
- Backend author API: apps/backend/pkg/pluginsdk.
|
|
- Wire contract: apps/backend/proto/kandev/plugin/v1/plugin.proto.
|
|
- Manifest model and semantic validation: apps/backend/internal/plugins/manifest.
|
|
- Package integrity and installation: apps/backend/internal/plugins/pkgtar.
|
|
- Durable decisions: [ADR 0043](../decisions/0043-plugin-host-data-api.md), [ADR 0047](../decisions/0047-plugin-host-conversation-reads.md), [ADR 0048](../decisions/0048-plugin-host-utility-agent-invoke.md), and [ADR 0050](../decisions/0050-plugin-external-auth-capability.md).
|
|
|
|
## Frontend contract
|
|
|
|
### Loading and cleanup
|
|
|
|
The package's ui.bundle is a hand-written ES module. When active, Kandev loads
|
|
it and the bundle calls:
|
|
|
|
```js
|
|
window.registerKandevPlugin("my-plugin", {
|
|
initialize(registry, host) {},
|
|
destroy() {},
|
|
});
|
|
```
|
|
|
|
initialize receives a registry scoped to this plugin and a PluginHostApi. The
|
|
host may call initialize more than once across disable/enable cycles. Make it
|
|
repeatable; return a cleanup function from subscriptions and timers, and use
|
|
destroy to remove side effects that are not registrations. The host revokes all
|
|
registry entries, closes plugin modals, and removes plugin styles on
|
|
disable/uninstall. Reloads can temporarily leave a plugin in a loading or failed
|
|
state; open and saved panels remain until the current generation is ready. A
|
|
ready generation that no longer registers a panel closes it, while an explicit
|
|
disable or uninstall closes every panel owned by the plugin. This lifecycle
|
|
bookkeeping is host-internal; plugins do not call lifecycle methods.
|
|
|
|
### Frontend hook/API matrix
|
|
|
|
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
|
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
|
|
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
|
|
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
|
|
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
|
|
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
|
|
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
|
|
| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) |
|
|
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) |
|
|
| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) |
|
|
| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx |
|
|
| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) |
|
|
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Declare `webhooks[].access: authenticated` for UI-only or billable operations; abort requests on destroy | host.api.fetch("webhooks/inbound", { method: "POST" }) |
|
|
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) |
|
|
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
|
|
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
|
|
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
|
|
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
|
|
|
|
### Supported named slots
|
|
|
|
registerComponent currently has these mounted slots. The source type is open
|
|
to strings, but an unmounted name renders nowhere.
|
|
|
|
| Slot | Mounted location | slotProps |
|
|
| ------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
| task-sidebar | Bottom of task-detail sidebar | none |
|
|
| settings-nav | Settings navigation tree | none |
|
|
| chat-input-actions | Task or Quick Chat composer toolbar | PluginComposerSlotProps |
|
|
| task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps |
|
|
| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps |
|
|
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
|
|
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
|
|
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
|
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
|
|
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
|
|
| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } |
|
|
| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } |
|
|
|
|
AppStatusBarSlotProps is { placement, presentation, density, pathname,
|
|
activeWorkspaceId, activeTaskId, activeSessionId }. Desktop presentation is a
|
|
compact 24px bar; mobile presentation is an in-flow drawer, so render a
|
|
touch-usable row. Status items can be reordered by the host; plugins do not get
|
|
an ordering API.
|
|
|
|
`PluginComposerSlotProps` is `{ surface, presentation, taskId, taskTitle?,
|
|
activeSessionId, sessionIds, disabled, submittable, disabledReason?, composer }`.
|
|
The `composer` capability provides `insertText`, `focus`, and asynchronous
|
|
`submit`; submit returns `submitted` only when the native composer accepts the
|
|
operation, otherwise `blocked` or `unavailable`.
|
|
|
|
## Backend contract
|
|
|
|
### Plugin lifecycle surface
|
|
|
|
| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example |
|
|
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
| Plugin.OnEvent | OnEvent(ctx, \*pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } |
|
|
| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } |
|
|
| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.<id>.<name> | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) |
|
|
|
|
Event delivery is best-effort: Kandev makes the initial attempt plus retries
|
|
with 5s, 15s, and 45s delays, and a bounded in-memory queue/error buffer can
|
|
drop events during sustained overload or restart. Use EventID for deduplication
|
|
and a source-of-truth reconciliation path when loss matters. The full
|
|
subscription vocabulary and wildcard rules are in the
|
|
[manifest reference](plugins-manifest.md#event-subscription-vocabulary).
|
|
|
|
### Host API matrix
|
|
|
|
| Host surface | Methods | Required manifest capability | Notes |
|
|
| -------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions |
|
|
| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins |
|
|
| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values |
|
|
| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor |
|
|
| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire |
|
|
| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records |
|
|
| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces |
|
|
| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id |
|
|
| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API |
|
|
| Repositories | Repositories().List | api_read: repositories | List by workspace id |
|
|
| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped |
|
|
| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin:<id> author |
|
|
| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition |
|
|
|
|
The Go signatures, filters, DTOs, and pagination types live in
|
|
apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message
|
|
methods are implemented in the current branch; do not repeat older docs that
|
|
call them reserved.
|
|
|
|
Host calls are request-scoped rather than registrations: pass the handler
|
|
context, handle cancellation, and close any files or external clients created
|
|
by the plugin. There is no Host cleanup callback. Typical calls are
|
|
`host.SetState(ctx, "task", taskID, "note", value)`,
|
|
`host.Tasks().List(ctx, filter, page)`,
|
|
`host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{...})`,
|
|
`host.Messages().Send(ctx, taskID, sessionID, text)`, and
|
|
`host.InvokeUtilityAgent(ctx, prompt)`; each fails with `PermissionDenied`
|
|
when its manifest capability is absent.
|
|
|
|
## Task-oriented recipes
|
|
|
|
The snippets below are deliberately small. Keep the full template Makefile and
|
|
release workflow from the official scaffold.
|
|
|
|
The maintained in-tree fixture shows the same surfaces end to end: its
|
|
[Go backend](../../apps/backend/cmd/plugin-fixture/plugin.go),
|
|
[manifest](../../apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml),
|
|
and [native bundle](../../apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js)
|
|
are the source examples used by the package and browser tests.
|
|
|
|
### 1. UI-focused plugin
|
|
|
|
Use a no-op backend because the managed installer requires a host executable.
|
|
|
|
```yaml
|
|
# manifest.yaml
|
|
id: "acme-dashboard"
|
|
api_version: 1
|
|
version: "0.1.0"
|
|
display_name: "Acme Dashboard"
|
|
runtime:
|
|
type: binary
|
|
executables:
|
|
linux-amd64: server/plugin-linux-amd64
|
|
ui:
|
|
bundle: "/ui/bundle.js"
|
|
```
|
|
|
|
```go
|
|
// server/main.go
|
|
package main
|
|
|
|
import "github.com/kandev/kandev/pkg/pluginsdk"
|
|
|
|
func main() { pluginsdk.Serve(&pluginsdk.UnimplementedPlugin{}) }
|
|
```
|
|
|
|
```js
|
|
// ui/bundle.js
|
|
window.registerKandevPlugin("acme-dashboard", {
|
|
initialize(registry, host) {
|
|
function Page() {
|
|
return host.jsx("main", null, "Acme dashboard");
|
|
}
|
|
registry.registerRoute("/acme-dashboard", Page);
|
|
registry.registerNavItem({
|
|
id: "home",
|
|
label: "Acme",
|
|
path: "/acme-dashboard",
|
|
icon: "chart",
|
|
});
|
|
},
|
|
});
|
|
```
|
|
|
|
### 2. Go backend with Host state
|
|
|
|
Declare state, embed the no-op base, and read the Host at handling time because
|
|
the broker connection may not exist during construction.
|
|
|
|
```yaml
|
|
capabilities:
|
|
events: ["task.created", "task.state_changed"]
|
|
state: true
|
|
```
|
|
|
|
```go
|
|
import (
|
|
"context"
|
|
"errors"
|
|
)
|
|
|
|
type plugin struct{ pluginsdk.UnimplementedPlugin }
|
|
|
|
func (p *plugin) OnEvent(ctx context.Context, event *pluginsdk.Event) error {
|
|
if event == nil || event.EventID == "" { return errors.New("event ID is required") }
|
|
host := p.Host()
|
|
if host == nil { return errors.New("plugin host unavailable") }
|
|
value, found, err := host.GetState(ctx, "instance", "", "events")
|
|
if err != nil { return err }
|
|
if !found { value = map[string]any{} }
|
|
processed, _ := value["processed_event_ids"].(map[string]any)
|
|
if processed == nil { processed = map[string]any{} }
|
|
if _, seen := processed[event.EventID]; seen { return nil }
|
|
count := 0.0
|
|
if found { count, _ = value["count"].(float64) }
|
|
processed[event.EventID] = true
|
|
value["processed_event_ids"] = processed
|
|
value["count"] = count + 1
|
|
return host.SetState(ctx, "instance", "", "events", value)
|
|
}
|
|
```
|
|
|
|
The durable EventID claims make retries no-ops. Delivery is sequential per
|
|
plugin, while Host state itself has no transaction primitive; keep the claim
|
|
and counter in one state update and bound or prune old claims in a long-lived
|
|
plugin.
|
|
|
|
### 3. Task panel with task-scoped Host state
|
|
|
|
There is now a real `registerTaskPanel` and per-user `host.storage`; prefer
|
|
them over the old task-sidebar-plus-webhook workaround.
|
|
|
|
```yaml
|
|
capabilities:
|
|
user_state: true
|
|
```
|
|
|
|
```js
|
|
function useNoteValue(taskId, panelId) {
|
|
const [value, setValue] = host.React.useState("");
|
|
const [loadedTaskId, setLoadedTaskId] = host.React.useState(null);
|
|
const [readError, setReadError] = host.React.useState(false);
|
|
const updatedAtRef = host.React.useRef(undefined);
|
|
const dirtyRef = host.React.useRef(false);
|
|
// Holds cancellation/generation state outside the effect so `refresh` is a
|
|
// stable function the conflict-refetch below can also call, instead of
|
|
// duplicating the read-and-commit logic without its guards.
|
|
const guardRef = host.React.useRef({ cancelled: false, generation: 0 });
|
|
|
|
const refresh = host.React.useCallback((options = {}) => {
|
|
const preserveValue = options.preserveValue ?? dirtyRef.current;
|
|
const guard = guardRef.current;
|
|
const generation = ++guard.generation;
|
|
return host.storage.get("task", taskId, "note").then(
|
|
(entry) => {
|
|
// Ignore a response that resolves after taskId changed, the panel
|
|
// unmounted, or a newer refresh started; otherwise a stale read
|
|
// (the previous task's note, or one of two overlapping reads that
|
|
// resolved out of order) can land in the current field.
|
|
if (guard.cancelled || generation !== guard.generation) return;
|
|
if (!preserveValue && !dirtyRef.current) setValue(entry ? entry.value : "");
|
|
updatedAtRef.current = entry ? entry.updatedAt : undefined;
|
|
setLoadedTaskId(taskId);
|
|
setReadError(false);
|
|
return true;
|
|
},
|
|
() => {
|
|
// Do not mark the task loaded after a rejected read. Rendering an
|
|
// empty editor here would allow its first save to omit
|
|
// ifUnmodifiedSince and overwrite an existing note. Keep the panel
|
|
// in a retry state until an authoritative read succeeds.
|
|
if (guard.cancelled || generation !== guard.generation) return false;
|
|
setReadError(true);
|
|
return false;
|
|
},
|
|
);
|
|
}, [taskId]);
|
|
|
|
host.React.useEffect(() => {
|
|
const guard = guardRef.current;
|
|
guard.cancelled = false;
|
|
// Clear the previous task's value/timestamp synchronously; otherwise it
|
|
// stays on screen (and could be sent as ifUnmodifiedSince under the new
|
|
// taskId) until this effect's first refresh() resolves.
|
|
setValue("");
|
|
updatedAtRef.current = undefined;
|
|
dirtyRef.current = false;
|
|
setLoadedTaskId(null);
|
|
setReadError(false);
|
|
refresh();
|
|
// Scope echo suppression to this panel instance rather than the shared
|
|
// per-tab default writer id; otherwise a second surface editing the
|
|
// same note (a kanban shortcut, another panel) looks like this panel's
|
|
// own echo and its write never arrives here.
|
|
const unsubscribe = host.storage.subscribe(
|
|
{ scope: "task", scopeId: taskId, key: "note", writerId: panelId },
|
|
refresh,
|
|
);
|
|
return () => {
|
|
guard.cancelled = true;
|
|
unsubscribe();
|
|
};
|
|
}, [taskId, panelId, refresh]);
|
|
return {
|
|
value,
|
|
setValue,
|
|
updatedAtRef,
|
|
dirtyRef,
|
|
loaded: loadedTaskId === taskId,
|
|
readError,
|
|
refresh,
|
|
};
|
|
}
|
|
|
|
function NotesPanel({ taskId, panelId }) {
|
|
const {
|
|
value,
|
|
setValue,
|
|
updatedAtRef,
|
|
dirtyRef,
|
|
loaded,
|
|
readError,
|
|
refresh,
|
|
} = useNoteValue(taskId, panelId);
|
|
const [conflict, setConflict] = host.React.useState(false);
|
|
const [writeError, setWriteError] = host.React.useState(false);
|
|
const pendingValueRef = host.React.useRef(undefined);
|
|
const writeTimerRef = host.React.useRef(undefined);
|
|
const writeInFlightRef = host.React.useRef(false);
|
|
const writeBlockedRef = host.React.useRef(false);
|
|
const writeGenerationRef = host.React.useRef(0);
|
|
|
|
const flushWrite = host.React.useCallback(() => {
|
|
if (
|
|
writeBlockedRef.current ||
|
|
writeInFlightRef.current ||
|
|
pendingValueRef.current === undefined
|
|
) {
|
|
return;
|
|
}
|
|
const generation = writeGenerationRef.current;
|
|
const next = pendingValueRef.current;
|
|
pendingValueRef.current = undefined;
|
|
writeInFlightRef.current = true;
|
|
host.storage
|
|
.set("task", taskId, "note", next, {
|
|
writerId: panelId,
|
|
ifUnmodifiedSince: updatedAtRef.current,
|
|
})
|
|
.then((result) => {
|
|
if (writeGenerationRef.current !== generation) return;
|
|
updatedAtRef.current = result.updatedAt;
|
|
dirtyRef.current = pendingValueRef.current !== undefined;
|
|
setConflict(false);
|
|
setWriteError(false);
|
|
})
|
|
.catch((error) => {
|
|
if (writeGenerationRef.current !== generation) return;
|
|
// Stop the queue on a conflict. Refresh only the authoritative
|
|
// timestamp and preserve the local value so the user can choose
|
|
// whether to retry their edit; never replace dirty text silently.
|
|
pendingValueRef.current = pendingValueRef.current ?? next;
|
|
writeBlockedRef.current = true;
|
|
if (error.name === "PluginStorageConflictError") {
|
|
setConflict(true);
|
|
return refresh({ preserveValue: true });
|
|
}
|
|
setWriteError(true);
|
|
})
|
|
.finally(() => {
|
|
if (writeGenerationRef.current !== generation) return;
|
|
writeInFlightRef.current = false;
|
|
if (!writeBlockedRef.current && pendingValueRef.current !== undefined) {
|
|
writeTimerRef.current = setTimeout(() => {
|
|
if (writeGenerationRef.current !== generation) return;
|
|
writeTimerRef.current = undefined;
|
|
flushWrite();
|
|
}, 150);
|
|
}
|
|
});
|
|
}, [dirtyRef, panelId, refresh, taskId, updatedAtRef]);
|
|
|
|
const scheduleWrite = host.React.useCallback(() => {
|
|
if (writeBlockedRef.current || writeTimerRef.current !== undefined) return;
|
|
const generation = writeGenerationRef.current;
|
|
writeTimerRef.current = setTimeout(() => {
|
|
if (writeGenerationRef.current !== generation) return;
|
|
writeTimerRef.current = undefined;
|
|
flushWrite();
|
|
}, 150);
|
|
}, [flushWrite]);
|
|
|
|
const retryWrite = host.React.useCallback(async () => {
|
|
if (!(await refresh({ preserveValue: true }))) return;
|
|
writeBlockedRef.current = false;
|
|
setConflict(false);
|
|
setWriteError(false);
|
|
scheduleWrite();
|
|
}, [refresh, scheduleWrite]);
|
|
|
|
host.React.useEffect(() => {
|
|
const generation = ++writeGenerationRef.current;
|
|
pendingValueRef.current = undefined;
|
|
writeInFlightRef.current = false;
|
|
writeBlockedRef.current = false;
|
|
setConflict(false);
|
|
setWriteError(false);
|
|
return () => {
|
|
if (writeGenerationRef.current === generation)
|
|
writeGenerationRef.current += 1;
|
|
pendingValueRef.current = undefined;
|
|
writeInFlightRef.current = false;
|
|
writeBlockedRef.current = false;
|
|
if (writeTimerRef.current !== undefined)
|
|
clearTimeout(writeTimerRef.current);
|
|
writeTimerRef.current = undefined;
|
|
};
|
|
}, [taskId, panelId]);
|
|
|
|
if (readError) {
|
|
return host.jsx(
|
|
"button",
|
|
{ type: "button", onClick: refresh },
|
|
"Could not load this note. Retry",
|
|
);
|
|
}
|
|
if (!loaded) return host.jsx("div", null, "Loading...");
|
|
const editor = host.jsx(host.ui.RichTextEditor, {
|
|
taskId,
|
|
value,
|
|
onChange: (next) => {
|
|
setValue(next);
|
|
dirtyRef.current = true;
|
|
pendingValueRef.current = next;
|
|
scheduleWrite();
|
|
},
|
|
});
|
|
const status = conflict
|
|
? host.jsx(
|
|
"div",
|
|
null,
|
|
"This note changed elsewhere. Your edit is preserved.",
|
|
host.jsx(
|
|
"button",
|
|
{ type: "button", onClick: retryWrite },
|
|
"Retry my edit",
|
|
),
|
|
)
|
|
: writeError
|
|
? host.jsx(
|
|
"div",
|
|
null,
|
|
"Could not save this note.",
|
|
host.jsx("button", { type: "button", onClick: retryWrite }, "Retry"),
|
|
)
|
|
: null;
|
|
return host.jsx("div", null, editor, status);
|
|
}
|
|
|
|
registry.registerTaskPanel({
|
|
id: "notes",
|
|
title: "Notes",
|
|
icon: "book",
|
|
Component: NotesPanel,
|
|
mobileEnabled: true,
|
|
});
|
|
```
|
|
|
|
On a phone, all `mobileEnabled` panels appear under one **Panels** bottom-nav
|
|
action. The picker uses a touch-sized, internally scrolling sheet with each
|
|
panel's icon and title; selecting a row dismisses the picker and focuses that
|
|
panel as the single full-height mobile surface. The component receives
|
|
`presentation: "mobile"`; desktop dockview receives `presentation: "desktop"`.
|
|
The same registration and panel identity drive both viewports, so a panel remains
|
|
selected through a slow or failed reload and is closed only after a ready
|
|
generation omits it or the plugin is explicitly disabled/uninstalled.
|
|
|
|
`writerId` is appended to the host's own per-tab id, not a full replacement;
|
|
a static value like `panelId` is the same across every tab that has that
|
|
panel open, so two different tabs subscribing with the same raw `panelId`
|
|
would otherwise suppress each other's real, cross-tab updates as if they were
|
|
local echoes. Pass `ifUnmodifiedSince` (the `updatedAt` from the last `get`)
|
|
to `set` and handle the resulting `409` by refetching, rather than silently
|
|
discarding a concurrent edit. `host.storage.list` returns every entry under a
|
|
`(scope, scopeId)` pair with no pagination; fine for a handful of keys per
|
|
task, not for an unbounded per-item collection.
|
|
|
|
### 4. Webhook receiver
|
|
|
|
Declare the route, validate the method and provider signature inside the
|
|
backend, and return a bounded status/body. Public webhooks are capped at 4 MiB.
|
|
For plugin-UI operations, declare `access: authenticated`; those routes may
|
|
raise `max_body_bytes` to 16 MiB. Kandev verifies the current user but strips
|
|
its session cookie and PAT before relaying the remaining headers.
|
|
|
|
```yaml
|
|
webhooks:
|
|
- key: "provider-events"
|
|
description: "Provider event callback"
|
|
method: "POST" # informational; enforce it in HandleWebhook
|
|
```
|
|
|
|
```go
|
|
func (p *plugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) {
|
|
if req.WebhookKey != "provider-events" || req.Method != http.MethodPost {
|
|
return &pluginsdk.WebhookResponse{Status: http.StatusNotFound}, nil
|
|
}
|
|
if !validProviderSignature(req.Headers, req.Body) {
|
|
return &pluginsdk.WebhookResponse{Status: http.StatusUnauthorized}, nil
|
|
}
|
|
// Decode, deduplicate, and enqueue the event before returning.
|
|
return &pluginsdk.WebhookResponse{Status: http.StatusAccepted}, nil
|
|
}
|
|
```
|
|
|
|
### 5. Event subscriber
|
|
|
|
Subscribe to exact subjects or one-segment wildcards and make processing
|
|
idempotent by EventID.
|
|
|
|
```yaml
|
|
capabilities:
|
|
events: ["task.created", "task.state_changed"]
|
|
```
|
|
|
|
```go
|
|
func (p *plugin) OnEvent(ctx context.Context, event *pluginsdk.Event) error {
|
|
// Apply the durable EventID claim pattern from the Host-state recipe before
|
|
// performing side effects.
|
|
if event.EventType == "task.created" { return p.handleTask(ctx, event.Payload) }
|
|
return nil
|
|
}
|
|
```
|
|
|
|
For critical workflows, periodically reconcile from host.Tasks() or another
|
|
Host reader because event queues are bounded and delivery is best-effort.
|
|
|
|
### 6. Kanban-aware contribution
|
|
|
|
`registerTaskMenuAction` adds an item to the kanban card's context/dropdown
|
|
menu; group `"edit"` nests it inside the `Edit` submenu, group `"primary"`
|
|
renders it as a flat, top-level item positioned between the "Move
|
|
to"/"Send to workflow" submenus and the "Link" submenu.
|
|
`task-card-indicators` (see the named slots table) is the matching read-only
|
|
surface, rendered beside the PR status icon on every card. `task-card-tags`
|
|
is a sibling read-only surface with the same `slotProps` shape, mounted in its
|
|
own row instead of that cramped title-row spot; reach for it when a
|
|
contribution (e.g. a row of tag chips) needs its own width. Both
|
|
`visible(context)` and `run(context)` receive the card's actual `presentation`:
|
|
`"desktop"` on the desktop kanban and `"mobile"` on the phone kanban. Use it
|
|
when an action needs to choose responsive behavior; the host supplies it
|
|
through the card composition.
|
|
|
|
```js
|
|
registry.registerTaskMenuAction({
|
|
id: "enhance-notes",
|
|
label: "Enhance notes",
|
|
group: "edit",
|
|
visible: (context) => Boolean(context.taskId),
|
|
run: async (context) => {
|
|
await host.storage.set("task", context.taskId, "note", "...");
|
|
},
|
|
});
|
|
```
|
|
|
|
With no plugin action registered, the card's `Edit` item stays flat; once any
|
|
plugin registers one for group `"edit"`, it becomes `Edit > Edit task`
|
|
followed by each visible action. A `run` that throws or rejects is caught and
|
|
logged, not left to crash the card; the menu closes either way. Group
|
|
`"primary"` actions render and behave the same way, just as their own
|
|
top-level menu row instead of nested under `Edit`. Do not patch first-party
|
|
Kanban components directly.
|
|
|
|
`registerTaskFilter` adds a client-side, multi-select filter section to the
|
|
kanban board's display dropdown, next to the built-in Workflow and Repository
|
|
sections. The plugin supplies its own options (including any "untagged"-style
|
|
sentinel; the host does not special-case option values) and a `matches`
|
|
predicate; filtering runs entirely against tasks already loaded in the
|
|
board's in-memory state, with no backend query or persistence; selections
|
|
reset on reload.
|
|
|
|
```js
|
|
registry.registerTaskFilter({
|
|
id: "tags",
|
|
label: "Tags",
|
|
getOptions: () => [
|
|
{ value: "bug", label: "Bug", color: "#ef4444" },
|
|
{ value: "untagged", label: "Untagged" },
|
|
],
|
|
matches: (context, selected) => taskHasAnyTag(context.taskId, selected),
|
|
});
|
|
```
|
|
|
|
An empty selection is implicit "All" for that section: `matches` is only
|
|
called once at least one option is selected, and a `matches` that throws is
|
|
caught, logged, and treated as non-matching for that task.
|
|
|
|
## Build, package, install, and test
|
|
|
|
### Plugin repository
|
|
|
|
From the plugin repository created from the official template:
|
|
|
|
```bash
|
|
go test ./...
|
|
go vet ./...
|
|
make package-host # local platform, fastest loop
|
|
make package # all platforms declared by the template
|
|
```
|
|
|
|
The template's package target stages manifest.yaml, server/, ui/, and assets,
|
|
then runs the monorepo plugin-pack command. If using a custom Makefile, the
|
|
equivalent is:
|
|
|
|
```bash
|
|
rm -rf .build/stage
|
|
mkdir -p .build/stage/server
|
|
cp manifest.yaml .build/stage/manifest.yaml
|
|
if [ -d ui ]; then cp -R ui .build/stage/ui; fi
|
|
if [ -d assets ]; then cp -R assets .build/stage/assets; fi
|
|
go build -o .build/stage/server/plugin-$(go env GOOS)-$(go env GOARCH) ./server
|
|
go run github.com/kandev/kandev/cmd/plugin-pack \
|
|
-dir .build/stage \
|
|
-out acme-dashboard-0.1.0.tar.gz \
|
|
-platform-only
|
|
```
|
|
|
|
plugin-pack requires manifest.yaml, rejects pre-supplied checksum files, and
|
|
generates checksums.txt. It is a packager, not an exhaustive semantic or
|
|
JavaScript validator: it does not execute the plugin or prove every referenced
|
|
UI asset is valid.
|
|
|
|
### In-tree fixture validation
|
|
|
|
The monorepo provides a maintained package smoke path without a production
|
|
instance:
|
|
|
|
```bash
|
|
make -C apps/backend e2e-plugin-package
|
|
cd apps/backend
|
|
go test ./cmd/plugin-pack ./cmd/plugin-fixture ./internal/plugins/manifest \
|
|
./internal/plugins/pkgtar ./pkg/pluginsdk
|
|
```
|
|
|
|
The fixture build proves the current SDK/package shape and produces a
|
|
host-platform archive. pkgtar.Install is the install-time authority for manifest
|
|
validity, safe archive paths, checksums, managed runtime, and the host
|
|
executable. It also extracts the package; it does not run the plugin or perform
|
|
a full browser/module build.
|
|
|
|
### Disposable-instance smoke test
|
|
|
|
Install the archive through Settings > Plugins or the local API:
|
|
|
|
```bash
|
|
curl -F "package=@acme-dashboard-0.1.0.tar.gz" \
|
|
http://localhost:38429/api/plugins/install
|
|
```
|
|
|
|
Then enable it, open each registered route, exercise every declared webhook or
|
|
event, verify denied capabilities fail as expected, and disable/re-enable to
|
|
confirm destroy and state preservation. Uninstall only in a disposable
|
|
instance; it removes the plugin package and KANDEV_PLUGIN_DATA_DIR.
|
|
|
|
Reinstalling the same id/version is rejected. Bump version or uninstall before
|
|
repackaging.
|
|
|
|
| Layer | Command/source | Checks | Does not check |
|
|
| ------- | ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime |
|
|
| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls |
|
|
| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering |
|
|
| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance |
|
|
|
|
## Common mistakes
|
|
|
|
- **Missing the leading slash:** ui.bundle and ui.styles must be root-relative
|
|
(/ui/bundle.js, /ui/plugin.css), not ui/bundle.js.
|
|
- **Forgetting capabilities:** every Host state, secret, data-reader, write, or
|
|
utility-agent call fails with PermissionDenied unless its manifest capability
|
|
is present.
|
|
- **Confusing plugin_state with host.storage:** Host state is backend,
|
|
plugin-scoped JSON with no per-user identity; host.storage is per-user.
|
|
- **Reusing a raw surface id as writerId:** a static id like panelId is the
|
|
same across every tab that has that surface open. Passed as-is to
|
|
host.storage's writerId, it makes two different tabs look like the same
|
|
writer and suppresses real cross-tab updates as if they were local echoes;
|
|
the host already appends it to a per-tab id, so pass the surface id, not a
|
|
fabricated combined string.
|
|
- **Writing outside KANDEV_PLUGIN_DATA_DIR:** arbitrary files belong below the
|
|
injected per-plugin directory; do not write beside the Kandev database or into
|
|
another plugin's directory.
|
|
- **Leaving stale frontend registrations:** use destroy, unsubscribe store/WS
|
|
listeners, clear timers, and rely on host bulk revocation after disable or
|
|
uninstall.
|
|
- **Importing direct DB/internal packages:** use apps/backend/pkg/pluginsdk and
|
|
typed Host methods. Direct database access is outside the plugin contract.
|
|
- **Trusting webhook metadata:** `webhooks[].method` is informational. Public
|
|
routes are not authenticated by Kandev, so validate method, signature, timestamp, replay
|
|
protection, and body before side effects.
|
|
- **Bundling React:** use host.React, host.jsx, and host.ui; a second React or
|
|
Radix copy breaks shared contexts and portals.
|
|
- **Shipping the wrong binary name:** every declared executable must be under
|
|
server/, match its <goos>-<goarch> key, and include .exe on Windows.
|
|
|
|
Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md),
|
|
[Plugin marketplace](plugins-marketplace.md), and [Extending Kandev](extending-kandev.md).
|