--- status: draft system: plugins requirements: - REQ-PLUGINS-PLUGINS-001 created: 2026-04-26 owners: - cfl --- # Plugin System System Design Part 2 ## Purpose and boundaries This design preserves the technical source detail for `REQ-PLUGINS-PLUGINS-001` during migration. ## Requirement mapping | Requirement | Design section | | --- | --- | | `REQ-PLUGINS-PLUGINS-001` | [Migrated source detail](#migrated-source-detail) | ## Migrated source detail ## API surface ### Plugin management API (operator -> kandev, HTTP) Install is administrator-only when authentication is enabled. Auth-disabled single-user instances retain the synthetic administrator used by other system settings. ``` POST /api/plugins/install # Install a plugin: JSON {"url": "..."} or multipart `package` POST /api/plugins/sync # Reconcile the registry with the plugins directory on disk GET /api/plugins # List installed plugins GET /api/plugins/{id} # Get plugin detail GET /api/plugins/{id}/config # Stored operator config; secret values masked # (secret fields live in the encrypted vault; the # config file persists only a vault reference) PATCH /api/plugins/{id} # Update plugin config (masked secrets keep stored values; restarts a running plugin) DELETE /api/plugins/{id} # Uninstall plugin (stops subprocess, removes package + state) POST /api/plugins/{id}/enable POST /api/plugins/{id}/disable GET /api/plugins/{id}/bundle # Frontend bundle, served from the extracted package dir GET /api/plugins/{id}/ui/* # Frontend bundle assets, served from the extracted package dir POST /api/plugins/{id}/actions/{key} # Authenticated declared browser action ``` `POST /api/plugins/sync` is documented in full under "Filesystem sideloading & sync" below. `GET /api/plugins/{id}/bundle` and `/ui/*` are served directly by kandev **from the extracted package directory** (`~/.kandev/plugins///ui/...`) — there is no reverse proxy and no upstream plugin process involved in serving frontend assets, since the files are already on local disk after install. Enable/disable/uninstall act on the supervised subprocess: disable stops it (state and config preserved); enable respawns it; uninstall stops it and deletes its package, record, and state. ### Authenticated plugin actions (browser -> kandev -> plugin) `POST /api/plugins/{id}/actions/{key}` is the only browser action route. Kandev authenticates the caller, rejects inactive plugins and undeclared keys, and authorizes each referenced workspace, task, or repository. It derives a task's workspace server-side. `access` defaults to `authenticated`. `admin` requires `min_kandev_version: "0.91.1"` or later; validation rejects older/missing minimums. Non-admins are rejected before envelope parsing or plugin invocation. Verified context is separate from untrusted JSON; calls are bounded/cancellable and response headers allowlisted. Public `/webhooks/` callbacks cannot serve browser actions. A task action may select one attached persisted repository; other IDs are rejected, and the accepted repository is separate in `VerifiedActionContext`. Canonical field: `scope`; `resource_scope` is a read-only prerelease alias. ### Dynamic composer reference sources (plugin -> kandev) An active manifest `reference_sources` descriptor registers a plugin bridge in `internal/mentions`. Kandev calls `Plugin.SearchEntityReferences` with verified workspace, plain query, and bounded limit; plugin candidates are untrusted. The host injects descriptor identity and constructs canonical references. Kandev calls `Plugin.AuthorizeEntityReference` at search and again at message submission with purpose `search` or `submission`. A disabled plugin, invalid result, stale selection, or denial fails closed; no stale reference metadata enters the message queue. ### Event delivery (kandev -> plugin, gRPC `DeliverEvent`) Kandev calls the plugin's `Plugin.DeliverEvent` RPC (unary) with an `Event` message: ```proto message Event { string event_id = 1; // fresh uuid per delivery string event_type = 2; // bus subject, e.g. "task.created" string occurred_at = 3; // RFC3339 UTC string workspace_id = 4; // empty if not derivable google.protobuf.Struct payload = 5; // marshaled bus event.Data } ``` Expected response: `EventAck{}`. A non-nil gRPC error or a timeout counts as failure. Delivery semantics (unchanged from earlier design, now carried over gRPC): - **At-least-once.** Plugins must be idempotent (dedup by `event_id`). - **Timeout:** 10 seconds. Up to 3 retries with exponential backoff (5s, 15s, 45s). - **Sequential per plugin** — no concurrent delivery to the same plugin. Plugins needing parallel processing queue internally. - **Buffering while unhealthy:** events are held in a ring buffer (100 events, 5-minute TTL) and flushed in order once the plugin recovers (health/Ping succeeds again). Event types: any subject kandev publishes on its internal event bus (`internal/events/types.go`). Plugins subscribe to whatever they need; the catalog below is a non-exhaustive sample across feature areas — the plugin system is not tied to any one of them. | Category | Events | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved` | | Sessions | `task_session.state_changed`, `turn.started`, `turn.completed` | | Agents | `agent.started`, `agent.completed`, `agent.failed`, `agent.stopped` | | Other feature areas (examples) | Any additional subjects emitted by feature areas such as office/agents (e.g. `office.comment.created`, `office.approval.created`) — plugins may subscribe to these, but the plugin system does not depend on them | | GitHub | `github.pr_state_changed`, `github.pr_feedback`, `github.new_issue` | | Plugin | `plugin..` (cross-plugin events) | Wildcard subscriptions: `task.*`, `agent.*`, `.*` (any subject prefix). ### External webhook proxy (external -> kandev -> plugin) ``` POST /api/plugins/{plugin_id}/webhooks/{webhook_key} ``` This is kandev's only plugin-facing **HTTP** endpoint. Anonymous callers need `access: public`; otherwise kandev returns 401 without revealing plugin/key. Kandev validates plugin/key, strips session/PAT, converts to `WebhookRequest`, and calls `Plugin.HandleWebhook`: ```proto message WebhookRequest { string webhook_key = 1; string method = 2; string path = 3; // remainder after the key string query = 4; map headers = 5; // single-valued; multi joined by ", " bytes body = 6; } message WebhookResponse { int32 status = 1; map headers = 2; bytes body = 3; } ``` The `WebhookResponse` is relayed back as the HTTP response. The plugin verifies the external system's signature (Slack signing secret, GitHub webhook secret, etc.) itself. ### Authenticated per-user storage API (browser -> kandev, `host.storage`) ```text GET /api/plugins/{id}/user-state/{scope}/{scopeId} # list, ordered by key GET /api/plugins/{id}/user-state/{scope}/{scopeId}/{key} PUT /api/plugins/{id}/user-state/{scope}/{scopeId}/{key} # body: {value, writerId?, ifUnmodifiedSince?} DELETE /api/plugins/{id}/user-state/{scope}/{scopeId}/{key} ``` Unlike every other plugin HTTP surface, this one is reachable directly from an authenticated browser session (session cookie or PAT) — it is **not** in `httpmw`'s public-path allowlist, and it never touches the plugin's gRPC subprocess. Identity comes from `authn.FromGin`; every read/write is scoped to that user via a `plugin_user_state` row keyed `(plugin_id, user_id, scope, scope_id, state_key)`, which is a separate table from the plugin-owned `plugin_state` (no proto change, no migration between the two). Requires the plugin manifest to declare `capabilities.user_state: true` (`403` otherwise); an unknown/disabled plugin returns `404`; a cross-user `GET` returns `404` (never leaks that the key exists for someone else). `scope` must be one of `instance|workspace|task|session|repository`; `scopeId`/`key` must match `[A-Za-z0-9][A-Za-z0-9._:-]{0,127}`; the body is capped (`413` over the limit). `PUT` accepts an optional `ifUnmodifiedSince`, compared against the stored row's `updated_at` — a conflicting write returns `409` and leaves the stored value unchanged (optimistic concurrency; see ADR 2026-08-01-per-user-plugin-storage). Uninstalling a plugin deletes its `plugin_user_state` rows for every user, not just one. A successful `PUT`/`DELETE` publishes the `plugin.user-state.updated` bus event, routed by the existing `UserEventBroadcaster` (the same user-scoped fan-out `user.settings.updated` uses) to only the writing user's own WS connections. The payload carries `{ pluginId, scope, scopeId, key, updatedAt, writerId, deleted }` — keys only, never the value. ### Host gRPC service (plugin -> kandev) There is no plugin-facing HTTP API for state, secrets, or cross-plugin events. Instead, kandev implements a `Host` gRPC service and serves it back to the plugin over the go-plugin broker (the plugin is the gRPC client for this service): ```proto service Host { rpc GetState(GetStateRequest) returns (GetStateResponse); rpc SetState(SetStateRequest) returns (SetStateResponse); rpc DeleteState(DeleteStateRequest) returns (DeleteStateResponse); rpc ListState(ListStateRequest) returns (ListStateResponse); rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); rpc InvokeUtilityAgent(InvokeUtilityAgentRequest) returns (InvokeUtilityAgentResponse); } ``` - `GetState`/`SetState`/`DeleteState`/`ListState` operate on `plugin_state`, scoped by `scope` (`instance`, `workspace`, `task`, `agent`) and `scope_id`. Plugins cannot read other plugins' state — the Host service instance handed to a plugin's subprocess is bound to that plugin's own ID at spawn time, so there is no plugin-supplied ID to spoof. - `RevealSecret(ref)` resolves a secret reference through kandev's `internal/secrets/` package. Requires `capabilities.secrets: true`. - `EmitEvent(event_name, payload)` publishes `plugin..` on the internal event bus for delivery to subscribers (replaces the old `POST /api/plugins/{plugin_id}/events/emit` HTTP endpoint). - `InvokeUtilityAgent(prompt)` runs a one-shot, non-interactive completion using the utility agent selected in the plugin's `utility_agent` config field and returns its text. Plugins declaring `capabilities.agent_invoke: true` must declare that field in `config_schema` with `type: string` and `format: utility-agent`; Settings > Plugins then renders a picker containing the configured built-in and custom utility agents. The picker displays the agent name and persists its stable ID. It reuses kandev's sessionless host-utility inference tier (ADR 0002) — no task, session, or workspace — so a plugin can delegate a lightweight LLM step without holding a provider API key. Returns gRPC `FailedPrecondition` when no utility agent is configured, selected agent was deleted, or it is disabled. See [ADR 0048](../../../decisions/0048-plugin-host-utility-agent-invoke.md). The plugin does not select an execution profile directly; the selected utility agent resolves its effective profile and complete launch/permission policy according to [Profile-backed Utility Agents](../../agents/requirements/utility-agent-profiles.md). Every Host RPC is capability-gated: `GetState`/`SetState`/`DeleteState`/`ListState` check `capabilities.state`, `RevealSecret` checks `capabilities.secrets`, `InvokeUtilityAgent` checks `capabilities.agent_invoke`, and each Host data API read RPC checks `capabilities.api_read` for its resource, and each Host data API write RPC checks `capabilities.api_write` for its resource (see "Host data API" below) — all before the handler runs, returning gRPC status `PermissionDenied` with message `capability '' not declared` on a miss. `EmitEvent` is ungated (no boolean capability applies). ### Host data API (plugin -> kandev, gRPC) Plugins read and write kandev's own domain data — tasks, sessions, workspaces, workflows, agent profiles, repositories, messages — over the same capability-gated Host gRPC channel they use for state and secrets, instead of opening the kandev database file. The wire contract is the `kandev.plugin.v1` Host data RPCs; DTOs are hand-mapped, versioned proto messages, never internal domain structs. See [ADR 0043](../../../decisions/0043-plugin-host-data-api.md) and `docs/plans/plugins/HOST-DATA-API.proto`. **Readable resources (v1).** Each is gated by an `api_read:` capability: | RPC | Capability | Returns | | ----------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ListTasks` / `GetTask` | `api_read:tasks` | Tasks (id, workspace, workflow, workflow-step ID, title, description, state, priority, timestamps, parent, identifier, repositories, metadata, and deprecated API v1 labels compatibility) | | `ListWorkspaces` | `api_read:workspaces` | Workspaces (id, name, owner, defaults, timestamps) | | `ListWorkflows` | `api_read:workflows` | Workflows for a workspace | | `ListWorkflowSteps` | `api_read:workflows` | Steps for a workflow (`id`, `workflow_id`, `name`, `position`, `stage_type`, `color`, `is_start_step`, `wip_limit`, `agent_profile_id`, `on_enter_action_types`) | | `ListAgentProfiles` | `api_read:agent_profiles` | Agent profiles (id, agent id, display name, model, mode) | | `ListExecutorProfiles` | `api_read:executor_profiles` | Executor profiles (id, display name, executor type) | | `ListRepositories` | `api_read:repositories` | Repositories for a workspace (id, name, default branch) | | `ListSessions` | `api_read:sessions` | Session identity + agent context (id, task, agent profile, resolved display name + model, `acp_session_id`, state, timestamps) | | `ListSessionCodeStats` | `api_read:sessions` | **Computed** per-session code metrics: committed lines added/deleted, peak pending-diff lines added/deleted | | `ListMessages` | `api_read:messages` | Historical conversation content (id, session, task, turn, `author_type` (user/agent), `content`, `type`, `created_at`), filterable by session ids, task ids, a `created_at` range (`since`/`until`), and types. See "Conversation content" below. | `acp_session_id` on a session is the external usage-attribution join key (e.g. `tokscale`): kandev exposes the session identity and code stats but stays out of the token business. `SessionCodeStats` is a deliberately computed shape — the aggregate the agent-stats plugin previously re-derived by hand from `task_session_commits` and `task_session_git_snapshots` — so plugins never touch those raw rows. `Task.labels` field 23 shipped in v0.93.0 and remains generated/readable because API v1 DTO evolution is additive-only. It is deprecated: new provider plugins keep tracker annotations in plugin-owned task state and UI slot data, and the plugin task write contract does not expose label mutation. `on_enter_action_types` on a step lists the on-enter action *types* configured for that step (e.g. `auto_start_agent`, `queue_run`), never the action *config* — target task ids, payloads, and profile ids stay server-side. A type appearing in this list means it is configured, not a guarantee of when or whether it fires. Plugins must ignore action types they do not recognize. **Conversation content (`api_read:messages`, ADR 0047).** `ListMessages` reads historical user/agent message content — the data a "summarize yesterday" plugin needs, which the `message.added` bus event alone (live-only, post-install-only) cannot provide. `MessageFilter` narrows by `session_ids`, `task_ids`, a `created_at` window (`since` inclusive / `until` exclusive, RFC3339), and message `types`; results are ordered oldest-first with opaque cursor pagination. `content` is sanitized the same way the event path is — kandev-injected `` blocks are stripped via `sysprompt.StripSystemContent`, and raw system content is never exposed. `author_type` is only `user` or `agent`: kandev has no `system` author, since system context is inline markup removed at read time. Reads route through the task service's `ListMessagesForPlugin` (a single filtered session/task/time/type query), never a repository or the DB file directly. **Host data API writes.** `CreateTask`, `UpdateTask`, and `SendMessage` are implemented, each gated by `api_write:` and routed through the first-party service layer (never a repository) so the corresponding events fire and WS clients update. The server stamps `source = "plugin:"` — a plugin cannot set provenance itself. `Host.Tasks().Create`/`Update` (capability `api_write:tasks`) go through `internal/task/service` so `task.*` events fire. `CreateTask` fills sane placement defaults when the plugin omits them (single workspace; that workspace's first workflow — ambiguous → `InvalidArgument`), accepts an optional `start_agent` that best-effort auto-launches an agent, and requires a title. `UpdateTask` accepts a conservative field mask — `title` / `description` / `state` / `priority`, each optional (a nil field is left unchanged); a present `workflow_step_id` is rejected and workflow transitions use `MoveTask`. A missing task returns gRPC `NotFound`. `Host.Messages().Send` (capability `api_write:messages`) delivers a prompt to a task session through the orchestrator's real delivery path — the same one `message_task` uses — so the message reaches the agent and drives a turn (it is _not_ an office comment). It resolves the target session (an explicit `session_id`, verified to belong to the task, or the task's primary session), records a user message stamped with the plugin source, and dispatches by session state: a running session queues the prompt (`status: queued`); an idle/completed one is prompted, resuming the agent process if it has gone (`sent`); a never-started one is launched with the prompt as its first turn (`started`). If dispatch fails after the user message was recorded, the recorded message is deleted so a failed delivery leaves no durable user message (and a retry can't stack a duplicate prompt). `task_id` and `text` are required (`InvalidArgument` otherwise); a session that doesn't belong to the task, or a task with no active session, returns `NotFound`; a terminal (failed/cancelled) session is rejected. **Conventions.** - **Pagination** is opaque-cursor based: a request carries `Page{limit, cursor}` and a list response carries `PageInfo{next_cursor, has_more}`. `limit: 0` means the server default; the server caps the maximum. An empty cursor is the first page; echoing `next_cursor` continues. Plugins MUST NOT interpret cursor contents. - **Timestamps** are RFC3339 strings. - **Nullable** string fields use proto `optional`, so absent (NULL) is distinguishable from empty. - **Scoping (v1):** reads are global to the kandev instance (plugins are instance-global; see "Permissions"). Filters (`workspace_ids`, `task_ids`, `states`) narrow results but do not confer or restrict visibility. A server-side scoping hook is reserved for a future per-plugin/per-user restriction without a contract change. - **Ephemeral tasks** (quick-chat) are excluded from `ListTasks` unless the request sets `include_ephemeral`. Every Host data RPC is capability-gated the same way as state/secrets: an undeclared capability returns gRPC `PermissionDenied` with message `capability '' not declared` before the handler runs. Because the Host service instance is bound to the plugin's own ID at spawn time, the check evaluates directly against that plugin's installed manifest. ### Generic Host boundary Manifest declaration remains the package-authored maximum capability set. A capability that needs workspace approval also uses the current `(installation, workspace)` approval and immutable Human-reserved policy. The Host enforces this intersection. The plugin cannot supply an installation identity or grant itself authority. The generic service and capability-family boundaries, domain ownership, versioning, and migration rules are recorded in [ADR-2026-08-31-generic-plugin-host-boundary](../../../decisions/2026-08-31-generic-plugin-host-boundary.md). Host RPC and global MCP adapters can use different request DTOs, but both call the same named domain command or query. Neither adapter duplicates authorization, ownership, idempotency, transition, lifecycle, relation, or audit invariants. The Host can provide a connection-bound capability context for discovery. The context reports supported capability families and approved workspace scope. It does not grant authority. The Host checks current approval for every request. ## Filesystem sideloading & sync Besides the URL/upload install pipeline, an operator with shell access to the host can place plugin content directly under the plugins directory (`~/.kandev/plugins/`) without going through `POST /api/plugins/install`. `POST /api/plugins/sync` (and the Settings > Plugins **Sync** button, which calls it and refreshes the list) reconciles the registry with whatever is actually on disk: 1. **Directory sideloads.** For every `///manifest.yaml` found with no existing `{id}.yml` record, kandev parses and validates the manifest, requires it to be runtime-managed (`runtime.type: binary`) and its `id` field to match the `` directory name, and registers it — always with status **`disabled`**, never `active`. Sideloads are unverified (no checksum, no integrity gate the URL/upload pipeline runs) and are never auto-spawned; an operator must explicitly enable one after inspecting it. If more than one version directory exists for the same unregistered id, the lexically greatest version is registered and the others are reported as skipped, not registered. 2. **Dropped tarballs.** Every `*.tar.gz` file sitting directly in the plugins directory is run through the same verified install pipeline `POST /api/plugins/install` uses (checksum verification, manifest validation, platform executable check, extraction, spawn, activate). On success the tarball file is deleted. On a validation failure the file is left in place (not retried automatically) and the failure is reported. 3. **Missing installs.** Every registered record whose `install_path` no longer exists on disk (deleted out from under kandev) is stopped, if its process is running, and transitioned to status `error`. `POST /api/plugins/sync` returns a `SyncResult`: which plugin ids were newly sideloaded (`added`), which were installed from a dropped tarball (`installed`), which were marked missing (`missing`), and a list of per-item `errors` (path + reason) for anything rejected or skipped along the way. A single item's failure never aborts the rest of the scan. Concurrent sync calls are serialized so an operator double-clicking Sync (or a sync racing the boot scan) cannot double-install the same dropped tarball. At **boot**, kandev runs only the directory-sideload and missing-install steps (never the tarball-install step) as part of resuming previously-active plugins — conservative by design: starting up should never itself spawn a binary an operator has not explicitly approved via install or Sync. What the boot scan found is logged; an operator triggers the full sync (including tarball installs) explicitly via the Sync button or the API.