Files
kandev/docs/specs/office/plugins.md
T
2026-05-16 20:07:57 +01:00

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:

  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.

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:

  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.