16 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| draft | 2026-04-26 | 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:
- 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.
- 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.
- 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_urland 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.
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)
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:
- Operator starts plugin process.
- Operator calls
POST /api/plugins/registerwith manifest. - Kandev validates schema, checks ID conflicts.
- Kandev generates
webhook_secret(HMAC) andapi_key(plugin->kandev auth). - Kandev stores record on disk + in-memory registry.
- Kandev calls plugin's health endpoint.
- Healthy -> status
active; unreachable -> statusregistered(retries on next health cycle). - Kandev returns cleartext
api_keyandwebhook_secretonce.
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}/commentsGET /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/*.ymland 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_keyandwebhook_secretare 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/registerwith the plugin manifest, THEN kandev validates the manifest, generates API key and webhook secret, stores the registration at~/.kandev/plugins/kandev-plugin-slack.yml, callsGET http://localhost:9100/health, and returns the credentials. The plugin appears inGET /api/pluginswith statusactive. -
GIVEN an active Slack plugin subscribed to
task.state_changed, WHEN a task moves todone, THEN kandev POSTs the event tohttp://localhost:9100/eventswith 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_searchagent tool, WHEN an agent callsjira_searchwith query "auth migration", THEN kandev POSTs the tool call tohttp://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-webhookswebhook, WHEN Jira POSTs a webhook tohttps://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 callsPATCH /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 toactiveand buffered events are delivered in order. -
GIVEN a plugin with
api_read: ["tasks"], WHEN the plugin callsGET /api/office/agents, THEN kandev returns403 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 toplugin.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}/statewith{ scope: "task", scope_id: "task_xyz", key: "jira_issue_id", value: "PROJ-123" }, THEN the state is persisted in SQLite. A subsequentGET /api/plugins/{id}/state/jira_issue_id?scope=task&scope_id=task_xyzreturns"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.