Spec: Plugin Host

  • Status: Draft
  • Last amended: 2026-05-27 (ADR-0023, ADR-0024 — lifecycle hooks, plugin roles, knob schema, project/system config split, per-agent declaration model)
  • Constrained by: ADR-0008, ADR-0009, ADR-0004, ADR-0010, ADR-0011, ADR-0023, ADR-0024
  • Implements: packages/plugin-host/ (JSON-RPC transport, plugin process lifecycle, supervisor orchestration — implemented; cage policy compilation, storage brokering, install CLI, lifecycle hooks, plugin roles, knob schema — pending)

Purpose

This spec defines the plugin host: the daemon subsystem that spawns, supervises, and communicates with plugins over JSON-RPC 2.0 on stdio. It is the boundary between trusted daemon code and untrusted plugin code.

This document is normative for:

  • The JSON-RPC wire protocol — framing, message encoding, batching rules.
  • The initialization handshake — the initialize / initialized exchange.
  • The method taxonomy — system methods, storage methods, plugin-declared methods, notifications.
  • The manifest schema — kaged-plugin.yaml in full detail, including the per-ADR-0023/0024 extensions (role, hooks, tools, knobs, system_config_schema).
  • The capability allowlist grammar and its translation to a cage policy (bwrap argv).
  • The plugin supervisor — spawn, health, restart, backoff, disable.
  • The install flow — manual, project-load-driven, consent, validation.
  • The plugin-scoped storage model — per-plugin SQLite schema prefix.
  • The plugin SDK contract — what the TypeScript (and future Python/shell) SDKs must expose.
  • The lifecycle hooks, on_session_start, on_session_idle, pre_compact, post_compact, that project plugins subscribe to (ADR-0023).
  • The plugin call context (PluginCallContext) passed on every hook fire and plugin-declared method call.
  • The project/system config splitconfig_schema (committed) vs system_config_schema (operator-local secrets).
  • The plugin roles (observer, compactor) and the compactor return contract (ADR-0024).
  • The knob schema — kaged-defined operator-tunable configuration fields rendered automatically by the UI.
  • The plugin tool naming rule — plugin-name-prefixed for uniqueness.

It is not normative for:

Constraints (from ADRs)

Constraint Source
Plugins are subprocess children; JSON-RPC 2.0 over line-delimited stdio ADR-0008
Plugins are language-agnostic; the contract is the wire protocol, not a language SDK ADR-0008
Every plugin is sandboxed via bwrap; no "trusted" plugin tier ADR-0008, ADR-0009
Plugins cannot talk to each other directly; daemon mediates ADR-0008
Plugin install is operator-consent-gated; no auto-install from internet ADR-0008 amendment
Plugins have scope: local (any project) or project (activated per-project) ADR-0008 amendment
Daemon runtime is Bun; plugin host uses Bun.spawn ADR-0004
Plugin store location depends on deployment mode ADR-0010
Plugins are declared per-agent under AgentSpec.plugins; no inheritance between agents ADR-0023
Lifecycle hooks (on_session_start, on_session_idle, pre_compact, post_compact) are kaged-defined; plugins subscribe via manifest ADR-0023
Plugin config splits into project-committed and operator-local-system halves ADR-0023
Plugin tool names are prefixed with the plugin name for uniqueness ADR-0023
Plugin isolation: 'agent' | 'project' is a kaged-defined field; default 'agent' ADR-0023
Plugins declare a role: 'observer' | 'compactor' (or both) ADR-0024
At most one compactor role per agent ADR-0024
Compactor plugin failures fall back to drop; compaction never stalls ADR-0024
Plugin tunable knobs declared in a kaged-defined schema; UI renders from manifest ADR-0024

Wire protocol

Framing

  • Transport: stdin (daemon → plugin) and stdout (plugin → daemon).
  • Encoding: UTF-8, one JSON object per line, terminated by \n (0x0A). No other framing.
  • Line discipline: Each line is a complete, self-contained JSON-RPC 2.0 message. Partial lines are buffered; lines exceeding 4 MiB are rejected and the plugin is killed (protocol.oversize_message).
  • Stderr: Reserved for plugin logs. The daemon captures stderr line-by-line into the operational log tagged with the plugin name. Plugins must not write JSON-RPC to stderr.
  • Stdout discipline: Plugins must not write anything to stdout that is not a JSON-RPC message. Any non-JSON line on stdout is logged as a protocol error and triggers a warning audit event (plugin.stdout_noise). The SDK provides a log() helper that routes to stderr.

JSON-RPC 2.0 compliance

The wire protocol is JSON-RPC 2.0 per https://www.jsonrpc.org/specification, with these constraints:

  • No batched requests. The daemon never sends a JSON array of requests. Plugins must not send batched responses. If a batch is received by either side, it is rejected with error code -32600 (invalid request).
  • Request IDs are monotonically increasing integers, scoped per direction. The daemon's IDs start at 1. The plugin's IDs start at 1. Both counters are independent.
  • Notifications (requests without id) are fire-and-forget. Neither side sends a response to a notification. Both sides may send notifications at any time after the handshake completes.
  • Error codes use the standard JSON-RPC ranges plus kaged-specific codes (see Error codes).

Message flow

daemon                          plugin
  │                               │
  │──── initialize ──────────────▶│
  │◀─── initialize (response) ───│
  │──── initialized ────────────▶│  (notification, no response)
  │                               │
  │──── method call ────────────▶│  (daemon calls plugin method)
  │◀─── method response ─────────│
  │                               │
  │◀─── notification ─────────────│  (plugin pushes event)
  │                               │
  │──── shutdown ───────────────▶│  (notification)
  │                    plugin exits│

Initialization handshake

The daemon sends initialize as the first message after spawning the plugin process. The plugin must not send any message before receiving initialize.

initialize request (daemon → plugin)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "daemon_version": "0.1.0",
    "api_version": 1,
    "plugin_name": "oh-my-pi",
    "storage_available": true,
    "projects": ["music", "homelab"]
  }
}
Field Type Description
daemon_version string The daemon's semantic version. Informational.
api_version integer The kaged plugin API version the daemon speaks. Currently 1.
plugin_name string The plugin's name as declared in the manifest. The plugin may use this to confirm it was loaded correctly.
storage_available boolean Whether the daemon has storage ready for this plugin (see Plugin-scoped storage).
projects list of strings Project slugs that have activated this plugin. Empty if the plugin is local-scope with no active projects.

initialize response (plugin → daemon)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "oh-my-pi",
    "version": "1.4.2",
    "api_version": 1,
    "methods": ["presets.list", "presets.search", "preset.install", "preset.uninstall"],
    "notifications": ["catalog.updated"],
    "capabilities_used": ["read:fs:/opt/oh-my-pi", "exec:bash:/opt/oh-my-pi"]
  }
}
Field Type Description
name string Must match the manifest's name. Mismatch → daemon kills the plugin (initialize.name_mismatch).
version string Must match the manifest's version. Mismatch → kill (initialize.version_mismatch).
api_version integer The API version the plugin implements. Must equal the daemon's api_version. Mismatch → kill (initialize.api_mismatch).
methods list of strings The methods the plugin exposes. Must be a subset of the manifest's methods. Extra methods → daemon logs warning, ignores them. Missing methods → daemon logs warning, marks them unavailable.
notifications list of strings Notification types the plugin may emit. Informational; the daemon uses this for log filtering and forwarding.
capabilities_used list of strings The capabilities the plugin reports using. Must be a subset of the manifest's capabilities. Extra → kill (initialize.capability_overreach). Fewer → fine.

Handshake failure modes

Condition Daemon action
Plugin sends message before initialize Kill, audit plugin.protocol_violation
Plugin does not respond within 10s Kill, audit plugin.initialize_timeout
api_version mismatch Kill, audit plugin.api_mismatch, log both versions
name mismatch Kill, audit plugin.name_mismatch
capabilities_used exceeds manifest Kill, audit plugin.capability_overreach
Response is malformed JSON-RPC Kill, audit plugin.protocol_violation

initialized notification (daemon → plugin)

After validating the initialize response, the daemon sends an initialized notification. This is the plugin's signal that the handshake is complete and normal method calls may begin.

{
  "jsonrpc": "2.0",
  "method": "initialized",
  "params": {}
}

The plugin must not process daemon method calls received before initialized. If the daemon sends a call before initialized (it won't), the plugin should return error -32002 (not_initialized).


Method taxonomy

Methods are grouped by namespace. Namespaces are dot-delimited. The first segment identifies the owner.

System methods (daemon ↔ plugin)

These are defined by kaged and implemented by every plugin (via the SDK or manually).

Method Direction Description
initialize daemon → plugin Handshake. See above.
initialized daemon → plugin Handshake complete notification.
shutdown daemon → plugin Graceful shutdown notification. Plugin should flush state and exit within shutdown_timeout_sec.
ping daemon → plugin Health check. Plugin responds with {"status": "ok"}.
config.update daemon → plugin The plugin's config (from local config or project DSL) has changed. Params contain the new config object. Plugin may return an error if the config is invalid.
projects.activated daemon → plugin A project that uses this plugin has been loaded or reloaded. Params: {project: string, config: object}.
projects.deactivated daemon → plugin A project that used this plugin has been unloaded. Params: {project: string}.

Storage methods (plugin → daemon)

Plugins that declared kaged:storage:read and/or kaged:storage:write capabilities can call these methods. The daemon brokers access to a plugin-scoped SQLite schema (see Plugin-scoped storage).

Method Capability required Description
kaged.storage.exec kaged:storage:write Execute a SQL statement (INSERT, UPDATE, DELETE, CREATE TABLE, etc.) against the plugin's schema. Params: {sql: string, params: any[]}. Returns {rows_affected: number}.
kaged.storage.query kaged:storage:read Execute a read-only SQL query (SELECT). Params: {sql: string, params: any[]}. Returns {rows: object[], columns: string[]}.
kaged.storage.schema kaged:storage:read List tables in the plugin's schema. Returns {tables: string[]}.

The daemon rewrites all table names to the plugin's prefixed schema before executing. A plugin requesting SELECT * FROM presets executes as SELECT * FROM plugin_oh_my_pi_presets internally. The plugin never sees the prefix.

Restrictions:

  • Plugins cannot reference tables outside their schema prefix. Any attempt returns error -32003 (storage_access_denied).
  • DDL statements (CREATE TABLE, ALTER TABLE) are permitted only with kaged:storage:write. Plugins create their own schema on first use.
  • Transactions: the daemon wraps each kaged.storage.exec call in a transaction. Multi-statement transactions are not supported in v0 (each call is atomic).

Plugin-declared methods (daemon → plugin)

These are the plugin's business logic. The method names are declared in the manifest's methods field and confirmed in the initialize response.

Method names must:

  • Be dot-delimited, 2–4 segments. Example: presets.list, preset.install, models.pull.
  • Not start with kaged. or system. (reserved namespaces).
  • Be lowercase ASCII with dots and underscores only. Regex: ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$.

The daemon calls these methods when:

  1. The HTTP API receives a request that maps to a plugin method (e.g., GET /api/v1/plugins/oh-my-pi/call/presets.list).
  2. The primary or a subagent invokes a plugin tool (future; depends on tool-calling spec).
  3. An internal daemon flow needs plugin data (e.g., project-load checking plugin readiness).

Call context (PluginCallContext)

Every daemon → plugin method call and every lifecycle hook fire includes a _context field in params. The shape is canonical and stable; plugins compose their own identity, isolation key, or storage path from these fields.

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "presets.list",
  "params": {
    "_context": {
"operator_id": "operator",
      "project_id": "music",
      "agent_path": "primary",
      "session_id": "ses_abc123",
      "request_id": "req_xyz789"
    },
    "filter": "web"
  }
}
Field Type Description
_context.operator_id string The operator who initiated the call. From X-Kaged-User-Id. Absent in insecure mode.
_context.project_id string | null The normalized project root, when the call originates from a session. Null for daemon-level calls (e.g. status).
_context.agent_path string | null The canonical agent path in the recursive tree (per ADR-0022) — e.g. "primary" or "primary.subagents.researcher". Null when the call is not in an agent context.
_context.session_id string | null The session ID, if applicable.
_context.request_id string A unique ID for this request, for tracing and audit correlation.

TypeScript shape (canonical):

interface PluginCallContext {
  operator_id: string | null;
  project_id: string | null;
  agent_path: string | null;
  session_id: string | null;
  request_id: string;
}

The fields project_id, agent_path, and session_id are always populated together for any call originating from an agent run; they are all null for daemon-level calls. The plugin host enforces this — partial population (e.g. project_id set but agent_path null) is a contract violation.

Plugins that scope behavior per-agent (e.g. memory plugins that compose a storage path from agent_path) use _context.agent_path. Plugins that scope per-project use _context.project_id. Plugins that ignore context can ignore _context entirely.

Note on the rename. Prior versions of this spec used user_id and project as field names. ADR-0023 aligns these with the canonical PluginCallContext shape used throughout the kaged-side specs: operator_id (consistent with operator identity throughout the daemon spec) and project_id (consistent with the normalized project-root identifier). Plugins implementing against the older names must update; the wire schema is breaking.

Plugin-declared notifications (plugin → daemon)

Plugins may push notifications to the daemon at any time after initialized. Notification names follow the same naming rules as methods.

{
  "jsonrpc": "2.0",
  "method": "catalog.updated",
  "params": {
    "source": "filesystem_watch",
    "entries_added": 3
  }
}

The daemon:

  1. Logs the notification to the audit log.
  2. Forwards it to any active WebSocket connections that are subscribed to plugin events for this plugin.
  3. Does not respond (it's a notification).

Notification rate limiting: the daemon drops notifications from a plugin that exceeds 100/sec, logs plugin.notification_flood, and sends a system.rate_limited notification back to the plugin.


Lifecycle hooks

Lifecycle hooks are kaged-defined methods the daemon fires on plugins at well-known points in the session/run lifecycle. A plugin subscribes to a hook by listing it in the manifest hooks: array; the daemon calls the corresponding method when the event fires. Per ADR-0023, this is the load-bearing mechanism that makes project plugins first-class consumers of agent lifecycle without requiring a separate event bus.

Defined hooks

Hook Method When fired Scope Return
on_session_start kaged.hook.on_session_start Before the first message of a session reaches the LLM Primary only — sessions are primary-owned (ADR-0023) optional { inject: string } → prepended to system prompt
on_session_idle kaged.hook.on_session_idle When the session goes idle (no run activity for the daemon's idle window) Primary only none (side-effect only)
pre_compact kaged.hook.pre_compact Before compaction strategy (ADR-0024) Per-agent (each agent's window compacts independently) observer: { retain?: string[], inject?: string }, compactor: see Plugin roles.
post_compact kaged.hook.post_compact After compaction strategy (ADR-0024) Per-agent (each agent's window compacts independently) observer: { retain?: string[], inject?: string }

on_session_start and on_session_idle fire only on plugins declared on the primary agent. A subagent declaration may list them; the daemon never fires them on subagents. The plugin host emits a warning at load time when a subagent declares one of these hooks.

pre_compact and post_compact fire per-agent. Every agent in the recursive tree has its own context window and its own compaction events; only plugins declared on the affected agent receive its pre_compact and post_compact calls.

Wire shape

Hook firings ride the standard daemon → plugin method-call envelope. The method name follows the kaged.hook.* namespace (reserved). Every hook receives the standard _context (with agent_path always populated for hook fires from agent contexts).

on_session_start — request:

{
  "jsonrpc": "2.0",
  "id": 101,
  "method": "kaged.hook.on_session_start",
  "params": {
    "_context": {
"operator_id": "operator",
      "project_id": "music",
      "agent_path": "primary",
      "session_id": "ses_abc123",
      "request_id": "req_xyz789"
    }
  }
}

on_session_start — response (subscribed plugin):

{
  "jsonrpc": "2.0",
  "id": 101,
  "result": {
    "inject": "<plugin:memory-markdown>\nKnown about this project:\n- Uses bun 1.x\n- Prefers TypeScript strict mode\n</plugin:memory-markdown>"
  }
}

Returning no result field (or result: null) is a no-op. The daemon does not inject when nothing was returned.

The daemon wraps any non-empty inject in <plugin:NAME>...</plugin:NAME> delimiters at the harness boundary so the audit log shows which plugin contributed which content (per ADR-0023). Plugins may include their own delimiters inside inject; the kaged-level wrap is always applied.

on_session_idle — request:

{
  "jsonrpc": "2.0",
  "id": 102,
  "method": "kaged.hook.on_session_idle",
  "params": {
    "_context": { /* ... */ },
    "transcript": [
      { "role": "user", "content": "..." },
      { "role": "assistant", "content": "..." }
    ]
  }
}

The transcript is the session's message history since session start (or since the last idle fire — plugins decide whether they want full-session or sliding-window via their own config). Responses are not expected; the daemon treats this as fire-and-forget aside from JSON-RPC ack.

post_compact — request:

{
  "jsonrpc": "2.0",
  "id": 103,
  "method": "kaged.hook.post_compact",
  "params": {
    "_context": { /* ... */ },
    "role": "observer",
    "messages_being_compacted": [ /* messages */ ],
    "messages_remaining": [ /* messages */ ],
    "strategy": "summarize",
    "trigger": "threshold_crossed"
  }
}

post_compact — observer response:

{
  "jsonrpc": "2.0",
  "id": 103,
  "result": {
    "retain": [
      "user prefers ESM imports",
      "deploy target is Cloudflare Workers"
    ],
    "inject": "<plugin:memory-hindsight>\n...\n</plugin:memory-hindsight>"
  }
}

pre_compact — compactor response (see Plugin roles for the role field and the CompactorResult shape).

Firing semantics

  • Hooks are serialized per plugin. The plugin host does not issue a second hook call to the same plugin until the first returns (or times out). Plugins implementing slow hooks should use the standard JSON-RPC timeout config in their manifest.
  • Multiple plugins on the same agent receive hooks in manifest-declaration order (the order they appear in the agent's plugins: map in the DSL).
  • Hook timeout. Default 10s per hook fire. Configurable per-plugin in the manifest (hook_timeout_sec, max 60). A timed-out hook is logged (plugin.hook.timeout) and treated as if the plugin returned null; the session continues.
  • Hook failure. A hook that throws or returns a JSON-RPC error is logged (plugin.hook.failed) and treated as null. The session never stalls on a broken hook.
  • Restart and pending hooks. Pending idle-window timers are not restored on daemon restart (per ADR-0023). The next genuine session-activity event after restart fires the hook normally.

Hook firing point in the harness

The harness (per agent.md § Plugin hook firing) is the source of truth for where each hook is invoked in the run lifecycle. This spec is normative for the wire protocol; the harness spec is normative for the lifecycle position.


Plugin roles

A plugin declares its role(s) in the manifest. Roles are kaged-defined and bound to specific capabilities. Per ADR-0024, the v1 roles are:

Role Capability
observer May subscribe to lifecycle hooks. May return inject/retain content (on_session_start, post_compact). The default role for any plugin subscribing to hooks.
compactor May replace the message list during pre_compact (returns CompactorResult). At most one compactor per agent. Compactor responses bypass the configured strategy in agent.compaction.

A plugin may declare both roles. The hindsight reference integration is the canonical example, which observes (retains the transcript on on_session_idle) and acts as a compactor (returns intelligently-compacted message lists on pre_compact).

Compactor return contract

When a plugin declares compactor and is the designated compactor for an agent (per the agent's compaction.delegate.plugin field, see project-dsl.md § compaction), its pre_compact response replaces the strategy step. The daemon does not apply any further strategy after a compactor response.

Compactor response shape:

{
  "jsonrpc": "2.0",
  "id": 103,
  "result": {
    "role": "compactor",
    "messages": [ /* the new (compacted) message list */ ],
    "superseded": [ "msg_001", "msg_002", "msg_003" ],
    "summary": "Summarized 12 messages covering the JWT auth implementation."
  }
}
Field Type Required Description
role string yes Must be "compactor". Discriminates from an observer response.
messages Message[] yes The full new message list the harness will hand to the LLM. The compactor is responsible for preserving the always-keep set (per agent.md); the daemon enforces this and emits compactor_dropped_always_keep audit + falls back to drop if the compactor's messages omits an always-keep message.
superseded string[] yes IDs of the original messages to mark superseded = true in storage. Must be a subset of the messages that were in messages_being_compacted.
summary string no Human-readable summary for the audit log and the Compactor UI.

Compactor failure fallback

If the designated compactor plugin:

  • throws or returns a JSON-RPC error, OR
  • times out (per hook_timeout_sec), OR
  • returns an invalid CompactorResult (e.g. omitted required field, malformed message, drops an always-keep message)

…the daemon falls back to the drop strategy (per ADR-0024) and emits compaction.failed followed by compaction.completed audit events as a single failure chain. The session continues. A broken plugin must not stall compaction.

Enforcement

  • The plugin host enforces at most one plugin with role: compactor per agent at plugin-load. Two compactor plugins on the same agent is a configuration error; the daemon refuses to start the second.
  • Observer role has no per-agent cap.
  • A plugin without an explicit role field is treated as observer-only.

Plugin tool naming

Per ADR-0023, plugin-registered tools must be prefixed with the plugin name. The schema is <plugin-name>.<tool>. Examples:

  • memory-markdown.retain, memory-markdown.recall
  • memory-hindsight.retain, memory-hindsight.recall, memory-hindsight.reflect
  • oh-my-pi.preset_list, oh-my-pi.preset_install

Enforcement

  • At plugin-load, the host validates that every tool the plugin registers starts with the plugin's name from the manifest, followed by ., followed by a single segment matching [a-z][a-z0-9_]*. Tools not matching this pattern cause the plugin to fail to load.
  • If two enabled plugins register a tool with the same prefixed name, the second-loaded plugin fails with tool_name_collision. The first-loaded plugin remains active.
  • kaged does not reserve any tool-name namespace (e.g. memory.* is not reserved; whether a plugin is a "memory plugin" is a community label conveyed by package naming, not enforced by the host).

Cross-namespace coexistence

The built-in tool registry (per agent-tooling.md) reserves the namespaces file.*, search.*, code.*, debug, shell.*, and kaged.*. Plugin tool names use the plugin's own name prefix and cannot clash with these unless an operator names their plugin file, search, etc. — which is not forbidden but is documented as a footgun. The plugin host emits a load-time warning when a plugin's name matches a built-in namespace.

Operator override via tools: block

Even when a plugin auto-enables its tools on the declaring agent, the agent's tools: block (per project-dsl.md § tools) can opt out of specific plugin tools:

primary:
  plugins:
    memory:
      package: "@kaged/memory-markdown"
      hooks: [ on_session_start ]
  tools:
    "memory-markdown.recall": { enabled: false }   # last-resort disable

This is the operator's escape hatch for poorly-designed plugins. Well-designed plugins expose their own per-tool toggles in their config block; the tools: opt-out is the override of last resort.


Project and system config

Plugin configuration splits into two distinct blocks with different trust models and lifecycles. Per ADR-0023, this is the load-bearing reason plugins are tractable across the trust boundary between projects (committed to git, shared with teammates) and operators (auth secrets, machine-specific paths).

The split

Block Where it lives Committed? Carries Example
Project config AgentSpec.plugins.<name>.config in project.yaml Yes — part of the project Everything except secrets storage paths, knob values, model aliases, isolation policy, tags
System config [plugins."<package-name>"] in operator-local local.toml No — per ADR-0011 Secrets only API tokens, OAuth refresh tokens, machine-specific paths

The plugin manifest declares which fields belong in which block via two schemas:

# In kaged-plugin.yaml
config_schema:                       # the project-side schema (committed)
  type: object
  properties:
    api_url:
      type: string
      default: "https://api.hindsight.vectorize.io"
    recall_budget:
      type: string
      enum: [low, mid, high]
      default: mid
    tags:
      type: array
      items: { type: string }
  additionalProperties: false

system_config_schema:                 # the operator-local schema (secrets)
  type: object
  properties:
    api_token:
      type: string
      description: "Hindsight API token; obtain at ui.hindsight.vectorize.io/connect"
  required: [api_token]
  additionalProperties: false

Merging

At plugin initialization (after initialize, before initialized), the daemon:

  1. Validates the project-side config against config_schema.
  2. Validates the system-side config against system_config_schema.
  3. Merges the two: the union of fields. Field-name collisions between the two schemas are a manifest-validation error (a field is either project-side or system-side, never both).
  4. Sends the merged object to the plugin via the config.update method (per System methods).

The plugin receives one config object and does not see the split. The split is the kaged-side trust boundary.

system_only fields (forbidden in project-side overrides)

Some plugins want to declare that a field cannot be set in project config even if the operator wanted to. For these cases, declaring the field in system_config_schema is sufficient — the daemon rejects project-side config containing those keys at validation time. There is no separate system_only: true annotation; the schema location is the declaration.

Operator-local config block key

The local.toml section is keyed by the plugin's package name (not the agent-side declaration key). This matches how operators install and reason about plugins:

# local.toml
[plugins."@kaged/memory-hindsight"]
api_token = "${KAGED_HINDSIGHT_TOKEN}"

[plugins."@example/smart-compactor"]
license_key = "${SMART_COMPACTOR_KEY}"

Environment-variable substitution (${VAR}) is resolved at config-load time (per local-config.md). Raw secrets are supported but discouraged.

Per-agent overrides under isolation: project

When a plugin is declared with isolation: project (see Isolation), each participating agent may override individual project-side config fields via a parallel plugins.<name> block on that agent. Override semantics: any field specified at the agent level overrides the same field from the project-level declaration; non-specified fields inherit. The plugin host computes the merged per-agent config at hook fire / tool dispatch time.

System config is not overridable per-agent. Secrets are operator-machine-wide.


Isolation

Per ADR-0023, every project plugin carries an isolation field with two values, defaulting to 'agent':

Value Meaning
agent (default) The plugin instance for an agent is logically isolated from instances on other agents. The plugin receives agent_path in PluginCallContext and uses it as part of its identity (storage prefix, bank ID, etc.).
project The plugin instance is shared across agents in the project. The plugin still receives agent_path but composes a project-scoped identity. Each participating agent must declare the plugin on itself; non-declaring agents are excluded.

Cascade pattern under isolation: project

When a plugin declared on the primary has isolation: project, subagents that want to participate declare the plugin on themselves with only the overridden fields (typically hooks: and a subset of config:):

primary:
  plugins:
    memory:
      package: "@kaged/memory-hindsight"
      isolation: project
      hooks:
        - on_session_start
        - on_session_idle
        - pre_compact
        - post_compact
      config:
        recall_budget: mid
        tags: [primary]

  subagents:
    researcher:
      plugins:
        memory:
          # package + isolation inherited from primary's declaration
          hooks: [ on_session_idle ]
          config:
            tags: [researcher]

Inheritance is computed by the plugin host at plugin-load:

  1. The primary's declaration is the base declaration (must include package and is the source of isolation).
  2. Each subagent's declaration is the override — any field specified there overrides the base; non-specified fields inherit.
  3. package and isolation cannot be overridden on a subagent (validation error at load time).

Subagents that do not declare the plugin do not participate. There is no implicit cascade — declaration is always explicit (consistent with the per-agent-everything posture from ADR-0022).

Isolation under isolation: agent

With isolation: agent (the default), each declaring agent's plugin instance is independent. There is no inheritance, no cascade, no parallel-block pattern; each declaration is fully self-contained.


Plugin knob schema

Per ADR-0024, plugins that want to expose operator-tunable configuration use a kaged-defined knob schema. The schema is declared in the manifest under knobs:; the daemon's HTTP API exposes it to the UI via GET /api/v1/plugins/<name>/knobs (per http-api.md); the Compactor view and Plugin settings view render UI controls automatically from this schema.

Knob types

Type UI rendering Schema fields
range Slider with min/max/step min: number, max: number, step: number, default: number
int_range Integer slider min: integer, max: integer, step: integer, default: integer
enum Select / radio group values: string[], default: string, optional labels: Record<string, string>
boolean Toggle default: boolean
text Single-line input default: string, optional max_length: integer, optional pattern: regex
multiline Textarea default: string, optional max_length: integer
model_alias Model picker (resolves against operator's local.toml [models]) default: string | null, optional filter: 'any' | 'reasoning' | 'fast'
path Path input with URI-prefix validation default: string, prefixes: ['project:' | 'config:'], optional must_exist: boolean

Manifest shape

# In kaged-plugin.yaml
knobs:
  recall_budget:
    type: enum
    label: "Recall budget"
    description: "Compute budget for memory recall operations."
    values: [low, mid, high]
    labels:
      low: "Low (fast, less context)"
      mid: "Mid (balanced)"
      high: "High (deep, slower)"
    default: mid
    binds_to: "config.recall_budget"     # which config field this knob writes

  retain_every_n_turns:
    type: int_range
    label: "Auto-retain frequency"
    description: "Save the transcript after every N user turns."
    min: 1
    max: 20
    step: 1
    default: 3
    binds_to: "config.retain_every_n_turns"

  store:
    type: path
    label: "Memory storage location"
    description: "Where memory files are stored."
    prefixes: ["config:", "project:"]
    default: "config:/memory"
    binds_to: "config.store"

  thinking_model:
    type: model_alias
    label: "Reflection model"
    description: "Model used for the reflect operation."
    filter: reasoning
    default: null
    binds_to: "config.thinking_model"

Field-by-field

Field Required Description
<knob-name> yes The key in the knobs: map. Slug format ([a-z][a-z0-9_]*). Operator-facing identifier.
type yes One of the knob types above.
label yes Short human-readable label (max 64 chars). Shown next to the control.
description no Tooltip / help text (max 280 chars).
binds_to yes A dotted path into the plugin's project-side config object (e.g. config.recall_budget). When the operator changes the knob, the UI writes to this config path. The daemon validates the new value against both the config_schema and the knob's type bounds.
(type-specific) varies Per the table above.

binds_to is the bridge between operator-tunable UI and committed project config. A knob always writes to a field that exists in config_schema; the manifest validator enforces this at install time. A knob that bound to a system_config field would let the UI write secrets into project-committed YAML — forbidden by validation.

Why a kaged-defined schema

Operators get one consistent UI for tuning every plugin. Plugin authors describe their knobs once and the UI renders them automatically. The alternative (each plugin renders its own settings UI in an iframe) was considered and rejected in ADR-0024 — consistency of operator UX is the load-bearing reason for the schema constraint.

Knobs and the Compactor view

The Compactor UI (per ui/compactor.md) is the first consumer of the knob schema. For every plugin with role: observer or role: compactor enabled on the current agent, the UI fetches the plugin's knobs and renders them inline in the per-agent compaction settings panel. The operator tunes; the changes write to project.yaml's AgentSpec.plugins.<name>.config via the standard config-update path.

Future surfaces (notifications, audit sinks) consume the same machinery.


Error codes

Standard JSON-RPC codes

Code Name Meaning
-32700 Parse error Malformed JSON
-32600 Invalid request Not a valid JSON-RPC 2.0 object (or batch, which is forbidden)
-32601 Method not found Plugin does not implement the requested method
-32602 Invalid params Method exists but params are wrong
-32603 Internal error Unspecified plugin-internal failure

kaged-specific codes

Code Name Meaning
-32000 plugin_error Generic plugin-domain error. data field should contain details.
-32001 capability_denied Plugin attempted an operation it doesn't have capability for.
-32002 not_initialized Message received before handshake completed.
-32003 storage_access_denied Plugin attempted storage operation outside its schema or without capability.
-32004 config_invalid Plugin rejected a config.update because the config doesn't validate.
-32005 resource_busy Plugin is busy (e.g., already running an install). Client should retry.
-32006 not_applicable Method exists but doesn't apply in this context (e.g., calling a project-scoped method with no project context).

Plugins may use codes in the range -32000 to -32099 for domain-specific errors. Codes below -32099 are reserved for future kaged use.


Manifest schema

Each plugin ships a kaged-plugin.yaml in its install directory. The manifest is the plugin's identity document — it declares what the plugin is, what it needs, and what it exposes.

Full schema

# kaged-plugin.yaml
name: memory-markdown                   # required, string, slug format
version: 0.1.0                          # required, string, semver
kaged_api: 1                            # required, integer
description: >-                         # required, string (one line, max 200 chars)
  Markdown-file-backed agent memory.
author: kaged                           # optional, string
license: MIT                            # optional, string (SPDX identifier)
homepage: https://github.com/...        # optional, URL

command:                                # required, list of strings
  - /usr/bin/env
  - bun
  - ./dist/index.js

env:                                    # optional, map of string→string
  LOG_LEVEL: info

capabilities:                           # required, list of strings
  - read:fs:config:/memory
  - write:fs:config:/memory
  - kaged:storage:read
  - net: []

# --- ADR-0023 / ADR-0024 fields ---

roles:                                  # optional, list of strings (ADR-0024)
  - observer                            # may subscribe to hooks; may inject/retain
  # - compactor                         # may also replace the message list on pre_compact

hooks:                                  # optional, list of strings (ADR-0023)
  - on_session_start
  - on_session_idle
  - pre_compact
  - post_compact

tools:                                  # optional, list of tool declarations (ADR-0023)
  - name: retain                        # prefixed name will be: memory-markdown.retain
    description: "Store information in long-term memory."
    parameters_schema:
      type: object
      properties:
        content: { type: string }
        context: { type: string }
        tags: { type: array, items: { type: string } }
      required: [content]
  - name: recall
    description: "Search long-term memory."
    parameters_schema:
      type: object
      properties:
        query: { type: string }
        tags: { type: array, items: { type: string } }
      required: [query]

methods:                                # required, list of strings (custom methods)
  - presets.list                        # plugin-declared methods (legacy or non-tool RPC)
  # (memory plugins typically have no custom methods beyond tools + hooks)

notifications:                          # optional, list of strings
  - catalog.updated

config_schema:                          # optional, JSON Schema object (project-side; committed)
  type: object
  properties:
    isolation:
      type: string
      enum: [agent, project]
      default: agent
    store:
      type: string
      default: "config:/memory"
    tags:
      type: array
      items: { type: string }
  additionalProperties: false

system_config_schema:                   # optional, JSON Schema object (operator-local; secrets)
  type: object
  properties: {}                        # markdown backend has no secrets
  additionalProperties: false

knobs:                                  # optional, map (ADR-0024)
  store:
    type: path
    label: "Storage location"
    description: "Where memory files are stored."
    prefixes: ["config:", "project:"]
    default: "config:/memory"
    binds_to: "config.store"
  isolation:
    type: enum
    label: "Isolation scope"
    description: "Per-agent or per-project memory."
    values: [agent, project]
    default: agent
    binds_to: "config.isolation"

shutdown_timeout_sec: 5                 # optional, integer, default 5
health_interval_sec: 30                 # optional, integer, default 30
hook_timeout_sec: 10                    # optional, integer, default 10 (max 60)

Field-by-field

Field Type Required Description
name string yes Plugin identifier. Slug format: ^[a-z][a-z0-9-]*$, max 64 chars. Must be unique within the local plugin store.
version string yes Semver. The daemon uses this for version constraint checks when projects declare version: requirements.
kaged_api integer yes The kaged plugin API version. Currently 1. Daemon refuses plugins with kaged_api > its own.
description string yes One-line description. Shown in kaged plugin list and install prompts. Max 200 chars.
author string no Plugin author name or handle.
license string no SPDX license identifier. Shown in install prompts.
homepage string no URL. Shown in kaged plugin list --verbose.
command list of strings yes The argv to spawn the plugin process. command[0] is the executable. Relative paths are resolved from the plugin's install directory.
env map no Environment variables set for the plugin process. Merged with the daemon's plugin-environment defaults (see Spawn environment). Plugin env values override daemon defaults on collision.
capabilities list of strings yes The capability allowlist. See Capability grammar. May be empty ([]) for plugins that need no host access.
roles list of strings no The roles the plugin claims. Values: "observer", "compactor". Default: ["observer"] if hooks is non-empty, otherwise []. At most one plugin may claim compactor per agent. See Plugin roles.
hooks list of strings no Lifecycle hooks the plugin subscribes to. Allowed values: "on_session_start", "on_session_idle", "pre_compact", "post_compact". The daemon calls kaged.hook.<hook_name> on the plugin when each event fires. See Lifecycle hooks.
tools list of objects no Plugin-registered tools. Each entry: name (string, will be prefixed as <plugin-name>.<tool>), description (string), parameters_schema (JSON Schema). See Plugin tool naming.
methods list of strings no Custom (non-tool, non-hook) JSON-RPC methods the plugin exposes. Must follow the naming rules in Plugin-declared methods. May be empty/omitted for plugins that use only tools and hooks.
notifications list of strings no Notification types the plugin may emit. Informational.
config_schema object no A JSON Schema describing the plugin's project-side config shape (committed to git). The daemon validates operator-provided config (from project DSL) against this schema before merging. See Project and system config.
system_config_schema object no A JSON Schema describing the plugin's system-side config shape (operator-local, never committed). Carries secrets. Validated against operator-local local.toml [plugins."<package>"]. Field-name collisions with config_schema are a manifest-validation error.
knobs map of objects no Operator-tunable configuration declarations, rendered as UI controls. See Plugin knob schema. Each knob's binds_to must reference a field in config_schema (never system_config_schema).
shutdown_timeout_sec integer no Seconds the daemon waits after sending shutdown before SIGTERM. Default: 5. Max: 30.
health_interval_sec integer no Seconds between ping health checks. Default: 30. Min: 5. Max: 300.
hook_timeout_sec integer no Seconds the daemon waits for a hook response before treating it as a timeout. Default: 10. Max: 60.

Manifest validation

The manifest is validated at two points:

  1. Install time (kaged plugin install <path>) — full schema validation. Invalid manifests are rejected; the plugin is not installed.
  2. Daemon startup — re-validated. Manifests that were valid at install but are now invalid (e.g., after a kaged upgrade changes the schema) are logged and the plugin is disabled for this daemon run.

Validation uses JSON Schema (published at kaged.dev/schema/plugin-manifest-v1.json) mirrored as Zod internally, same discipline as the project DSL (ADR-0006).

Additional validations per ADR-0023 / ADR-0024:

  • Tool naming. Every entry in tools[].name is validated against ^[a-z][a-z0-9_]*$ (single segment, lowercase). At runtime registration, the host prefixes each name with <plugin-name>. before adding to the registry; the final registered name must match ^[a-z][a-z0-9-]+\.[a-z][a-z0-9_]*$.
  • Hook membership. Every entry in hooks must be in the defined-hooks set (on_session_start, on_session_idle, pre_compact, post_compact). Unknown hooks are a manifest error.
  • Role consistency. If roles contains compactor, hooks must contain pre_compact. (A compactor that doesn't subscribe to the compaction hook is meaningless.)
  • Config schema disjointness. Property names in config_schema.properties and system_config_schema.properties must be disjoint. A field declared in both is a manifest error (operator-local secrets cannot share field names with project-committed config).
  • Knob binds_to validity. Every knob's binds_to must be a path of the form config.<field> where <field> exists in config_schema.properties. Binding to system_config paths is forbidden (knobs are UI-rendered and would expose secrets); the validator emits knob_binds_to_system_config.
  • Knob type/schema agreement. A knob with type: enum and values: [low, mid, high] requires the bound config field to be a string enum with matching values. The validator emits warnings for type-mismatches; runtime config writes through the UI revalidate against both the knob bounds and the JSON schema.

Capability grammar

The capability allowlist is the operator-readable declaration of what host resources a plugin needs. The daemon translates capabilities into a cage policy (bwrap argv) before spawning the plugin.

Capability strings

Pattern Description Example
read:fs:<path> Read-only access to a host filesystem path (recursive). read:fs:/opt/oh-my-pi
write:fs:<path> Read-write access to a host path (recursive). write:fs:/var/lib/ollama/models
exec:<binary>:<path> Permission to execute a specific binary, with working dir restricted to <path>. exec:bash:/opt/oh-my-pi
net:<host>:<port> TCP access to a specific host:port. net:api.openai.com:443
net:<host>:* TCP access to all ports on a host. net:localhost:*
net:* Unrestricted network access (rare, documented as dangerous). net:*
net:[] No network access. net:[]
kaged:storage:read Read access to the plugin's scoped SQLite schema.
kaged:storage:write Read-write access to the plugin's scoped SQLite schema (implies read).

Paths

  • All fs and exec paths must be absolute.
  • Symlinks are resolved at cage-compile time; the resolved path must be within the declared capability.
  • Glob patterns are not supported in capabilities (they are in DSL cage blocks; capabilities are more restrictive by design).

Capability → cage policy translation

The plugin host calls the cage compiler (from sandbox.md) with a synthesized CagePolicy:

// Conceptual — the plugin host builds this from the manifest
const policy: CagePolicy = {
  fs: [
    // Always present: plugin's own install directory (read-only)
    { path: pluginInstallDir, mode: "ro" },
    // From capabilities
    ...manifest.capabilities
      .filter(c => c.startsWith("read:fs:") || c.startsWith("write:fs:"))
      .map(c => ({
        path: extractPath(c),
        mode: c.startsWith("write:") ? "rw" : "ro",
      })),
  ],
  net: {
    allow: manifest.capabilities
      .filter(c => c.startsWith("net:") && c !== "net:[]")
      .map(c => extractNetTarget(c)),
    // net:[] → allow is empty list → no network
    // net:* → allow is ["*"] → unrestricted
  },
  exec: manifest.capabilities
    .filter(c => c.startsWith("exec:"))
    .map(c => ({
      binary: extractBinary(c),
      cwd: extractPath(c),
    })),
  state: "ephemeral",           // plugins don't get persistent cage state
  seccomp: "default",           // always default profile for plugins
  cgroup: {
    memory_mb: 512,             // per-plugin default, configurable in config.toml
    cpu_shares: 256,
    pids: 100,
    walltime_sec: 0,            // no walltime limit for long-running plugins
  },
};

Key differences from subagent cages:

Aspect Subagent cage Plugin cage
Policy source Project DSL cage: block Plugin manifest capabilities
cage: disabled Allowed (per-subagent opt-out) Not allowed. Plugins always run sandboxed.
--no-sandbox daemon flag Disables subagent cages Does not affect plugins. Plugins are always caged.
Network model Per-cage netns via gatekeeper Same mechanism, but capabilities are typically narrower.
Seccomp profile default or relaxed per DSL Always default. No relaxed for plugins.
State persistence ephemeral or persistent Always ephemeral. Plugin state lives in kaged:storage, not the cage filesystem.

Plugins always run sandboxed. This is a deliberate design decision from ADR-0008: "No 'trusted' plugin tier." The --no-sandbox flag is for subagent development convenience; plugins are a different trust boundary.


Plugin supervisor

The daemon's PluginSupervisor (from daemon.md) owns the lifecycle of all plugin processes. This section specifies the supervisor's behavior in detail.

Spawn

At daemon startup (after self-check gates pass), the supervisor:

  1. Reads the enabled-plugins list from config.toml.
  2. For each enabled plugin, validates the manifest (re-validation gate).
  3. Compiles the capability allowlist into a cage policy.
  4. Calls the cage compiler to produce bwrap argv.
  5. Spawns the plugin process via Bun.spawn with:
    • The bwrap-wrapped command from step 4.
    • stdin piped (daemon writes JSON-RPC).
    • stdout piped (daemon reads JSON-RPC).
    • stderr piped (daemon captures logs).
    • Environment from Spawn environment.
  6. Sends the initialize request.
  7. Validates the initialize response.
  8. Sends the initialized notification.
  9. Records the plugin as running.

If any step fails, the plugin is marked failed and an audit event is logged. Failed plugins do not block daemon startup (per daemon.md startup gates).

Spawn environment

Every plugin process receives these environment variables:

Variable Value Description
KAGED_PLUGIN_NAME The plugin's name Identity, for logging.
KAGED_PLUGIN_DIR Absolute path to plugin install dir The plugin's home.
KAGED_API_VERSION 1 (string) The API version.
KAGED_LOG_LEVEL Daemon's configured log level Guidance for plugin log verbosity.
HOME Plugin install dir Sandboxed HOME.
PATH Minimal: /usr/bin:/usr/local/bin Restricted PATH inside the cage.
LANG C.UTF-8 Locale.

The manifest's env block is merged on top. Plugins cannot override KAGED_PLUGIN_NAME, KAGED_PLUGIN_DIR, or KAGED_API_VERSION via their env block — those are daemon-controlled.

Health checks

The supervisor sends ping to each running plugin at health_interval_sec intervals (from the manifest, default 30s).

Outcome Action
Response {"status": "ok"} within 5s Healthy. Reset failure counter.
Response with error Log warning. Increment failure counter.
No response within 5s Log warning. Increment failure counter.
3 consecutive health failures Kill the plugin (SIGTERM → SIGKILL), trigger restart with backoff.

Restart policy

When a plugin process exits (crash, SIGKILL, health-failure kill):

  1. Record plugin.crashed or plugin.exited audit event with exit code and last 50 stderr lines.
  2. If exit was clean (exit code 0) after a shutdown notification, do not restart.
  3. Otherwise, apply exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s.
  4. After 5 consecutive failures within 10 minutes, mark the plugin failed and disable it.
  5. Operator re-enables via kaged plugin enable <name> (which resets the failure counter and attempts a fresh spawn).

Graceful shutdown

During daemon shutdown:

  1. Daemon sends shutdown notification to each plugin.
  2. Waits shutdown_timeout_sec (per-plugin, from manifest) for the plugin to exit.
  3. If still alive → SIGTERM.
  4. Waits 2 additional seconds.
  5. If still alive → SIGKILL.
  6. Records plugin.stopped (clean) or plugin.killed (forced) audit event.

Plugin states

                  ┌──────────┐
         install──▶ disabled │◀────── operator disable
                  └────┬─────┘
                       │ enable
                  ┌────▼─────┐
                  │ spawning │
                  └────┬─────┘
                       │ handshake ok
                  ┌────▼─────┐
            ┌─────│ running  │◀──── restart (after backoff)
            │     └────┬─────┘
            │          │ crash / health fail
            │     ┌────▼─────┐
            │     │ crashed  │──── backoff ───▶ spawning
            │     └────┬─────┘
            │          │ 5 consecutive
            │     ┌────▼─────┐
            │     │ failed   │──── operator enable ───▶ spawning
            │     └──────────┘
            │
            │ shutdown
       ┌────▼─────┐
       │ stopped  │
       └──────────┘
State Description
disabled Installed but not enabled. No process.
spawning Process starting, handshake in progress.
running Healthy, responding to calls.
crashed Just exited unexpectedly. Restart pending (within backoff window).
failed Exceeded consecutive failure limit. Disabled until operator intervenes.
stopped Cleanly stopped (daemon shutdown or operator disable).

Install flow

Manual install

operator$ kaged plugin install /path/to/oh-my-pi

Validating manifest...
  name: oh-my-pi
  version: 1.4.2
  kaged_api: 1
  capabilities:
    - read:fs:/opt/oh-my-pi
    - exec:bash:/opt/oh-my-pi
    - net: []

Install oh-my-pi? [y/N] y

✓ Installed oh-my-pi 1.4.2
  → enable with: kaged plugin enable oh-my-pi

Steps:

  1. Validate kaged-plugin.yaml exists at the given path.
  2. Validate the manifest against the JSON Schema.
  3. Check for name conflicts (plugin with same name already installed).
  4. Show the operator: name, version, API version, capabilities, description.
  5. Prompt for confirmation.
  6. Copy the plugin directory into ${KAGED_HOME}/plugins/<name>/.
  7. Write local-config entry: [plugins.<name>] with installed = "<version>", local = true.
  8. The plugin is disabled until explicitly enabled.

Project-load-driven install

When a project is loaded (kaged project load <path>) and declares a plugin the operator doesn't have:

  1. Daemon identifies missing plugins (per daemon.md project-load flow).
  2. For each missing plugin, the daemon emits a consent request via the API (see http-api.md plugin consent flow).
  3. The UI (or CLI) shows the operator the plugin details and capabilities from the source.
  4. Operator approves → daemon fetches from source, validates manifest, installs.
  5. Operator declines → project enters pending state.
  6. Installed-via-project plugins get local = false in local config — activated only for the declaring project.

Source resolution

The source field in a project's plugins: entry tells kaged where to fetch:

Source format Resolution
./relative/path Path relative to project root. Must contain kaged-plugin.yaml. Copied to plugin store.
https://github.com/... Git clone (shallow, specific tag if version is specified). Must contain kaged-plugin.yaml at repo root.
git+https://... Explicit git scheme. Same as above.
(absent) Operator must install manually. Install prompt shows "source not specified" and the operator provides a path.

kaged never fetches from a source without the operator's explicit consent on the install prompt. The source URL is shown to the operator as part of the consent flow.

Upgrade

operator$ kaged plugin install /path/to/oh-my-pi-v2

Plugin oh-my-pi is already installed (version 1.4.2).
New version: 2.0.0

Capability changes:
  + net:api.openai.com:443    (NEW)
  - exec:bash:/opt/oh-my-pi  (REMOVED)

Upgrade oh-my-pi 1.4.2 → 2.0.0? [y/N] y

✓ Upgraded oh-my-pi to 2.0.0
  Plugin was running; restarting...
✓ oh-my-pi restarted

Upgrades show a capability diff. New capabilities require explicit operator acknowledgment (they expand the sandbox boundary).

Uninstall

operator$ kaged plugin uninstall oh-my-pi

Plugin oh-my-pi is active in 1 session(s). Stop sessions first, or use --force.

operator$ kaged plugin uninstall oh-my-pi --force

✓ Stopped oh-my-pi
✓ Removed plugin files
✓ Removed local config entry

Uninstall refuses if active sessions are using the plugin (unless --force). Removes the plugin directory from the store and the [plugins.<name>] entry from local config.


Plugin-scoped storage

Plugins that declare kaged:storage:read or kaged:storage:write get access to a logical schema within the daemon's SQLite database. The daemon brokers all access; the plugin never has a direct database connection.

Schema isolation

Each plugin's tables are prefixed with plugin_<name>_ where <name> is the plugin name with hyphens replaced by underscores. Example: plugin oh-my-pi → prefix plugin_oh_my_pi_.

The daemon rewrites SQL before execution:

  • All table references are prefixed.
  • References to tables outside the plugin's prefix are rejected with -32003 (storage_access_denied).
  • The rewriter is a simple AST-level prefix appender, not a full SQL parser. Plugins must use straightforward table names (no subqueries referencing other plugins' tables, no dynamic SQL that constructs table names).

DDL

Plugins create their own tables on first use. The daemon does not pre-create schemas.

{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "kaged.storage.exec",
  "params": {
    "sql": "CREATE TABLE IF NOT EXISTS presets (id TEXT PRIMARY KEY, name TEXT, path TEXT, installed_at TEXT)",
    "params": []
  }
}

This executes as CREATE TABLE IF NOT EXISTS plugin_oh_my_pi_presets (...) internally.

Cleanup on uninstall

When a plugin is uninstalled, the daemon drops all tables with the plugin's prefix. This is logged in the audit log. There is no "keep plugin data after uninstall" option in v0 — if the operator wants to preserve data, they export it before uninstalling.


Plugin SDK

The kaged project ships a TypeScript SDK as the reference implementation. Python and shell reference implementations follow. The SDK is a convenience, not a requirement — any language that can read/write line-delimited JSON on stdio can be a plugin.

TypeScript SDK shape

import { createPlugin } from "@kaged/plugin-sdk";

const plugin = createPlugin({
  name: "oh-my-pi",
  version: "1.4.2",
  methods: {
    "presets.list": async (params, context) => {
      // params: whatever the caller sent (minus _context)
      // context: { user_id, project, session_id, request_id }
      const presets = await scanPresets(params.filter);
      return { presets };
    },
    "presets.search": async (params, context) => {
      return { results: search(params.query) };
    },
    "preset.install": async (params, context) => {
      await install(params.name);
      return { installed: true };
    },
    "preset.uninstall": async (params, context) => {
      await uninstall(params.name);
      return { uninstalled: true };
    },
  },
  notifications: ["catalog.updated"],
  onConfigUpdate: (newConfig) => {
    // Handle config changes
    updatePresetDir(newConfig.preset_dir);
  },
  onShutdown: async () => {
    // Flush any pending state
    await flushCache();
  },
});

// Start the plugin (connects stdin/stdout, runs event loop)
plugin.start();

// Emit a notification at any time
plugin.notify("catalog.updated", { entries_added: 3 });

// Use storage (if capability declared)
const rows = await plugin.storage.query("SELECT * FROM presets WHERE name LIKE ?", ["%web%"]);
await plugin.storage.exec("INSERT INTO presets (id, name) VALUES (?, ?)", ["p1", "my-preset"]);

// Log safely (goes to stderr, not stdout)
plugin.log.info("Scanned 42 presets");
plugin.log.error("Failed to read preset directory", { error: err.message });

SDK responsibilities

Responsibility Description
JSON-RPC framing Read/write line-delimited JSON on stdin/stdout. Buffer partial lines.
Handshake Respond to initialize, validate params, send capabilities back.
Method routing Dispatch incoming calls to registered handlers by method name.
Context extraction Parse _context from params and pass it separately to handlers.
Notification sending Provide plugin.notify(type, params) that writes to stdout.
Storage proxy Provide plugin.storage.query() / .exec() / .schema() that send JSON-RPC to daemon.
Logging Provide plugin.log.* that writes structured JSON to stderr. Never stdout.
Shutdown handling Listen for shutdown notification, call user's onShutdown, exit cleanly.
Health response Auto-respond to ping with {"status": "ok"}.
Config update Call user's onConfigUpdate when config.update arrives.

Shell plugin pattern

A minimal shell plugin (for simple adapters):

#!/usr/bin/env bash
# kaged plugin: my-simple-adapter
# Reads JSON-RPC from stdin, writes to stdout

while IFS= read -r line; do
  method=$(echo "$line" | jq -r '.method // empty')
  id=$(echo "$line" | jq -r '.id // empty')

  case "$method" in
    initialize)
      echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"name\":\"my-simple-adapter\",\"version\":\"0.1.0\",\"api_version\":1,\"methods\":[\"status.get\"],\"notifications\":[],\"capabilities_used\":[]}}"
      ;;
    initialized)
      # Notification, no response
      ;;
    ping)
      echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"status\":\"ok\"}}"
      ;;
    shutdown)
      exit 0
      ;;
    status.get)
      result=$(get_status 2>/dev/null)
      echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"result\":{\"status\":\"$result\"}}"
      ;;
    *)
      echo "{\"jsonrpc\":\"2.0\",\"id\":$id,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}"
      ;;
  esac
done

This pattern is documented in the plugin authoring guide (TBD) and works without any SDK. The SDK makes it better; it's never required.


Audit events

The plugin host emits these audit events (to the daemon's audit log, per daemon.md logging):

Event When Data
plugin.spawned Plugin process started name, version, pid, cage_id
plugin.initialized Handshake completed name, methods_count, capabilities_count
plugin.method_called Daemon called a plugin method name, method, request_id, user_id, project
plugin.method_returned Plugin responded to a call name, method, request_id, duration_ms, success
plugin.notification Plugin emitted a notification name, notification_type
plugin.notification_flood Plugin exceeded notification rate limit name, rate
plugin.stdout_noise Plugin wrote non-JSON to stdout name, line (truncated to 200 chars)
plugin.health_fail Health check failed name, consecutive_failures
plugin.crashed Plugin process exited unexpectedly name, exit_code, signal, last_stderr (50 lines)
plugin.exited Plugin process exited cleanly name, exit_code
plugin.killed Plugin SIGKILLed after timeout name
plugin.failed Plugin exceeded consecutive failure limit name, total_failures
plugin.enabled Plugin enabled by operator name
plugin.disabled Plugin disabled by operator or by failure name, reason
plugin.installed Plugin installed name, version, source, local
plugin.upgraded Plugin upgraded name, old_version, new_version, capability_diff
plugin.uninstalled Plugin uninstalled name, version
plugin.protocol_violation Plugin violated the wire protocol name, violation_type, detail
plugin.capability_overreach Plugin claimed capabilities beyond manifest name, claimed, allowed
plugin.storage_denied Plugin attempted storage access outside its schema name, sql (redacted)
plugin.hook.fired A lifecycle hook was invoked on a plugin name, hook, agent_path, session_id, request_id
plugin.hook.returned A lifecycle hook returned successfully name, hook, duration_ms, has_result
plugin.hook.timeout A lifecycle hook timed out (treated as null result) name, hook, agent_path, timeout_sec
plugin.hook.failed A lifecycle hook threw or returned a JSON-RPC error name, hook, agent_path, error_code, error_message
plugin.hook.illegal A subagent declared a primary-only hook (warning, not error) name, hook, agent_path
plugin.tool_registered A plugin tool was registered in the agent's resolved tool set name, tool, agent_path
plugin.tool_name_collision Two plugins attempted to register the same prefixed tool name first, second, tool
plugin.role_violation At-most-one-compactor-per-agent rule violated; second plugin refused first, second, agent_path
plugin.config_validation_failed Project- or system-side config failed schema validation name, side ("project" | "system"), errors
plugin.knob_write The operator changed a knob via the UI; the plugin received a config.update name, knob, agent_path, request_id

All events include a timestamp (ISO 8601) and the daemon's request ID if the event correlates to an API call.

Compaction-specific audit events (compaction.triggered, compaction.completed, compaction.failed, compaction.flagged) are emitted by the harness, not the plugin host. See agent.md § Compaction.


Failure modes

Failure Detection Recovery Operator impact
Plugin process crashes EOF on stdin/stdout Restart with backoff Calls to plugin return 502 until restart. Active sessions see [BLOCKED].
Plugin hangs (no response) Request timeout (30s default) Kill + restart Same as crash. The timed-out request returns -32603 to caller.
Plugin health check fails 3 consecutive ping failures Kill + restart Same as crash.
Manifest invalid at startup Validation gate Plugin disabled for this run Plugin unavailable. kaged plugin list shows status disabled (invalid manifest).
Capability overreach at init initialize response check Kill, do not restart Plugin must be fixed by author.
Storage access violation SQL rewriter check Request rejected with -32003 Plugin gets error; audit logged. Plugin not killed (may be a bug, not malice).
Plugin writes to stdout incorrectly Non-JSON line detection Warning logged; line discarded Plugin may malfunction if it expected the line to reach somewhere.
All plugins crash simultaneously Individual detection per plugin Each restarts independently Multiple 502s. No cascade into daemon.
Plugin install source unreachable Git clone / HTTP fetch timeout Install fails, project stays pending Operator retries or installs manually.

Testing notes

Protocol tests

  • Handshake happy path: Spawn a mock plugin, verify initialize → response → initialized sequence.
  • Handshake timeout: Mock plugin that never responds. Assert daemon kills after 10s.
  • API version mismatch: Mock plugin returning api_version: 99. Assert kill + audit event.
  • Name mismatch: Plugin responding with wrong name. Assert kill.
  • Capability overreach: Plugin claiming capabilities not in manifest. Assert kill.
  • Batched request rejection: Send a JSON array. Assert -32600 error.
  • Oversize message: Send a 5 MiB line. Assert kill.

Method call tests

  • Happy path: Call a method, assert response.
  • Method not found: Call a method not in the manifest. Assert -32601.
  • Context passing: Verify _context is present and correct.
  • Timeout: Mock plugin that never responds to a method. Assert timeout error after 30s.
  • Concurrent calls: Send 10 calls without waiting for responses. Assert all 10 get responses (order may vary).

Supervisor tests

  • Crash → restart: Kill the plugin process. Assert restart within backoff window.
  • Consecutive failures → disable: Kill the plugin 5 times within 10 minutes. Assert failed state.
  • Health check failure → kill: Mock plugin that stops responding to ping. Assert kill after 3 failures.
  • Graceful shutdown: Send shutdown, assert plugin exits within timeout.
  • Forced kill: Mock plugin that ignores shutdown and SIGTERM. Assert SIGKILL after timeout.

Storage tests

  • Create table: Plugin creates a table. Assert it exists with the prefixed name.
  • Cross-schema access: Plugin tries to reference another plugin's table. Assert -32003.
  • DDL without write capability: Plugin with only kaged:storage:read tries CREATE TABLE. Assert error.
  • Cleanup on uninstall: Uninstall a plugin. Assert all prefixed tables dropped.

Sandbox tests

  • Capability enforcement: Plugin with read:fs:/opt/foo tries to read /etc/passwd. Assert failure (inside the cage).
  • No-network plugin: Plugin with net:[] tries to connect to the internet. Assert failure.
  • Plugin always caged: Start daemon with --no-sandbox. Assert plugins are still sandboxed.

Install tests

  • Manual install happy path: Valid manifest, install, verify local config entry.
  • Invalid manifest rejection: Missing name field. Assert install refused.
  • Upgrade with capability diff: Show the diff, verify operator prompt.
  • Uninstall with active sessions: Assert refusal without --force.

Open questions

  1. Plugin method schemas. v0 validates that methods exist but does not validate request/response payloads against per-method schemas. Should the manifest carry per-method JSON Schemas? Adds complexity for plugin authors; improves debugging. Decision deferred to v0.x.
  2. Plugin hot-reload. Today, upgrading a plugin requires restart. A reload command that re-initializes the plugin without full process restart is plausible. Deferred.
  3. Plugin-to-plugin coordination. Currently forbidden (daemon mediates). If real use cases emerge for plugin-to-plugin events, we'd add a daemon-mediated pub/sub. No evidence of need yet.
  4. Plugin metrics. What telemetry does the daemon expose about plugin health? v0: just audit events. v0.x: structured metrics (call count, latency histogram, error rate) per plugin, exposed via the status API.
  5. Multi-statement storage transactions. v0 is single-statement-per-call. If plugins need atomic multi-statement transactions, we add a kaged.storage.begin / kaged.storage.commit pair. Deferred.
  6. Binary data in method calls. JSON-RPC is text. Plugins that need to transfer binary data (e.g., files) must base64-encode. If this becomes a bottleneck, we add a sideband binary channel (e.g., a Unix socket per plugin for file transfer). Not v0.

Amendments

  • 2026-05-27: Implementation status updated. JsonRpcConnection (line-delimited JSON-RPC 2.0 stdio transport), PluginProcess (single-plugin lifecycle: spawn, handshake, health checks, backoff restart, notification rate limiting, graceful shutdown), and PluginSupervisor (multi-plugin orchestration: register, startAll/stopAll, enable/disable, callMethod with CallContext injection) now implemented in packages/plugin-host/. Daemon integration complete: PluginSupervisor wired into HandlerContext, status-handlers serve real supervisor data, main.ts creates supervisor with Bun.spawn bridge, discovers plugins from plugins.dir, starts/stops with daemon lifecycle. 171 tests. Remaining gaps: cage policy compilation (depends on sandbox cage compiler), plugin-scoped storage brokering, install/upgrade/uninstall CLI flow.
  • 2026-05-27 — ADR-0023 (project-plugin lifecycle hooks, per-agent declaration, isolation as a core principle):
    • Per-agent declaration. Plugins are declared in AgentSpec.plugins, not at the project root. No inheritance between agents. Each declaring agent looks like a fresh root to the plugin. The project-level plugins: block in project-dsl.md is replaced (see project-dsl.md amendment of the same date).
    • PluginCallContext shape canonicalized. The previous _context fields user_id and project are renamed to operator_id and project_id and joined by agent_path. The shape is canonical across hook fires and method calls. The wire schema is breaking; plugins built against the old names must update.
    • Lifecycle hooks added. Four hooks defined: on_session_start (primary-only), on_session_idle (primary-only), pre_compact (per-agent), post_compact (per-agent). Manifest gains a hooks: [string] field; daemon calls kaged.hook.<name> on subscribed plugins. Hook timeouts and failures are logged but never stall the session.
    • Tool naming. Plugin tools are prefixed with <plugin-name>.. The kaged registry does not reserve any namespace; uniqueness is enforced by prefix. Two plugins registering the same prefixed tool name is a load-time error for the second.
    • Project/system config split. Manifest gains system_config_schema for operator-local secrets. Config from project.yaml's per-agent declaration and from local.toml [plugins."<package>"] are merged at plugin init; field-name disjointness is enforced.
    • isolation field. Plugins gain a kaged-defined isolation: 'agent' \| 'project' config field, defaulting to 'agent'. Under isolation: project, subagents declare the plugin on themselves via parallel plugins.<name> blocks; the host computes inheritance at load time.
    • Tool declarations in manifest. Manifest gains tools: [{name, description, parameters_schema}]. Tools are auto-enabled on agents that declare the plugin; per-tool opt-out via the agent's tools: block remains available.
    • New audit events: plugin.hook.fired, plugin.hook.returned, plugin.hook.timeout, plugin.hook.failed, plugin.hook.illegal, plugin.tool_registered, plugin.tool_name_collision, plugin.config_validation_failed, plugin.knob_write.
  • 2026-05-27 — ADR-0024 (context compaction):
    • Plugin roles. Manifest gains a roles: [string] field with values observer and compactor. Default: observer if hooks is non-empty. At most one plugin may claim compactor per agent; the host enforces at load.
    • Compactor return contract. Compactor responses to pre_compact follow the CompactorResult shape (role: "compactor", messages, superseded, optional summary) and replace the harness's configured strategy step. Compactor failures fall back to the drop strategy; the session never stalls on a broken plugin.
    • Knob schema. Manifest gains a knobs: map for operator-tunable configuration. Knob types: range, int_range, enum, boolean, text, multiline, model_alias, path. Every knob binds to a project-side config_schema field via binds_to; binding to system_config_schema fields is forbidden. The UI renders knobs from the manifest automatically; the first consumer is the Compactor view at ui/compactor.md.
    • New audit event: plugin.role_violation (at-most-one-compactor rule).

References