848 lines
36 KiB
Markdown
848 lines
36 KiB
Markdown
---
|
|
title: "Authoring a Plugin"
|
|
description: "Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance."
|
|
status: experimental
|
|
---
|
|
|
|
# Authoring a Plugin
|
|
|
|
This is a build tutorial for a kandev plugin: a Go backend spawned by kandev
|
|
over gRPC, with an optional native frontend bundle. See [Plugins](plugins.md)
|
|
for the operator-facing install/enable/disable flow, and the [Plugin manifest
|
|
reference](plugins-manifest.md) for the full `manifest.yaml` schema.
|
|
|
|
The fastest way to start is the
|
|
[`kdlbs/kandev-plugin-template`](https://github.com/kdlbs/kandev-plugin-template)
|
|
starter repo — click **Use this template** on GitHub for a minimal, working
|
|
plugin with the layout, build/package `Makefile`, tests, and a tag-triggered
|
|
release workflow already wired up. This guide explains what that scaffold
|
|
contains and how to extend it.
|
|
|
|
Two more example plugins go deeper on specific surfaces:
|
|
|
|
- `kandev-plugin-hello` — a nav item, native route, sidebar slot component, a
|
|
WS-driven counter, a Host-state-backed event handler, and a webhook.
|
|
- `kandev-plugin-github` — a connector PoC that shells out to the `gh` CLI
|
|
from a webhook handler used as a request relay, reads live kanban data from
|
|
the host store, and opens kandev's real create-task dialog
|
|
(`host.ui.TaskCreateDialog`) prefilled from a pull request.
|
|
|
|
## Quick path
|
|
|
|
1. Start from the plugin template.
|
|
2. Implement the backend and manifest first.
|
|
3. Add native UI, webhooks, or Host API capabilities only when needed.
|
|
4. Run focused tests, package the plugin, and install it locally.
|
|
|
|
## Prerequisites
|
|
|
|
- Go (matching the version in `apps/backend/go.mod`).
|
|
- The kandev plugin SDK, `github.com/kandev/kandev/pkg/pluginsdk`. It is
|
|
**not yet published as a standalone Go module** — it lives inside the
|
|
kandev monorepo (`apps/backend/pkg/pluginsdk`), so a plugin repo develops
|
|
against a local checkout via a `replace` directive:
|
|
|
|
```go
|
|
// go.mod
|
|
module my-plugin
|
|
|
|
require github.com/kandev/kandev v0.0.0-00010101000000-000000000000
|
|
|
|
replace github.com/kandev/kandev => ../kandev/apps/backend
|
|
```
|
|
|
|
Adjust the relative path to wherever you've checked out the kandev repo.
|
|
This is what both example plugins do; see their `go.mod` for the exact
|
|
pinned dependency versions.
|
|
|
|
## Backend: minimal plugin
|
|
|
|
A plugin backend implements `pluginsdk.Plugin` and calls `pluginsdk.Serve`,
|
|
which owns the entire go-plugin/gRPC transport — handshake, plugin map, and
|
|
Host injection. There is no HTTP server, no listen address, and no
|
|
credentials to configure.
|
|
|
|
> **Language.** The backend is any native executable that speaks the
|
|
> hashicorp/go-plugin gRPC protocol (magic-cookie handshake, AutoMTLS, the
|
|
> `kandev.plugin.v1` services) — kandev just spawns the platform binary and
|
|
> talks to it over that wire. Go is the only SDK kandev ships today, so this
|
|
> guide is Go; another language means implementing the handshake, mTLS, and
|
|
> proto yourself.
|
|
|
|
```go
|
|
// main.go
|
|
package main
|
|
|
|
import "github.com/kandev/kandev/pkg/pluginsdk"
|
|
|
|
func main() {
|
|
pluginsdk.Serve(&myPlugin{})
|
|
}
|
|
```
|
|
|
|
```go
|
|
// plugin.go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
type myPlugin struct {
|
|
pluginsdk.UnimplementedPlugin // embed to get HostSetter + no-op defaults
|
|
}
|
|
|
|
var _ pluginsdk.Plugin = (*myPlugin)(nil)
|
|
```
|
|
|
|
`pluginsdk.Plugin` is the interface kandev calls into:
|
|
|
|
```go
|
|
type Plugin interface {
|
|
// OnEvent handles a single bus event delivery. A non-nil error causes
|
|
// kandev to retry (3 retries, 5s/15s/45s backoff).
|
|
OnEvent(ctx context.Context, e *Event) error
|
|
|
|
// HandleWebhook handles an inbound request relayed from
|
|
// POST /api/plugins/{id}/webhooks/{key}.
|
|
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
|
|
}
|
|
```
|
|
|
|
Embed `pluginsdk.UnimplementedPlugin` and override only the methods you need
|
|
— it's a no-op base that also implements `HostSetter`, so `Serve` injects a
|
|
live `Host` into your plugin once the broker connection back to kandev is
|
|
established (retrieve it later via `p.Host()`).
|
|
|
|
## Webhooks
|
|
|
|
Declare each webhook key in `manifest.yaml`. Kandev relays `GET` and `POST`
|
|
requests for `/api/plugins/<id>/webhooks/<key>` to `HandleWebhook`; undeclared
|
|
keys return `404`. Request bodies are limited to **4 MiB** and requests that
|
|
exceed the limit return `413` before reaching the plugin. Kandev does not
|
|
authenticate webhook callers or enforce the manifest's informational `method`,
|
|
so validate the HTTP method and the upstream provider's signature before any
|
|
side effect.
|
|
|
|
## The Host API
|
|
|
|
> **Auth capability:** Only assert email addresses the identity provider verified as owned by the subject. An unverified or user-controlled email claim can take over an account; grant this capability only to trusted plugins.
|
|
|
|
<details>
|
|
<summary>Host API and native UI details</summary>
|
|
|
|
A plugin calls back into kandev through the injected `Host`:
|
|
|
|
```go
|
|
type Host interface {
|
|
GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error)
|
|
SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error
|
|
DeleteState(ctx context.Context, scope, scopeID, key string) error
|
|
ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error)
|
|
|
|
// GetConfig returns the plugin's own operator-editable config (set via
|
|
// Settings > Plugins > <plugin>, generated from manifest config_schema).
|
|
// Ungated — always readable, secret values included. Kandev restarts a
|
|
// running plugin when its config changes, so re-read at startup.
|
|
GetConfig(ctx context.Context) (map[string]any, error)
|
|
|
|
// GetSecret/SetSecret/DeleteSecret manage a plugin-owned secret in
|
|
// kandev's encrypted vault, namespaced to this plugin. Require the
|
|
// `secrets` capability.
|
|
GetSecret(ctx context.Context, key string) (value string, found bool, err error)
|
|
SetSecret(ctx context.Context, key, value string) error
|
|
DeleteSecret(ctx context.Context, key string) error
|
|
|
|
// RevealSecret resolves an operator-provided secret reference (e.g. a
|
|
// config value pointing at a shared kandev secret) to its cleartext
|
|
// value. For secrets the plugin itself owns, use GetSecret/SetSecret.
|
|
RevealSecret(ctx context.Context, ref string) (string, error)
|
|
|
|
EmitEvent(ctx context.Context, name string, payload map[string]any) error
|
|
|
|
// Tasks/Sessions/Workspaces/Workflows/AgentProfiles/Repositories return
|
|
// read-only accessors for the Host data API (ADR 0043), each gated on
|
|
// its own capabilities.api_read entry (e.g. "tasks", "sessions").
|
|
// Write RPCs (capabilities.api_write) are reserved and not yet
|
|
// implemented.
|
|
Tasks() TaskReader
|
|
Sessions() SessionReader
|
|
Workspaces() WorkspaceReader
|
|
Workflows() WorkflowReader
|
|
AgentProfiles() AgentProfileReader
|
|
Repositories() RepositoryReader
|
|
|
|
// Messages reads historical user/agent conversation content
|
|
// (capability api_read:messages). kandev-injected system blocks are
|
|
// stripped; raw system prompts are never returned.
|
|
Messages() MessageReader
|
|
|
|
// InvokeUtilityAgent runs a one-shot completion using this plugin's
|
|
// selected utility agent (capability agent_invoke). No API key of your
|
|
// own; FailedPrecondition when no valid enabled agent is selected.
|
|
InvokeUtilityAgent(ctx context.Context, prompt string) (string, error)
|
|
}
|
|
```
|
|
|
|
**Host state** is a small key/value store kandev keeps for your plugin in
|
|
its own database. Each entry is addressed by a `(scope, scopeID, key)`
|
|
triple and holds a JSON object (`map[string]any`): `SetState` upserts one,
|
|
`GetState` reads it back (`found` is `false` when the key was never set),
|
|
`DeleteState` removes it, and `ListState` returns every entry under a
|
|
`(scope, scopeID)`. Values are JSON objects, not bare scalars — wrap a
|
|
number as `map[string]any{"n": 3}`. State is namespaced per plugin (kandev
|
|
injects your plugin id server-side, so no plugin can read or write
|
|
another's), survives restarts, is captured by kandev's database backups, and
|
|
is deleted when the plugin is uninstalled. Reach for it when you want kandev
|
|
to durably remember something small and structured for you; use the writable
|
|
data directory below for arbitrary files you'd rather manage yourself.
|
|
|
|
`scope` partitions that store by what a value belongs to: `instance` (the
|
|
whole kandev instance; `scopeID` empty), or `workspace` / `task` / `agent`
|
|
with `scopeID` set to that entity's id. A per-task counter, for example, is
|
|
`SetState(ctx, "task", taskID, "count", map[string]any{"n": 3})` — kept
|
|
separate from every other task's.
|
|
|
|
`EmitEvent` publishes `plugin.<your-plugin-id>.<name>` on kandev's internal
|
|
event bus for delivery to any subscriber (including other plugins).
|
|
|
|
The data-reader accessors return typed, paginated readers — e.g.
|
|
`host.Tasks().List(ctx, TaskFilter{...}, Page{Limit: 50})` returns
|
|
`([]Task, *PageInfo, error)` with an opaque `PageInfo.NextCursor` for the
|
|
next page. See `pkg/pluginsdk/data_types.go` for the full `Task`,
|
|
`Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`, `Repository`,
|
|
`Session`, `Message`, and filter/page types.
|
|
|
|
`host.Messages().List(ctx, MessageFilter{...}, Page{...})` reads historical
|
|
conversation content (capability `api_read:messages`). Filter by `SessionIDs`,
|
|
`TaskIDs`, a `Since`/`Until` `created_at` window (RFC3339; `Since` inclusive,
|
|
`Until` exclusive — the natural way to fetch "yesterday"), and message
|
|
`Types`. Each `Message` carries `id`, `session_id`, `task_id`, `turn_id`,
|
|
`author_type` (`user` or `agent`), `content`, `type`, and `created_at`.
|
|
`content` has kandev's injected `<kandev-system>` blocks stripped — a plugin
|
|
never sees raw system prompts.
|
|
|
|
`host.InvokeUtilityAgent(ctx, prompt)` runs a one-shot, non-interactive LLM
|
|
completion using the utility agent selected for this plugin in **Settings >
|
|
Plugins > `<plugin>`** (capability `agent_invoke`), and returns its text. Declare
|
|
the selector in `manifest.yaml`:
|
|
|
|
```yaml
|
|
capabilities:
|
|
agent_invoke: true
|
|
|
|
config_schema:
|
|
type: object
|
|
properties:
|
|
utility_agent:
|
|
type: string
|
|
format: utility-agent
|
|
title: Utility Agent
|
|
description: Agent used for this plugin's LLM calls
|
|
required: ["utility_agent"]
|
|
```
|
|
|
|
The picker displays configured built-in and custom agent names but stores the
|
|
selected agent's stable ID. Omit `utility_agent` from `required` only when the
|
|
plugin supports operating without LLM delegation; optional selectors include a
|
|
**Not set** choice. The plugin needs no provider API key because it delegates to
|
|
a kandev-configured agent. A missing, deleted, or disabled selection returns
|
|
gRPC `FailedPrecondition`, so handle that as "ask the operator to configure
|
|
one" rather than a transient failure. This is the LLM step behind, e.g., a
|
|
"summarize yesterday" plugin: read the conversation with `host.Messages()`,
|
|
then summarize it with `host.InvokeUtilityAgent(...)`.
|
|
|
|
**Capability gating.** Every Host RPC except `GetConfig` and `EmitEvent` is
|
|
checked against your manifest's `capabilities` before the handler runs:
|
|
`GetState`/`SetState`/`DeleteState`/`ListState` require
|
|
`capabilities.state: true`; `GetSecret`/`SetSecret`/`DeleteSecret`/
|
|
`RevealSecret` require `capabilities.secrets: true`; `InvokeUtilityAgent`
|
|
requires `capabilities.agent_invoke: true`; each data-reader accessor requires
|
|
its resource in `capabilities.api_read` (e.g. `tasks`, `sessions`, `messages`,
|
|
`workspaces`, `workflows`, `agent_profiles`, `repositories`).
|
|
Calling one without the declared capability returns gRPC `PermissionDenied`
|
|
with a message naming the missing capability — declare what you use.
|
|
|
|
**External login (`capabilities.auth`).** An auth-capable plugin can log a
|
|
visitor in against an external IdP (OIDC/SAML). Handle the IdP callback / SAML
|
|
ACS in a `webhook`, validate the token yourself, then set the reserved
|
|
`X-Kandev-Auth-Login` response header to a JSON object
|
|
`{"provider","subject","email","display_name"}`. Kandev maps it to a user
|
|
(link-by-email or just-in-time member provisioning), mints the session, and sets
|
|
the `kandev_session` cookie itself — your plugin never handles the raw token,
|
|
and any `Set-Cookie` you return is dropped. Requires authentication enabled;
|
|
emitting the header without `capabilities.auth` returns 403.
|
|
|
|
**You MUST only assert an `email` the IdP has verified as owned by `subject`.**
|
|
Kandev auto-links that email to (or provisions) an account, so an unverified or
|
|
user-settable email claim is an account-takeover vector. Kandev refuses to
|
|
auto-link to an admin account as defense-in-depth, but it cannot tell a verified
|
|
email from an unverified one — that is on your plugin. See ADR 0050.
|
|
|
|
**Writable data directory.** Kandev injects `KANDEV_PLUGIN_DATA_DIR` into
|
|
every spawned plugin subprocess — a per-plugin writable directory
|
|
(`~/.kandev/plugins/<id>/data`) for anything you'd rather keep on disk than
|
|
in `Host` state.
|
|
|
|
```go
|
|
func (p *myPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error {
|
|
host := p.Host()
|
|
if host == nil {
|
|
return nil // broker dial still in progress
|
|
}
|
|
// Read the current count (found == false the first time), then write it back + 1.
|
|
value, _, err := host.GetState(ctx, "instance", "", "count")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := value["n"].(float64) // JSON numbers decode as float64
|
|
return host.SetState(ctx, "instance", "", "count", map[string]any{"n": n + 1})
|
|
}
|
|
```
|
|
|
|
## Optional: native UI
|
|
|
|
A plugin may ship `ui.bundle` in its manifest: a **hand-written, no-build**
|
|
plain-JS ES module. There is no bundler step — kandev serves the file
|
|
verbatim from the extracted package directory, so you edit the bundle and
|
|
repackage.
|
|
|
|

|
|
|
|
The single entry point: the bundle, once evaluated, calls
|
|
`window.registerKandevPlugin(id, { initialize(registry, host), destroy?() })`.
|
|
Kandev's frontend host imports the bundle on boot (or on runtime enable),
|
|
then calls `initialize(registry, host)`. On disable/uninstall it calls
|
|
`destroy?.()` and bulk-revokes every registration the plugin made.
|
|
|
|
**Registry surface** (`registry: PluginRegistry`, passed to `initialize`):
|
|
|
|
```ts
|
|
interface PluginRegistry {
|
|
// Top-level SPA route, exact-match against window.location path. The host
|
|
// wraps the page in kandev's title bar by default; configure or opt out
|
|
// via options.topbar.
|
|
registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void;
|
|
// Sidebar/main nav entry, rendered by <PluginNavItems/>.
|
|
registerNavItem(item: NavItem): void;
|
|
// Route under /settings/plugins/{id}/..., rendered inside the settings shell.
|
|
registerSettingsRoute(path: string, Component: React.ComponentType): void;
|
|
// Named slot injection. Initial slots: "task-sidebar", "settings-nav",
|
|
// "main-nav-footer", "chat-input-actions", "chat-top-bar", "main-top-bar",
|
|
// "app-status-bar-left", "app-status-bar-right", and "plugin-settings"
|
|
// (see "Named slots" below).
|
|
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
|
|
// WS action handler, bridged into the existing lib/ws dispatch.
|
|
registerWsHandler(action: string, handler: (payload: unknown) => void): void;
|
|
// Bind a handler to a keybinding declared in this plugin's manifest
|
|
// (ui.keybindings[].id). See "Keybindings" below.
|
|
registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void;
|
|
}
|
|
|
|
interface NavItem {
|
|
id: string;
|
|
label: string;
|
|
path: string;
|
|
// Curated icon name (see lib/plugins/icons.ts); unknown names render the puzzle glyph.
|
|
icon?: string;
|
|
// "main" (default): top-level sidebar entry. "integrations": renders inside
|
|
// the sidebar's Integrations section alongside first-party integration links.
|
|
// Both also render in the phone menu sheet, so plugin pages stay reachable
|
|
// when the desktop sidebar is hidden.
|
|
// "settings" is accepted by the type but not rendered by any sidebar section
|
|
// today — for a settings page use registerSettingsRoute instead.
|
|
section?: "main" | "settings" | "integrations";
|
|
}
|
|
|
|
interface PluginRouteOptions {
|
|
// Kandev-style title bar above the page. Default: enabled with a derived
|
|
// title (from the matching nav item, else the plugin's display name).
|
|
// Pass a PluginPageChrome to configure it, or `false` for a full-bleed
|
|
// page that owns its own chrome (e.g. with host.ui.PageTopbar).
|
|
topbar?: boolean | PluginPageChrome;
|
|
}
|
|
|
|
interface PluginPageChrome {
|
|
title?: string;
|
|
subtitle?: string;
|
|
icon?: string; // same curated set as NavItem.icon
|
|
backHref?: string; // default "/"
|
|
backLabel?: string; // default "Kandev"
|
|
actions?: React.ComponentType; // rendered on the right side of the topbar
|
|
}
|
|
```
|
|
|
|
**Host API** (`host: PluginHostApi`, passed to `initialize`):
|
|
|
|
```ts
|
|
interface PluginHostApi {
|
|
pluginId: string;
|
|
React: typeof import("react"); // shared host React instance — MUST use this, never bundle your own React
|
|
jsx: typeof React.createElement; // convenience alias
|
|
store: { // kandev's live app store — read access is the common
|
|
getState(): AppState; // case; setState is exposed but writes affect the whole SPA
|
|
setState(partial): void;
|
|
subscribe(listener): () => void;
|
|
};
|
|
api: {
|
|
// fetch scoped to /api/plugins/{id}/...; relayed to your webhook handler.
|
|
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
// Backend API origin ("" when the SPA and API share an origin) — lets a
|
|
// plugin reach first-party kandev REST endpoints directly.
|
|
baseUrl: string;
|
|
};
|
|
ui: Record<string, unknown>; // curated @kandev/ui subset — see below
|
|
theme: "light" | "dark";
|
|
// Soft SPA navigation (history push/replace), same as the app's own router.
|
|
navigate(href: string, options?: { replace?: boolean }): void;
|
|
// Imperatively opens a modal window. See "Modal windows" below.
|
|
openModal(options: PluginModalOptions): PluginModalHandle;
|
|
}
|
|
|
|
interface PluginModalOptions {
|
|
title?: string; // rendered in DialogHeader/DialogTitle; omit for no title
|
|
content: React.ComponentType; // reuses the slot-component contract
|
|
size?: "sm" | "md" | "lg" | "xl"; // default "md"
|
|
dismissible?: boolean; // overlay click / Escape; default true
|
|
}
|
|
|
|
interface PluginModalHandle {
|
|
close(): void; // no-op if already closed
|
|
}
|
|
```
|
|
|
|
A plugin bundle must render with `host.React` / `host.jsx` — bundling your
|
|
own React copy breaks hook identity against the host tree.
|
|
|
|
`host.ui` is a curated `@kandev/ui` subset — Alert, Badge, Button, Card,
|
|
Checkbox, Dialog, DropdownMenu, Input, Label, Pagination, ScrollArea, Select,
|
|
Separator, Sheet, Skeleton, Spinner, Switch, Table, Tabs, Textarea, Tooltip
|
|
(each with their compound sub-parts, e.g. `DialogContent`, `TableRow`) — plus
|
|
first-party app UI: `Combobox` (the app's picker), `PageTopbar` (the title
|
|
bar a route gets by default via `registerRoute`'s `options.topbar`, exposed
|
|
here for routes that opt out and render their own chrome), and
|
|
`TaskCreateDialog`, so a plugin can hand off task creation to kandev's real
|
|
create-task flow (repo/branch/agent pickers, validation) instead of POSTing
|
|
directly. See `apps/web/lib/plugins/host-api.ts` for the exact current list.
|
|
|
|
### Modal windows
|
|
|
|
`host.openModal(options)` imperatively opens a host-owned Dialog — rendered
|
|
by a `<PluginModalHost/>` mounted once at the app root, isolated behind its
|
|
own error boundary, and auto-closed if the plugin is disabled or uninstalled
|
|
while it's open. It's independent of keybindings: call it from a keybinding
|
|
handler, a nav route, a slot component, or a WS handler. It complements the
|
|
declarative `host.ui.Dialog` — reach for `host.ui.Dialog` when a dialog is
|
|
embedded in a slot's own render tree, and `host.openModal` when you need to
|
|
pop one open imperatively from anywhere in your plugin's code.
|
|
|
|
```js
|
|
const handle = host.openModal({
|
|
title: "Acme settings",
|
|
content: SettingsPanel,
|
|
size: "lg",
|
|
});
|
|
|
|
// later, e.g. after the panel calls back on save:
|
|
handle.close();
|
|
```
|
|
|
|
</details>
|
|
|
|
## Named slots
|
|
|
|
<details>
|
|
<summary>Named slot details</summary>
|
|
|
|
`registerComponent(slot, Component)` injects a component into a host-defined
|
|
slot. The host renders every plugin's component for that slot (each isolated
|
|
behind an error boundary), so a slot may hold contributions from several
|
|
plugins at once. Available slots:
|
|
|
|
| Slot | Where it renders | `slotProps` |
|
|
| --- | --- | --- |
|
|
| `task-sidebar` | Bottom of the task-detail sidebar | — |
|
|
| `settings-nav` | Settings navigation tree | — |
|
|
| `main-nav-footer` | Footer of the main sidebar | — |
|
|
| `chat-input-actions` | Chat composer toolbar, beside the model picker, mic, and send button | `{ taskId, taskTitle, activeSessionId, sessionIds }` |
|
|
| `chat-top-bar` | Session top bar, beside the CPU/DB metrics and the document/editor/debug controls | `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }` |
|
|
| `main-top-bar` | Default app top bar (Home / Kanban / Tasks), beside the CPU/DB metrics and the view/display controls | `{ workspaceId, workspaceLabel, currentPage }` |
|
|
| `app-status-bar-left` | Default-left item in the global status surface | `AppStatusBarSlotProps` |
|
|
| `app-status-bar-right` | Default-right item in the global status surface | `AppStatusBarSlotProps` |
|
|
| `plugin-settings` | A plugin's own settings page (**Settings > Plugins > `<plugin>`**), at the top above the settings form | `{ pluginId, status }` |
|
|
|
|
`plugin-settings` is the one exception to "every plugin's component renders":
|
|
it is **owner-scoped**, so the host renders only the component registered by the
|
|
plugin whose settings page is being viewed. See "Plugin settings page" below.
|
|
|
|
### Chat toolbar actions
|
|
|
|
Register a `chat-input-actions` component to add an icon button to the chat
|
|
composer toolbar. The host passes the current context as `slotProps`:
|
|
|
|
```ts
|
|
type ChatInputActionsSlotProps = {
|
|
taskId: string | null;
|
|
taskTitle?: string;
|
|
activeSessionId: string | null; // session the composer is bound to
|
|
sessionIds: string[]; // every kandev session id on the task
|
|
};
|
|
```
|
|
|
|
A task can hold several sessions, so both the active session and the full
|
|
`sessionIds` list are provided. These are **kandev** session ids — resolving one
|
|
to an agent/ACP transcript id (for example, to key per-session cost data from a
|
|
tool like tokscale) is your plugin's job. Do that **server-side in your plugin
|
|
backend** via the Host data API (`host.Sessions()` exposes each session's
|
|
`ACPSessionID`), not in the bundle: propagate the ids from the UI to your
|
|
backend over `host.api.fetch(...)`, and let the backend do the matching.
|
|
|
|
```js
|
|
function makeChatAction(host) {
|
|
const { jsx: h, ui } = host;
|
|
const { Button, Tooltip, TooltipTrigger, TooltipContent } = ui;
|
|
|
|
return function ChatAction({ slotProps }) {
|
|
const { taskId } = slotProps ?? {};
|
|
return h(
|
|
Tooltip,
|
|
null,
|
|
h(
|
|
TooltipTrigger,
|
|
{ asChild: true },
|
|
h(
|
|
Button,
|
|
{
|
|
type: "button",
|
|
variant: "ghost",
|
|
size: "icon",
|
|
className: "h-7 w-7 cursor-pointer hover:bg-muted/40",
|
|
"aria-label": "Open plugin page",
|
|
onClick: () => host.navigate("/hello-world"),
|
|
},
|
|
/* an icon element built with host.jsx */ myIcon(h),
|
|
),
|
|
),
|
|
h(TooltipContent, null, taskId ? `Task: ${taskId}` : "Plugin action"),
|
|
);
|
|
};
|
|
}
|
|
|
|
// inside initialize(registry, host):
|
|
registry.registerComponent("chat-input-actions", makeChatAction(host));
|
|
```
|
|
|
|
Match the first-party toolbar buttons: `Button` from `host.ui` with
|
|
`variant="ghost"`, `size="icon"`, `h-7 w-7`, `cursor-pointer`, and a 16px
|
|
(`h-4 w-4`) icon. Wrap it in `host.ui.Tooltip` so it reads like the native
|
|
mic/attach controls. `kandev-plugin-hello/ui/bundle.js` ships a working
|
|
example.
|
|
|
|
### Session top bar
|
|
|
|
Register a `chat-top-bar` component to surface at-a-glance status in the
|
|
session top bar, beside first-party document/editor/debug controls. The host passes the current context as
|
|
`slotProps`:
|
|
|
|
```ts
|
|
type ChatTopBarSlotProps = {
|
|
taskId: string | null;
|
|
taskTitle?: string;
|
|
workspaceId: string | null;
|
|
activeSessionId: string | null; // session the top bar is bound to
|
|
sessionIds: string[]; // every kandev session id on the task
|
|
};
|
|
```
|
|
|
|
Like `chat-input-actions`, both the active session and the full `sessionIds`
|
|
list are provided (see the note above about resolving kandev session ids to
|
|
ACP transcript ids server-side). The top bar is a compact horizontal strip, so
|
|
keep contributions to small badges or `h-7` buttons that match the native
|
|
metric chips.
|
|
|
|
```js
|
|
// inside initialize(registry, host):
|
|
registry.registerComponent("chat-top-bar", makeTopBarStatus(host));
|
|
```
|
|
|
|
### Default app top bar
|
|
|
|
Register a `main-top-bar` component to add status or a small action to the
|
|
**default app top bar** — the strip across the Home, Kanban, and Tasks views,
|
|
beside the CPU/DB metrics and the view/display controls. This is the app-wide,
|
|
task-agnostic counterpart to `chat-top-bar`: use it for something that isn't
|
|
tied to one session (a workspace-level indicator, a global quick action). The
|
|
host passes:
|
|
|
|
```ts
|
|
type MainTopBarSlotProps = {
|
|
workspaceId: string | null; // workspace the top bar is showing, null on global home
|
|
workspaceLabel?: string; // human-readable workspace name, when known
|
|
currentPage: "kanban" | "tasks";
|
|
};
|
|
```
|
|
|
|
Because the bar is not scoped to a task, no task/session ids are provided. Like
|
|
`chat-top-bar` it is a compact horizontal strip, so keep contributions to small
|
|
badges or `h-7` buttons that match the native metric chips.
|
|
|
|
```js
|
|
// inside initialize(registry, host):
|
|
registry.registerComponent("main-top-bar", makeAppBarStatus(host));
|
|
```
|
|
|
|
### Plugin settings page
|
|
|
|
Register a `plugin-settings` component to render your own UI inline on your
|
|
plugin's settings page (**Settings > Plugins > `<plugin>`**), at the top above
|
|
the schema-driven settings form. Use it for live integration health — e.g. "CLI
|
|
installed ✅ v0.45.2" or "API token ✅ authenticated" — or any custom controls
|
|
alongside the config form. The host passes:
|
|
|
|
```ts
|
|
type PluginSettingsSlotProps = {
|
|
pluginId: string; // the plugin whose settings page is being viewed (always yours)
|
|
status: "registered" | "active" | "error" | "disabled" | "uninstalled";
|
|
};
|
|
```
|
|
|
|
Unlike the other slots, `plugin-settings` is **owner-scoped**: the host renders
|
|
only the component registered by the plugin currently being viewed, so your card
|
|
appears on your own settings page and never on another plugin's — you do **not**
|
|
need to gate on `slotProps.pluginId` yourself. The host provides no wrapper card,
|
|
so your component owns its own card and can render `null` when it has nothing to
|
|
show.
|
|
|
|
```js
|
|
// inside initialize(registry, host):
|
|
registry.registerComponent("plugin-settings", makeSettingsStatus(host));
|
|
```
|
|
|
|
### Global Status bar
|
|
|
|
Register `app-status-bar-left` or `app-status-bar-right` for app-wide, compact
|
|
status UI. Kandev mounts exactly one presentation: a 24 px bar on tablet and
|
|
desktop, or an in-flow Status drawer section on phone. Keep bar content small;
|
|
render a touch-usable row when `presentation` is `"mobile-drawer"`.
|
|
|
|
```js
|
|
function StatusContribution({ slotProps }) {
|
|
const { placement, presentation, activeTaskId } = slotProps ?? {};
|
|
return host.jsx(
|
|
"span",
|
|
{ className: presentation === "bar" ? "truncate text-xs" : "block min-h-11 px-3 py-2" },
|
|
`${placement}: ${activeTaskId ?? "no active task"}`,
|
|
);
|
|
}
|
|
|
|
registry.registerComponent("app-status-bar-left", StatusContribution);
|
|
registry.registerComponent("app-status-bar-right", StatusContribution);
|
|
```
|
|
|
|
Each contribution receives this exact context:
|
|
|
|
```ts
|
|
type AppStatusBarSlotProps = {
|
|
placement: "left" | "right";
|
|
presentation: "bar" | "mobile-drawer";
|
|
density: "full" | "compact";
|
|
pathname: string;
|
|
activeWorkspaceId: string | null;
|
|
activeTaskId: string | null;
|
|
activeSessionId: string | null;
|
|
};
|
|
```
|
|
|
|
The IDs are hints; use `host.store` for full records. Each component registration
|
|
is one opaque item: Kandev does not inspect or separately reorder its children.
|
|
The slot chooses the default side. A user can Cmd-drag (macOS) or Ctrl-drag
|
|
(other desktop platforms) with a mouse across the full bar, and Kandev preserves
|
|
that backend-owned order across reloads, restarts, and plugin disable/enable.
|
|
Phone lists the saved left sequence followed by the saved right sequence and does
|
|
not offer drag ordering. There is no keyboard-arrow, touch, or plugin-priority
|
|
ordering API. Enable, disable, and uninstall update the live surface without a
|
|
reload, and each contribution has its own error boundary. A full-bleed route
|
|
(`topbar: false`) owns its own chrome; mount the host Status trigger there if that
|
|
route should expose Status.
|
|
|
|
## Keybindings
|
|
|
|
Declare each keybinding in `manifest.yaml`'s `ui.keybindings` (requires
|
|
`ui.bundle`), then bind a handler at runtime with `registry.registerKeybinding`:
|
|
|
|
```yaml
|
|
ui:
|
|
bundle: "/ui/bundle.js"
|
|
keybindings:
|
|
- id: "open-panel" # plugin-local slug: ^[a-z0-9][a-z0-9-]*$
|
|
default: "mod+shift+j" # combo grammar, see below
|
|
description: "Open the Acme panel"
|
|
```
|
|
|
|
```js
|
|
registry.registerKeybinding("open-panel", () => {
|
|
host.openModal({ title: "Acme panel", content: AcmePanel });
|
|
});
|
|
```
|
|
|
|
The `default` combo is `+`-separated: zero or more modifiers from
|
|
`mod | ctrl | cmd | meta | alt | option | shift` (`mod` resolves to ⌘ on
|
|
macOS and Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not
|
|
combine with a digit or symbol key — the browser reports the shifted glyph
|
|
for those keys (e.g. `shift+1` reports `!`), so the combo could never match.
|
|
|
|
A plugin keybinding is **user-overridable** at **Settings > Keyboard
|
|
Shortcuts**, namespaced `plugin:{pluginId}:{id}` so two plugins can each
|
|
declare their own `open-panel` id without colliding. If a plugin's effective
|
|
combo conflicts with a core kandev shortcut, the core shortcut always wins —
|
|
the plugin handler is skipped for that combo — and the conflict is surfaced
|
|
in that same settings page so the user can rebind it.
|
|
|
|
</details>
|
|
|
|
## Three integration patterns
|
|
|
|
Both example repos are full, working plugins — read them rather than
|
|
copy-pasting fragments:
|
|
|
|
1. **Event handling with Host state** (`kandev-plugin-hello/server/plugin.go`).
|
|
`OnEvent` receives `task.created` deliveries (declared in
|
|
`capabilities.events`) and increments a persistent counter via
|
|
`Host.GetState`/`SetState`, scoped `instance`. Demonstrates the full
|
|
Host state round trip and idempotent-by-design event handling.
|
|
|
|
2. **Live host-store reads from native UI**
|
|
(`kandev-plugin-hello/ui/bundle.js`, `useKanbanTasks`). A plugin page
|
|
subscribes directly to `host.store` and re-renders on every store change
|
|
— reading the exact same live state the first-party kanban UI renders
|
|
from, no polling and no extra API calls.
|
|
|
|
3. **Webhook-as-relay + `TaskCreateDialog`**
|
|
(`kandev-plugin-github/server/plugin.go` and `ui/bundle.js`). The backend
|
|
declares a `prs` webhook and, on `HandleWebhook`, shells out to the `gh`
|
|
CLI (inheriting the plugin subprocess's own environment for GitHub auth)
|
|
and returns JSON. The UI page calls `host.api.fetch("webhooks/prs")` —
|
|
which kandev relays over gRPC `HandleWebhook` to the same handler — to use
|
|
an external CLI as a same-origin data source without a webhook secret.
|
|
Each PR row opens `host.ui.TaskCreateDialog` prefilled from the PR,
|
|
reading `workspaceId`/`workflowId`/`steps` from the live host store.
|
|
|
|
## Packaging
|
|
|
|
<details>
|
|
<summary>Packaging, publishing, and iteration</summary>
|
|
|
|
A plugin ships as `<id>-<version>.tar.gz`:
|
|
|
|
```
|
|
manifest.yaml # authoritative; read BEFORE any code runs
|
|
server/plugin-<goos>-<goarch>[.exe] # any subset of platforms declared in runtime.executables
|
|
ui/bundle.js # optional
|
|
ui/*.css / assets/icon.svg # optional
|
|
checksums.txt # generated — do not author this file
|
|
checksums.txt.sig # optional ed25519 signature
|
|
```
|
|
|
|
Cross-compile one executable per platform you declare in
|
|
`runtime.executables`, then pack the staged directory with
|
|
`github.com/kandev/kandev/cmd/plugin-pack`:
|
|
|
|
```
|
|
plugin-pack -dir <staged-dir> -out <id>-<version>.tar.gz [-platform-only]
|
|
```
|
|
|
|
`plugin-pack` walks the directory, requires `manifest.yaml` to be present,
|
|
and **generates `checksums.txt`** covering every other file — pre-supplying
|
|
either checksum file yourself is a packaging error. `-platform-only`
|
|
restricts the package to the current host's platform, for faster local
|
|
iteration than a full multi-platform build.
|
|
|
|
A sample `Makefile` (trimmed from `kandev-plugin-hello/Makefile`):
|
|
|
|
```makefile
|
|
STAGE := .build/stage
|
|
VERSION := 1.0.0
|
|
PKG_OUT := my-plugin-$(VERSION).tar.gz
|
|
|
|
package:
|
|
rm -rf $(STAGE)
|
|
mkdir -p $(STAGE)/server
|
|
cp manifest.yaml $(STAGE)/manifest.yaml
|
|
cp -r ui $(STAGE)/ui
|
|
GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server
|
|
GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server
|
|
GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server
|
|
GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server
|
|
GOOS=windows GOARCH=amd64 go build -o $(STAGE)/server/plugin-windows-amd64.exe ./server
|
|
go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT)
|
|
rm -rf $(STAGE)
|
|
|
|
# Faster local loop: host platform only.
|
|
package-host:
|
|
rm -rf $(STAGE)
|
|
mkdir -p $(STAGE)/server
|
|
cp manifest.yaml $(STAGE)/manifest.yaml
|
|
cp -r ui $(STAGE)/ui
|
|
go build -o $(STAGE)/server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE) ./server
|
|
go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only
|
|
rm -rf $(STAGE)
|
|
```
|
|
|
|
Install the resulting tarball via **Settings > Plugins** (upload) or:
|
|
|
|
```bash
|
|
curl -F "package=@my-plugin-1.0.0.tar.gz" http://localhost:38429/api/plugins/install
|
|
```
|
|
|
|
To show a custom card icon in the marketplace, ship an image in the package
|
|
(e.g. `assets/icon.svg`) and point the manifest's [`icon`
|
|
field](plugins-manifest.md#field-reference) at its package-relative path.
|
|
|
|
## Publishing to the marketplace
|
|
|
|
To make your plugin discoverable and one-click installable from inside kandev,
|
|
publish the `<id>-<version>.tar.gz` as a GitHub **Release** asset and either
|
|
open a PR listing your repo in the official catalog or host your own source. A
|
|
separate release-level `checksums.txt` is optional; the checksum manifest inside
|
|
the package is mandatory. See [Plugin marketplace →
|
|
Publishing](plugins-marketplace.md#publishing-a-plugin).
|
|
|
|
## Iterate loop
|
|
|
|
- **UI-only or backend-only change:** edit the source, repackage
|
|
(`make package-host` for a fast host-only build), then reinstall. There is
|
|
no kandev rebuild needed for plugin-only changes — the extracted package
|
|
and running subprocess are entirely separate from the kandev binary.
|
|
- **`registry`/`host.ui`/host-core changes:** these live inside kandev
|
|
itself (`apps/web/lib/plugins/`), not the plugin package — changing them
|
|
requires a kandev build (`make build-web` / `make build-backend`), not a
|
|
plugin repackage.
|
|
- **Reinstalling the same version is rejected.** `pkgtar.Install` fails with
|
|
`ErrVersionExists` (HTTP 409) if `~/.kandev/plugins/<id>/<version>/`
|
|
already exists. To pick up a rebuilt package during iteration, either bump
|
|
`version` in `manifest.yaml` or uninstall the plugin first.
|
|
|
|
## Gotchas
|
|
|
|
- **`ui.bundle` and `ui.styles` are root-relative paths**, e.g.
|
|
`/ui/bundle.js` — not `ui/bundle.js`. Kandev serves them from
|
|
`GET /api/plugins/{id}/bundle` and `GET /api/plugins/{id}/ui/*`, resolved
|
|
against the extracted package directory.
|
|
- **Plugin pages are "unknown" SPA routes, but still get the full app
|
|
shell.** `registerRoute` paths are resolved after every static/nested
|
|
first-class route and before the kanban catch-all — a plugin can't shadow
|
|
a built-in route, but its page still renders inside the normal boot-payload-hydrated
|
|
app shell (workspaces, settings, theme), not a bare unstyled page.
|
|
- **Version bump or uninstall required to reinstall** — see "Iterate loop"
|
|
above.
|
|
- **Windows executables need the `.exe` suffix** in both the built file name
|
|
and the `runtime.executables` manifest value (e.g.
|
|
`windows-amd64: server/plugin-windows-amd64.exe`) — kandev does not append
|
|
it for you.
|
|
|
|
Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md).
|
|
|
|
</details>
|