--- 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. ## Package and data layout ```text -.tar.gz ├── manifest.yaml # required, validated first ├── server/ │ └── plugin--[.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//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 are routable, but Kandev does not authenticate callers or enforce webhooks[].method. Validate the method, provider signature, and replay/timestamp rules inside the plugin 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 | No current host.storage API | Unavailable on this branch; do not invent a signature or use browser storage as a Host contract | Store server-side with an explicit entity scope, or ask for a future Host API | | 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: it is plugin-scoped backend state, not a per-user frontend store. There is no transaction or compare-and-swap operation; design updates to be idempotent. ## 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. ### 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//...; 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(...)) | | 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//...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Abort/ignore requests after destroy; do not assume a webhook authenticates callers | host.api.fetch("webhooks/inbound", { method: "POST" }) | | 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*, 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 | Chat composer toolbar | { taskId, taskTitle?, activeSessionId, sessionIds } | | 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 | 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. ### APIs not available on this branch Do not copy examples that assume these hooks exist: - registerTaskPanel and registerTaskMenuAction are not implemented. Use a task-sidebar component, a route, or a webhook-backed UI action. - host.storage (per-user frontend storage) is not implemented. Use backend Host state with an explicit task, workspace, agent, or instance scope. - host.ui.RichTextEditor and host.ui.RichTextReadOnly are not exported. - There is no Kanban-card contribution hook. Use main-top-bar, a route that reads host.store, or a task-sidebar contribution. These are branch facts, not reserved signatures. When a future branch adds one, update the authoritative contract pair and this matrix together. ## 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.. | 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: author | | Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the operator-selected utility agent; missing selection is 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 no registerTaskPanel or per-user host.storage today. Use the mounted task-sidebar slot for the UI, read the active task id from host.store, send it to the Go backend, and store the value under the task scope. ```yaml capabilities: state: true webhooks: - key: "task-note" method: "POST" ``` ```js registry.registerComponent("task-sidebar", () => { const taskId = host.store.getState().tasks.activeTaskId; return host.jsx("button", { onClick: () => host.api.fetch("webhooks/task-note", { method: "POST", body: JSON.stringify({ taskId }), headers: { "content-type": "application/json" }, }), }, "Save task note"); }); ``` ```go // In HandleWebhook, after authenticating/validating the request: host := p.Host() if host == nil { return &pluginsdk.WebhookResponse{Status: http.StatusServiceUnavailable}, nil } return &pluginsdk.WebhookResponse{Status: http.StatusNoContent}, host.SetState( ctx, "task", taskID, "note", map[string]any{"text": note}, ) ``` This is task-scoped plugin state, not a per-user browser preference. If the behavior truly needs user identity, request a future Host API rather than guessing at one. ### 4. Webhook receiver Declare the route, validate the method and provider signature inside the backend, and return a bounded status/body. Kandev caps incoming bodies at 4 MiB but does not authenticate the caller. ```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 There is no Kanban-card injection hook in this branch. Use a mounted top-bar contribution or a route that reads the live app store, and link to a task route when a card is selected. ```js registry.registerComponent("main-top-bar", ({ slotProps }) => { const state = host.store.getState(); const count = state.kanban && state.kanban.tasks ? state.kanban.tasks.length : 0; return host.jsx("span", { title: "View: " + (slotProps && slotProps.currentPage) }, count + " tasks"); }); ``` For a card-specific action, use task-sidebar on the task detail or a route with host.store.subscribe; do not patch first-party Kanban components. ## 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; host.storage does not exist in this branch. - **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 and the route is not authenticated by Kandev. 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 - 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).