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/initializedexchange. - The method taxonomy — system methods, storage methods, plugin-declared methods, notifications.
- The manifest schema —
kaged-plugin.yamlin 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 split —
config_schema(committed) vssystem_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:
- The sandbox mechanism itself (that's
sandbox.md— the plugin host calls the cage compiler, not owns it). - The HTTP API endpoints that proxy plugin operations (that's
http-api.md). - The project DSL's per-agent
plugins:block (that'sproject-dsl.md). - The local config's
[plugins.*]schema (that'slocal-config.md). - The daemon's top-level process model or CLI (that's
daemon.md). - Where in the harness lifecycle the hooks fire (that's
agent.md § Plugin hook firing). - The compaction strategy itself (that's
agent.md § Compactionand ADR-0024).
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 alog()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.execcall 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.orsystem.(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:
- The HTTP API receives a request that maps to a plugin method (e.g.,
GET /api/v1/plugins/oh-my-pi/call/presets.list). - The primary or a subagent invokes a plugin tool (future; depends on tool-calling spec).
- 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:
- Logs the notification to the audit log.
- Forwards it to any active WebSocket connections that are subscribed to plugin events for this plugin.
- 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 returnednull; 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: compactorper 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
rolefield is treated asobserver-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.recallmemory-hindsight.retain,memory-hindsight.recall,memory-hindsight.reflectoh-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
namefrom 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:
- Validates the project-side config against
config_schema. - Validates the system-side config against
system_config_schema. - 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).
- Sends the merged object to the plugin via the
config.updatemethod (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:
- The primary's declaration is the base declaration (must include
packageand is the source ofisolation). - Each subagent's declaration is the override — any field specified there overrides the base; non-specified fields inherit.
packageandisolationcannot 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:
- Install time (
kaged plugin install <path>) — full schema validation. Invalid manifests are rejected; the plugin is not installed. - 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[].nameis 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
hooksmust 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
rolescontainscompactor,hooksmust containpre_compact. (A compactor that doesn't subscribe to the compaction hook is meaningless.) - Config schema disjointness. Property names in
config_schema.propertiesandsystem_config_schema.propertiesmust be disjoint. A field declared in both is a manifest error (operator-local secrets cannot share field names with project-committed config). - Knob
binds_tovalidity. Every knob'sbinds_tomust be a path of the formconfig.<field>where<field>exists inconfig_schema.properties. Binding tosystem_configpaths is forbidden (knobs are UI-rendered and would expose secrets); the validator emitsknob_binds_to_system_config. - Knob type/schema agreement. A knob with
type: enumandvalues: [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
fsandexecpaths 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:
- Reads the enabled-plugins list from
config.toml. - For each enabled plugin, validates the manifest (re-validation gate).
- Compiles the capability allowlist into a cage policy.
- Calls the cage compiler to produce bwrap argv.
- Spawns the plugin process via
Bun.spawnwith:- 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.
- Sends the
initializerequest. - Validates the
initializeresponse. - Sends the
initializednotification. - 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):
- Record
plugin.crashedorplugin.exitedaudit event with exit code and last 50 stderr lines. - If exit was clean (exit code 0) after a
shutdownnotification, do not restart. - Otherwise, apply exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s.
- After 5 consecutive failures within 10 minutes, mark the plugin
failedand disable it. - Operator re-enables via
kaged plugin enable <name>(which resets the failure counter and attempts a fresh spawn).
Graceful shutdown
During daemon shutdown:
- Daemon sends
shutdownnotification to each plugin. - Waits
shutdown_timeout_sec(per-plugin, from manifest) for the plugin to exit. - If still alive → SIGTERM.
- Waits 2 additional seconds.
- If still alive → SIGKILL.
- Records
plugin.stopped(clean) orplugin.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:
- Validate
kaged-plugin.yamlexists at the given path. - Validate the manifest against the JSON Schema.
- Check for name conflicts (plugin with same name already installed).
- Show the operator: name, version, API version, capabilities, description.
- Prompt for confirmation.
- Copy the plugin directory into
${KAGED_HOME}/plugins/<name>/. - Write local-config entry:
[plugins.<name>]withinstalled = "<version>",local = true. - The plugin is
disableduntil explicitly enabled.
Project-load-driven install
When a project is loaded (kaged project load <path>) and declares a plugin the operator doesn't have:
- Daemon identifies missing plugins (per
daemon.mdproject-load flow). - For each missing plugin, the daemon emits a consent request via the API (see
http-api.mdplugin consent flow). - The UI (or CLI) shows the operator the plugin details and capabilities from the source.
- Operator approves → daemon fetches from
source, validates manifest, installs. - Operator declines → project enters
pendingstate. - Installed-via-project plugins get
local = falsein 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 →initializedsequence. - 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
-32600error. - 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
_contextis 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
failedstate. - 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
shutdownand 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:readtries CREATE TABLE. Assert error. - Cleanup on uninstall: Uninstall a plugin. Assert all prefixed tables dropped.
Sandbox tests
- Capability enforcement: Plugin with
read:fs:/opt/footries 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
namefield. Assert install refused. - Upgrade with capability diff: Show the diff, verify operator prompt.
- Uninstall with active sessions: Assert refusal without
--force.
Open questions
- 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.
- Plugin hot-reload. Today, upgrading a plugin requires restart. A
reloadcommand that re-initializes the plugin without full process restart is plausible. Deferred. - 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.
- 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.
- 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.commitpair. Deferred. - 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), andPluginSupervisor(multi-plugin orchestration: register, startAll/stopAll, enable/disable, callMethod withCallContextinjection) now implemented inpackages/plugin-host/. Daemon integration complete:PluginSupervisorwired intoHandlerContext, status-handlers serve real supervisor data,main.tscreates supervisor withBun.spawnbridge, discovers plugins fromplugins.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-levelplugins:block inproject-dsl.mdis replaced (see project-dsl.md amendment of the same date). PluginCallContextshape canonicalized. The previous_contextfieldsuser_idandprojectare renamed tooperator_idandproject_idand joined byagent_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 ahooks: [string]field; daemon callskaged.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_schemafor operator-local secrets. Config fromproject.yaml's per-agent declaration and fromlocal.toml [plugins."<package>"]are merged at plugin init; field-name disjointness is enforced. isolationfield. Plugins gain a kaged-definedisolation: 'agent' \| 'project'config field, defaulting to'agent'. Underisolation: project, subagents declare the plugin on themselves via parallelplugins.<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'stools: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.
- Per-agent declaration. Plugins are declared in
- 2026-05-27 — ADR-0024 (context compaction):
- Plugin roles. Manifest gains a
roles: [string]field with valuesobserverandcompactor. Default:observerifhooksis non-empty. At most one plugin may claimcompactorper agent; the host enforces at load. - Compactor return contract. Compactor responses to
pre_compactfollow theCompactorResultshape (role: "compactor",messages,superseded, optionalsummary) and replace the harness's configured strategy step. Compactor failures fall back to thedropstrategy; 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-sideconfig_schemafield viabinds_to; binding tosystem_config_schemafields is forbidden. The UI renders knobs from the manifest automatically; the first consumer is the Compactor view atui/compactor.md. - New audit event:
plugin.role_violation(at-most-one-compactor rule).
- Plugin roles. Manifest gains a
References
- ADR-0008 and its amendment — the plugin model decision
- ADR-0009 — sandbox mechanism plugins are spawned under
- ADR-0004 — Bun runtime hosting the plugin host
- ADR-0005 — the storage layer plugins access via brokered methods
- ADR-0010 — deployment modes affecting plugin store location
- ADR-0011 — project portability, plugin scope model
sandbox.md— cage compiler called by the plugin hostdaemon.md— PluginSupervisor, daemon process modelhttp-api.md— plugin API endpointslocal-config.md— plugin store schema in local configproject-dsl.md— project-level plugin declarations- JSON-RPC 2.0 specification: https://www.jsonrpc.org/specification