579 lines
29 KiB
Markdown
579 lines
29 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.
|
|
|
|
## 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 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/<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(...)) |
|
|
| 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 | 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.<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 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 <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).
|