312 lines
16 KiB
Markdown
312 lines
16 KiB
Markdown
---
|
|
status: draft
|
|
created: 2026-04-26
|
|
owner: cfl
|
|
---
|
|
|
|
# Office: Plugin System
|
|
|
|
## Why
|
|
|
|
Kandev's office feature is building a growing list of external integrations directly into the core codebase: GitHub PR sync, Jira ticket browsing, notification providers (Apprise, system notifications), and planned channel types (Telegram, Slack, Discord, email). Each integration adds platform-specific logic - API clients, webhook handlers, payload formatting, OAuth flows, secret management - to the Go backend. This creates three problems:
|
|
|
|
1. **Core bloat.** Every new integration increases the surface area of the backend. Adding Slack, Discord, Telegram, Linear, and others at similar scale makes the backend unmaintainable.
|
|
2. **Release coupling.** Fixing a bug in the Telegram webhook parser requires a kandev release. Users who don't use Telegram still receive the code. Integration authors cannot ship independently of the core release cycle.
|
|
3. **Extensibility ceiling.** Users cannot add their own integrations without forking kandev.
|
|
|
|
A plugin system decouples integrations from core. Plugins run as separate processes, communicate with kandev over HTTP, and extend office through well-defined capabilities. The core stays small; the ecosystem grows independently.
|
|
|
|
## What
|
|
|
|
- Plugins SHALL run as **separate processes** and communicate with kandev over HTTP only - no in-process loading, no language-specific SDK requirement for v1.
|
|
- A plugin manifest declares identity, endpoints, capabilities, declared tools, declared webhooks, and config schema.
|
|
- Plugins SHALL receive event webhooks, register agent tools, expose proxied external webhook endpoints, and read/write a plugin-scoped KV state.
|
|
- All plugin <-> kandev requests SHALL be authenticated (API key) and signed (HMAC-SHA256).
|
|
- Capability-based access control: a plugin can only call kandev APIs it declared in its manifest.
|
|
- Operators manage plugin processes themselves; kandev only knows `base_url` and health endpoint.
|
|
|
|
## Data model
|
|
|
|
### Plugin registration (filesystem-backed)
|
|
|
|
Stored at `~/.kandev/plugins/{plugin_id}.yml` (global to the kandev instance, not workspace-scoped). Cleartext `api_key` and `webhook_secret` are returned once at registration and stored only as bcrypt hashes.
|
|
|
|
```yaml
|
|
id: "kandev-plugin-slack" # Unique, pattern: ^[a-z0-9][a-z0-9._-]*$
|
|
api_version: 1
|
|
version: "1.0.0"
|
|
display_name: "Slack Notifications"
|
|
description: "Post to Slack on task events, relay messages to agents"
|
|
author: "kandev"
|
|
categories: ["connector"] # connector | automation | tools | analytics
|
|
|
|
base_url: "http://localhost:9100"
|
|
|
|
endpoints:
|
|
health: "/health" # GET, returns 200 if healthy
|
|
events: "/events" # POST, receives event webhooks
|
|
tools: "/tools/{tool_name}" # POST, receives tool invocations
|
|
webhooks: "/webhooks/{webhook_key}" # POST, receives proxied external webhooks
|
|
|
|
capabilities:
|
|
events: ["task.created", "task.state_changed", "office.comment.created", "office.approval.created"]
|
|
api_read: ["tasks", "agents", "projects"]
|
|
api_write: ["tasks", "comments"]
|
|
state: true
|
|
secrets: true
|
|
|
|
tools:
|
|
- name: "slack_send_message"
|
|
display_name: "Send Slack Message"
|
|
description: "Posts a message to a Slack channel"
|
|
input_schema:
|
|
type: object
|
|
properties:
|
|
channel: { type: string }
|
|
text: { type: string }
|
|
required: ["channel", "text"]
|
|
|
|
webhooks:
|
|
- key: "slack-events"
|
|
description: "Slack Events API webhook"
|
|
method: "POST"
|
|
|
|
config_schema:
|
|
type: object
|
|
properties:
|
|
bot_token_secret: { type: string, description: "Secret reference for Slack bot token" }
|
|
default_channel: { type: string, description: "Default channel for notifications" }
|
|
notify_on_task_created: { type: boolean, default: true }
|
|
required: ["bot_token_secret", "default_channel"]
|
|
|
|
# Runtime fields managed by kandev:
|
|
status: "active"
|
|
api_key_hash: "<bcrypt hash>"
|
|
webhook_secret_hash: "<bcrypt hash>"
|
|
registered_at: "2026-04-26T10:00:00Z"
|
|
last_health_check: "2026-04-26T10:30:00Z"
|
|
```
|
|
|
|
Plugin config (operator-editable, conforms to `config_schema`) lives at `~/.kandev/plugins/{plugin_id}.config.yml`.
|
|
|
|
### `plugin_state` (SQLite)
|
|
|
|
```sql
|
|
CREATE TABLE plugin_state (
|
|
id TEXT PRIMARY KEY,
|
|
plugin_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL DEFAULT 'instance', -- instance | workspace | task | agent
|
|
scope_id TEXT, -- NULL for instance scope
|
|
state_key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE (plugin_id, scope, scope_id, state_key)
|
|
);
|
|
```
|
|
|
|
## API surface
|
|
|
|
### Plugin registration & management (operator -> kandev)
|
|
|
|
```
|
|
POST /api/plugins/register # Register a new plugin (manifest YAML in body)
|
|
GET /api/plugins # List registered plugins
|
|
GET /api/plugins/{id} # Get plugin detail
|
|
PATCH /api/plugins/{id} # Update plugin config
|
|
DELETE /api/plugins/{id} # Uninstall plugin
|
|
POST /api/plugins/{id}/enable
|
|
POST /api/plugins/{id}/disable
|
|
```
|
|
|
|
Registration flow:
|
|
1. Operator starts plugin process.
|
|
2. Operator calls `POST /api/plugins/register` with manifest.
|
|
3. Kandev validates schema, checks ID conflicts.
|
|
4. Kandev generates `webhook_secret` (HMAC) and `api_key` (plugin->kandev auth).
|
|
5. Kandev stores record on disk + in-memory registry.
|
|
6. Kandev calls plugin's health endpoint.
|
|
7. Healthy -> status `active`; unreachable -> status `registered` (retries on next health cycle).
|
|
8. Kandev returns cleartext `api_key` and `webhook_secret` once.
|
|
|
|
### Event webhook delivery (kandev -> plugin)
|
|
|
|
```
|
|
POST {plugin.base_url}{plugin.endpoints.events}
|
|
Content-Type: application/json
|
|
X-Kandev-Plugin-Id: kandev-plugin-slack
|
|
X-Kandev-Signature: sha256=<hmac>
|
|
X-Kandev-Delivery-Id: del_abc123
|
|
X-Kandev-Event-Type: task.state_changed
|
|
|
|
{
|
|
"event_id": "evt_abc123",
|
|
"event_type": "task.state_changed",
|
|
"occurred_at": "2026-04-26T10:30:00Z",
|
|
"workspace_id": "ws_default",
|
|
"payload": { "task_id": "task_xyz", "old_status": "in_progress", "new_status": "done", "agent_instance_id": "agent_frontend" }
|
|
}
|
|
```
|
|
|
|
HMAC: `HMAC-SHA256(webhook_secret, raw_body)`. Expected response: `200 OK`. Non-2xx triggers retry.
|
|
|
|
Delivery semantics:
|
|
- **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.
|
|
|
|
Event types: all from `internal/events/types.go` plus office-specific events.
|
|
|
|
| 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` |
|
|
| Office | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.comment.created`, `office.approval.created`, `office.approval.resolved`, `office.cost.recorded`, `office.wakeup.queued`, `office.wakeup.processed`, `office.routine.triggered`, `office.inbox.item` |
|
|
| GitHub | `github.pr_state_changed`, `github.pr_feedback`, `github.new_issue` |
|
|
| Plugin | `plugin.<plugin_id>.<name>` (cross-plugin events) |
|
|
|
|
Wildcard subscriptions: `task.*`, `office.*`, `agent.*`.
|
|
|
|
### Agent tool invocation (kandev -> plugin)
|
|
|
|
When an agent calls a plugin tool, kandev POSTs to the plugin's tool endpoint:
|
|
|
|
```
|
|
POST {plugin.base_url}/tools/slack_send_message
|
|
{ "tool_call_id": "tc_123", "input": { "channel": "#dev", "text": "Build passed" },
|
|
"context": { "task_id": "...", "agent_instance_id": "...", "session_id": "..." } }
|
|
```
|
|
|
|
Response: `{ "output": { "message_id": "M123", "channel": "#dev" } }`. Timeout: 30 seconds. Tools available to all agents by default (`tool_scope` for role/instance restrictions is future work).
|
|
|
|
### External webhook proxy (external -> kandev -> plugin)
|
|
|
|
```
|
|
POST /api/plugins/{plugin_id}/webhooks/{webhook_key}
|
|
```
|
|
|
|
Kandev acts as reverse proxy: validates plugin is active and webhook key is declared, forwards body + headers to the plugin's webhook endpoint, adds `X-Plugin-Id` and `X-Webhook-Key`. The plugin verifies the external system's signature (Slack signing secret, GitHub webhook secret, etc.).
|
|
|
|
### Plugin state API (plugin -> kandev)
|
|
|
|
```
|
|
GET /api/plugins/{id}/state?scope={scope}&scope_id={id}&key={key}
|
|
POST /api/plugins/{id}/state
|
|
Body: { "scope": "task", "scope_id": "task_xyz", "key": "sync_status", "value": { "synced": true } }
|
|
DELETE /api/plugins/{id}/state?scope={scope}&scope_id={id}&key={key}
|
|
GET /api/plugins/{id}/state/list?scope={scope}&scope_id={id}
|
|
```
|
|
|
|
Scopes: `instance`, `workspace`, `task`, `agent`. Plugins cannot read other plugins' state.
|
|
|
|
### Plugin secrets API (plugin -> kandev)
|
|
|
|
```
|
|
GET /api/plugins/{plugin_id}/secrets/{ref}
|
|
```
|
|
|
|
Resolves a secret reference through kandev's `internal/secrets/` package. Requires `capabilities.secrets: true`.
|
|
|
|
### Read/write office data (plugin -> kandev)
|
|
|
|
Plugins authenticate with their API key:
|
|
|
|
```
|
|
Authorization: Bearer <api_key>
|
|
```
|
|
|
|
Read endpoints (gated by `capabilities.api_read`):
|
|
- `GET /api/office/tasks`, `/api/office/tasks/{id}`, `/api/office/tasks/{id}/comments`
|
|
- `GET /api/office/agents`, `/api/office/agents/{id}`
|
|
- `GET /api/office/projects`, `/api/office/costs`, `/api/office/activity`, `/api/office/approvals`
|
|
|
|
Write endpoints (gated by `capabilities.api_write`):
|
|
- `POST /api/office/tasks`, `PATCH /api/office/tasks/{id}`
|
|
- `POST /api/office/tasks/{id}/comments` (`source: "plugin:<plugin_id>"`)
|
|
- `POST /api/office/wakeups`, `POST /api/office/activity`
|
|
|
|
### Cross-plugin events
|
|
|
|
```
|
|
POST /api/plugins/{plugin_id}/events/emit
|
|
Body: { "event_name": "sync-completed", "payload": { "count": 5 } }
|
|
```
|
|
|
|
Published as `plugin.<plugin_id>.sync-completed` and delivered to subscribers.
|
|
|
|
## State machine
|
|
|
|
```
|
|
registered -> active -> disabled -> uninstalled
|
|
| |
|
|
+-> error -+
|
|
```
|
|
|
|
| State | Meaning |
|
|
|---|---|
|
|
| `registered` | Manifest stored; plugin process expected running but health not yet verified |
|
|
| `active` | Health check passes; events delivered, tools available, webhooks proxied |
|
|
| `error` | Health check failed 3x consecutively (30s interval). Events buffered (ring buffer, 100 events, 5-minute TTL). Tools removed from agent tool set. Webhooks return 503 |
|
|
| `disabled` | Operator explicitly disabled. No events, no tools, no webhooks. State and config preserved |
|
|
| `uninstalled` | Removed. State deleted after 24-hour grace period (force-delete available) |
|
|
|
|
Health monitoring: every 30 seconds, `GET {base_url}{endpoints.health}` must return `200 OK` within 5 seconds. 3 consecutive failures -> `error` + inbox item. Next success -> `active`, queued events delivered in order.
|
|
|
|
## Permissions
|
|
|
|
- Plugins are global to the kandev instance, registered by the operator. There is no per-user plugin access in v1.
|
|
- Capability-based access control: undeclared capabilities return `403 Forbidden: capability '<name>' not declared`.
|
|
- A plugin's API key maps to its plugin ID; capability checks evaluate against the registered manifest on every request.
|
|
|
|
## Security
|
|
|
|
- **HMAC-SHA256 webhook signatures** on every kandev -> plugin request (`X-Kandev-Signature`).
|
|
- **Per-plugin API keys** for plugin -> kandev requests (`Authorization: Bearer <api_key>`). Stored only as bcrypt hashes.
|
|
- **Capability-based access control** evaluated per request.
|
|
- **Network**: plugins run on the same host or LAN as kandev. No internet-facing plugin API in v1. Remote plugins are the operator's responsibility (VPN, SSH tunnel).
|
|
|
|
## Failure modes
|
|
|
|
- **Plugin unreachable for 3 consecutive health checks (90s)**: status -> `error`. Events buffered (100, 5min TTL). Tools hidden from agent tool set. Webhooks return 503. Inbox item created.
|
|
- **Buffer overflows (>100 events or >5min)**: oldest events dropped and logged.
|
|
- **Plugin returns non-2xx on event delivery**: retry up to 3 times with exponential backoff (5s, 15s, 45s). After exhaustion, event is logged as failed and dropped.
|
|
- **Tool call timeout (>30s)**: agent receives a timeout error.
|
|
- **External webhook hits a disabled/error plugin**: kandev returns 503.
|
|
- **Undeclared capability access attempt**: 403 with clear error message naming the missing capability.
|
|
|
|
## Persistence guarantees
|
|
|
|
- Plugin registration manifests persist at `~/.kandev/plugins/*.yml` and survive backend restarts.
|
|
- Plugin state in SQLite survives restarts.
|
|
- Event delivery buffer is in-memory; events in the buffer do not survive a backend restart.
|
|
- Cleartext `api_key` and `webhook_secret` are returned exactly once at registration. Lost credentials require re-registration.
|
|
|
|
## Scenarios
|
|
|
|
- **GIVEN** an operator with a Slack notification plugin process running on localhost:9100, **WHEN** the operator calls `POST /api/plugins/register` with the plugin manifest, **THEN** kandev validates the manifest, generates API key and webhook secret, stores the registration at `~/.kandev/plugins/kandev-plugin-slack.yml`, calls `GET http://localhost:9100/health`, and returns the credentials. The plugin appears in `GET /api/plugins` with status `active`.
|
|
|
|
- **GIVEN** an active Slack plugin subscribed to `task.state_changed`, **WHEN** a task moves to `done`, **THEN** kandev POSTs the event to `http://localhost:9100/events` with HMAC signature. The plugin verifies the signature, formats a Slack message, and calls the Slack API.
|
|
|
|
- **GIVEN** a Jira sync plugin that registered the `jira_search` agent tool, **WHEN** an agent calls `jira_search` with query "auth migration", **THEN** kandev POSTs the tool call to `http://localhost:9200/tools/jira_search`. The plugin queries Jira, returns results. The agent receives the search results as the tool output.
|
|
|
|
- **GIVEN** a Jira sync plugin with a registered `jira-webhooks` webhook, **WHEN** Jira POSTs a webhook to `https://kandev.example.com/api/plugins/kandev-plugin-jira/webhooks/jira-webhooks`, **THEN** kandev proxies the request to the plugin's webhook endpoint. The plugin parses the Jira event and calls `PATCH /api/office/tasks/{id}` to update the linked task's status.
|
|
|
|
- **GIVEN** an active plugin that becomes unreachable (process crash), **WHEN** 3 consecutive health checks fail (90 seconds), **THEN** kandev marks the plugin as `error`, creates an inbox item "Plugin kandev-plugin-slack is unreachable", and buffers events (up to 100 or 5 minutes). **WHEN** the operator restarts the plugin and the next health check succeeds, **THEN** status returns to `active` and buffered events are delivered in order.
|
|
|
|
- **GIVEN** a plugin with `api_read: ["tasks"]`, **WHEN** the plugin calls `GET /api/office/agents`, **THEN** kandev returns `403 Forbidden: capability 'api_read:agents' not declared`.
|
|
|
|
- **GIVEN** two plugins (Slack and Jira), **WHEN** the Jira plugin emits a cross-plugin event `plugin.kandev-plugin-jira.sync-completed`, **THEN** the Slack plugin (subscribed to `plugin.kandev-plugin-jira.*`) receives the event and posts a sync summary to Slack.
|
|
|
|
- **GIVEN** a plugin with state, **WHEN** the plugin calls `POST /api/plugins/{id}/state` with `{ scope: "task", scope_id: "task_xyz", key: "jira_issue_id", value: "PROJ-123" }`, **THEN** the state is persisted in SQLite. A subsequent `GET /api/plugins/{id}/state/jira_issue_id?scope=task&scope_id=task_xyz` returns `"PROJ-123"`.
|
|
|
|
## Out of scope
|
|
|
|
- **UI extensions.** Plugins cannot add tabs, widgets, pages, or other UI elements to the kandev frontend in v1. Plugins affect the system through data which the existing UI renders.
|
|
- **In-process plugins.** No Go plugin loading, no WASM, no shared-memory communication.
|
|
- **Plugin marketplace or registry.** No central catalog, no one-click install, no automatic discovery.
|
|
- **Plugin process management.** Kandev does not start, stop, restart, or upgrade plugin processes.
|
|
- **Hot reload.** Changing a manifest requires re-registration.
|
|
- **Multi-instance plugins.** Each plugin ID maps to exactly one base URL.
|
|
- **Rate limiting.** No per-plugin rate limits in v1. Misbehaving plugins can be disabled manually.
|
|
- **Plugin database namespaces.** Plugins do not get their own SQLite schemas. KV state is sufficient for v1.
|