ADR-0023: Project-plugin lifecycle hooks, per-agent declaration, isolation as a core principle
- Status: Accepted
- Date: 2026-05-27
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
RFC-0003 explored how kaged should support persistent agent memory. The exploration started with the assumption that memory would be a first-class DSL concept (a memory: block on AgentSpec), traversed several intermediate designs, and ended at a structurally stronger position: memory is a project plugin, not a special case. Kaged exposes generic plugin lifecycle hooks that any project plugin may subscribe to. Memory plugins are the first consumer; audit sinks, notification backends, observability sinks, and similar capabilities follow the same shape.
This collapses an entire axis of decision (a memory-specific DSL surface) into a single, more general one (plugin lifecycle integration). The hindsight reference integration for opencode (hindsight-integrations/opencode/) — three tools, three hooks, an isolation key — becomes hostable on kaged with no special-case machinery.
The decision is load-bearing because it commits the project-plugin model to capabilities it does not currently have:
- Lifecycle hook subscription with a kaged-defined schema.
- A call-context object passed to every hook fire and plugin tool dispatch.
- A project/system config split (project config is committed; system config carries secrets and stays operator-local).
- A per-agent declaration model — plugins are declared on the agent that uses them, not at the project root, mirroring how
tools:andcage:work after ADR-0022. - An
isolationfield defining whether a plugin instance is logically per-agent or per-project, with a parallel-block cascade pattern for the project-isolation case. - A plugin-name-prefixed tool naming requirement for uniqueness.
The existing project-plugin spec (docs/specs/plugin-host.md) defines the JSON-RPC subprocess transport, the manifest shape, the supervisor lifecycle, and the capability flags. This ADR adds the lifecycle-and-config layer on top.
This ADR is not about daemon (operator-local) plugins. Daemon plugins (docs/specs/plugins/system-plugins.md) are unchanged.
Decision
kaged adopts a per-agent project-plugin declaration model. Project plugins declare lifecycle-hook subscriptions in a kaged-defined schema, receive a
PluginCallContexton every hook and tool dispatch, split their configuration into project-committed and operator-local halves, prefix their tool names with the plugin name for uniqueness, and use anisolation: 'agent' | 'project'field — defaulting to'agent'— to declare their scope. Theisolation: 'project'case supports a parallel-block cascade where each participating agent declares the plugin on itself with overrideable fields.
Specifics
Per-agent declaration. Plugins are declared inside
AgentSpec.plugins, not at the project root. A plugin enabled on the primary is enabled on the primary, full stop. Subagents do not inherit. To use a plugin on a subagent, the operator declares it on that subagent explicitly. From a plugin's perspective, each enabled agent looks like a fresh root.Lifecycle hooks. Kaged exposes four lifecycle hooks at this ADR's acceptance:
on_session_start— fires before the first message of a session reaches the LLM. Plugin may return a string to prepend to the system prompt, wrapped in<plugin:NAME>...</plugin:NAME>delimiters.on_session_idle— fires when the session goes idle. Side-effect only.pre_compact, fires before strategy execution during context compaction (per ADR-0024). Plugin may observe or return content per ADR-0024's contract.post_compact, fires after strategy execution during context compaction (per ADR-0024). Plugin may observe per ADR-0024's contract.
on_session_startandon_session_idlefire only for the primary agent (sessions are primary-owned and subagents do not have sessions). A subagent's declaration may list these hooks; they simply never fire.pre_compactandpost_compactare per-agent because each agent has its own context window.The hook list is forward-extensible. Future hooks (e.g.
before_tool_call,on_subagent_spawn,on_run_failed) are added by amendment to this ADR or by superseding spec amendments.PluginCallContext. Every hook fire and every plugin-registered tool dispatch receives:interface PluginCallContext { project_id: string; // normalized project root agent_path: string; // canonical, e.g. "primary" or "primary.subagents.researcher" session_id: string; operator_id: string; }The plugin composes its own identity / isolation key / storage path from these fields. Kaged guarantees the values are canonical and stable; how the plugin uses them is plugin-internal.
Project / system config split. Plugin configuration is divided:
- Project config (
plugins.<name>.configinsideAgentSpec) — committed to the project repo. Carries everything except secrets: storage paths, knob values, model choices, isolation policy, tags. - System config (operator-local
local.tomlunder[plugins."<package-name>"]) — never committed. Carries only secrets and operator-local concerns: API tokens, machine-specific paths, debug flags.
The plugin manifest declares which config fields live in which block:
{ "name": "memory-hindsight", "config_schema": { "api_url": "...", "recall_budget": "..." }, "system_config_schema": { "api_token": { "required": true } } }The plugin host merges the two blocks and hands the result to the plugin at startup; the project block takes precedence except for fields declared
system_only.- Project config (
Hook subscription schema. Kaged defines the schema for hook subscription; the field lives inside each plugin's declaration block (so plugins own their configuration surface but the shape is homogeneous):
primary: plugins: memory: package: "@kaged/memory-markdown" hooks: [ on_session_start, on_session_idle ] isolation: agent config: { tags: [project] }Isolation as a core principle.
isolation: 'agent' | 'project'is a kaged-defined config field on every project plugin, defaulting to'agent'.isolation: agent— the plugin instance for an agent is logically isolated from instances on other agents. The plugin receives theagent_pathin its call context and uses it as part of its identity.isolation: project— the plugin instance is shared across agents in the project. The plugin still receivesagent_pathin context, but composes a project-scoped identity. Each participating agent declares the plugin on itself; non-declaring agents are excluded. Per-agent overrides underisolation: projectuse a parallelplugins.<name>block on the agent with only the overridden fields specified; non-specified fields inherit from any other declaration of the same plugin name elsewhere in the tree.
Tool naming. All plugins prefix their tool names with the plugin's name:
<plugin-name>.<tool>. Kaged does not reserve any namespace. Two plugins registering tools with the same prefixed name is an error at plugin load. Operators can also disable specific plugin tools in the agent'stools:block as a last-resort override; well-designed plugins expose their own per-tool toggles in their config schema.Tool enablement. Enabling a plugin on an agent automatically makes the plugin's tools available to that agent. The agent's
tools:block can opt out of specific tools but does not need to opt in. This is consistent with the cage-and-tools posture: declaration is opt-in; once declared, the surface is active.Cross-plugin coordination. Two plugins providing similar capabilities (e.g. two memory plugins) may both be declared. They must register distinct tool names (the prefix requirement handles this). If their behaviors are semantically inconsistent (the agent stores in one and recalls from the other), that is operator error and beyond kaged's scope.
Restart semantics. Plugin instances are recreated on daemon restart. Pending hook timers (notably
on_session_idle) are dropped; the next genuine session-activity event fires the hook normally. Plugins persist their own state if they need it.No built-in memory backend. Memory is implemented entirely as plugins.
@kaged/memory-markdownships as the reference floor;@kaged/memory-hindsight(or community equivalent) is the ceiling. There is nomemory:DSL block.Daemon plugins unchanged. This ADR is exclusively about project plugins. Daemon plugins per
docs/specs/plugins/system-plugins.mdretain their existing model.
Consequences
What this commits us to
- A
plugins:field onAgentSpec, recursive (each agent in the ADR-0022 tree may declare its own plugins). Schema work in@kaged/dsl. - Lifecycle-hook registration and dispatch in the plugin host. JSON-RPC method names, ordering, and timeout semantics specified in the
plugin-host.mdspec amendment. - The
PluginCallContextshape, stable across hooks and tool dispatches. Includesproject_id,agent_path,session_id,operator_id. - A merge function for project + system config blocks at plugin startup, with
system_onlyfield handling. - A plugin manifest schema extension for
hooks: [HookName[]]andsystem_config_schema. - A duplicate-tool-name check at plugin load. Failing this is a fatal startup error for the plugin (not the daemon).
- Reference plugin
@kaged/memory-markdownwritten test-first per ADR-0003. Spec lives atdocs/specs/plugins/memory-markdown.md. - An amendment to
agent.mddocumenting where in the harness lifecycle each hook fires. - An amendment to
project-dsl.mddocumenting the per-agentplugins:block.
What this forecloses
- A project-level
plugins:block. Plugins are per-agent. Operators who want a plugin on every agent declare it on every agent — explicitly. The repetition is the cost of the no-inheritance rule and is consistent with how cage and tools already work. - An in-tree default memory backend. There will never be a built-in
memory:field that "just works" without declaring a plugin. The operator always picks a backend. - A reserved
memory.*(or similar) tool namespace. Tool names always carry the plugin-name prefix. - An undifferentiated "everything in the project DSL is portable" claim. Plugin auth credentials live in operator-local config and are explicitly not portable. This is consistent with ADR-0011 (operator-local concerns live in local config) and ADR-0015 (the URI-prefix model already distinguishes
config:/fromproject:/). - Cross-tree plugin sharing. Even under
isolation: project, each participating agent must declare the plugin on itself; there is no project-level "this plugin is on for everyone."
What becomes easier
- Writing a new project plugin. The lifecycle hooks, the context object, the config split, the tool naming — all uniform across the ecosystem. A plugin author has one model to learn.
- Reading a project DSL. The agents that have plugins say so on themselves; no separate plugins block to cross-reference. Per-agent visibility is preserved.
- Building memory backends.
@kaged/memory-markdownand@kaged/memory-hindsightuse the same surface. Switching between them changes one line inproject.yaml. - Hosting the hindsight reference integration. Three tools, three hooks, an isolation key — every piece has a direct kaged-side analog.
- Extending the hook list. Future hooks (compaction-related, run-related, subagent-related) plug into the same machinery without DSL changes.
- The existing plugin-host spec finally has a non-trivial worked consumer. Memory exercises the full lifecycle.
What becomes harder
- Boilerplate for projects with many agents that all want the same plugin. Eight subagents that all want memory means eight
plugins.memoryblocks. Documented tradeoff; consistent with cage and tools. - Operator onboarding for "I just want memory to work." There is no zero-config path. The operator declares a plugin in the DSL. Mitigated by launcher / UI conveniences (a "enable memory" prompt that writes the declaration for them), not by architectural shortcut.
- Plugin authors writing for both "memory backend" and "audit sink" roles. Two declarations needed. Acceptable; multi-role plugins are rare.
- Reasoning about
isolation: projectwith partial agent participation. The parallel-block cascade means an operator can declare a plugin on three of five subagents and accidentally exclude two. Documented; the synthesized DSL endpoint surfaces "plugins active for this agent" so the picture is inspectable.
Spec amendments required
Each lands in its own PR per ADR-0003, citing this ADR in its amendments section.
| # | File | Change |
|---|---|---|
| 1 | docs/specs/plugin-host.md |
Add § Lifecycle hooks (firing semantics, JSON-RPC method names, timeout, ordering). Add § Plugin call context (PluginCallContext shape). Add § Project and system config (split, merge order, system_only field, manifest schema extension). Add § Plugin tool naming (prefix requirement, duplicate detection). Add § Isolation (the 'agent' | 'project' field, default, cascade pattern). Add § Restart semantics (pending-timer drop). |
| 2 | docs/specs/agent.md |
Add § Plugin hook firing points (where in runPrimary each hook fires; ordering with respect to existing processor pipeline). Document that on_session_start / on_session_idle fire only at the primary level; pre_compact and post_compact are per-agent. |
| 3 | docs/specs/project-dsl.md |
Add AgentSpec.plugins as the recursive per-agent declaration map. Update the AgentSpec schema in Appendix A. Remove any "memory" mention from the DSL grammar — there is no memory-specific DSL field. |
| 4 | docs/specs/plugins/memory-markdown.md (new) |
The reference floor backend's spec: storage layout against agent_path, manifest, hook subscriptions, tool surface (memory-markdown.retain, memory-markdown.recall; reflect omitted), README at store root. |
| 5 | docs/specs/local-config.md |
Add § Plugin system config — operator-local block keyed by plugin package name; merged into the plugin's config at startup; never committed. |
| 6 | docs/specs/http-api.md |
Add plugin-knob-schema endpoint (returns the project + system schemas for a given plugin, used by the UI). Add synthesized-plugins endpoint or extend the synthesized-DSL endpoint to surface "plugins active for this agent." |
After spec PRs land:
- Tests. Schema tests in
@kaged/dsl, host tests in@kaged/plugin-host, harness hook-firing tests in@kaged/harness. Example-validation tests indocs/dsl/examples/. - Reference plugin.
@kaged/memory-markdownimplementation test-first. - STATUS.md sync per the hard rule.
Open questions
These were resolved during RFC-0003 discussion and are recorded for cross-reference; the resolutions in this ADR are the load-bearing record:
- Tool enablement → automatic on plugin declaration; per-tool opt-out via
tools:block. - Hook subscription location → kaged-defined schema, plugin-block-scoped.
- Compaction hook firing → contract defined here; firing handled by ADR-0024.
- Compaction delegation surface → out of scope here, decided by ADR-0024.
- Tool namespace enforcement → none. Pure plugin-prefix uniqueness.
- Reflect on markdown backend → omit (markdown plugin's spec, item 4 above).
- Tags grammar → opaque pass-through.
- Isolation as a core plugin principle → adopted (decision item 6).
on_session_idledebounce → plugin-internal.- Storage layout → plugin-internal; kaged passes canonical
agent_path. - Two memory plugins in the same project → allowed; tool-name prefix prevents collisions.
- Daemon restart and pending idle timers → loose; documented (decision item 10).
Alternatives considered
Alternative A — Memory as a first-class DSL block with an in-tree default backend
A memory: field on AgentSpec with kaged shipping a markdown backend in-tree.
Why tempting: zero-config "just works" story for first-time operators. Per-agent visibility maximally explicit in the DSL.
Why rejected: every framework that ships a default backend ends up with the default's edge cases baked into the abstraction. The "first-class memory" claim becomes a claim, not a property. Audit sinks, notification plugins, telemetry backends — none of these get DSL blocks, but they have similar shapes. Memory doesn't deserve a special case unless we can articulate why; we cannot.
Alternative B — First-class DSL block, all backends are plugins
The DSL block stays but no in-tree default.
Why tempting: preserves per-agent DSL visibility while killing the in-tree-quirk-leak problem.
Why rejected: the DSL block becomes a thin layer over "this plugin provides memory and these hooks are enabled for this agent." It is data the plugin's own config carries. The asymmetry with other plugin-shaped capabilities (audit sinks, notifications) remains. A future memory backend that needs a new hook (e.g., before_tool_call) would need a DSL amendment; the chosen plugin-side surface is forward-compatible.
Alternative C — Project-level plugin declaration with subagent inheritance
Plugins declared once at the project root, inherited by all agents.
Why tempting: less DSL boilerplate. Matches some operator intuition ("memory is on for this project").
Why rejected: breaks the per-agent-everything posture established by ADR-0022. Implicit inheritance is exactly the anti-pattern that ADR-0022 set out to remove for cages and tools; plugins should not reintroduce it. Operators who want a plugin on every agent declare it on every agent — explicitly. The cost is documented and consistent.
Alternative D — Hook subscription as a generic agent-level field
AgentSpec.plugin_hooks: { "memory": ["on_session_start"] } — homogeneous across plugins, at the agent level.
Why tempting: maximally discoverable. One place to look for "what hooks fire for this agent."
Why rejected: splits the configuration surface across two locations (the plugin's config block and the agent's plugin_hooks field). Operators have to know to look in both. Putting the hook list inside the plugin's declaration block keeps the plugin's configuration together; the homogeneity comes from the schema being kaged-defined, not from the location being kaged-defined.
References
- RFC-0003 — Agent memory (the design exploration that produced this decision)
- ADR-0003 — Doc-first, then TDD (the process this ADR feeds into)
- ADR-0008 — Plugins are subprocesses over JSON-RPC on stdio (the transport this ADR builds on)
- ADR-0011 — Projects are portable; operator-local concerns live in local config (the principle motivating the project/system config split)
- ADR-0015 — Federated project configuration (the URI-prefix model the config split aligns with)
- ADR-0022 — Agents are recursive; tools and cage are per-agent (the posture this ADR extends to plugins)
docs/specs/plugin-host.md— The spec receiving most of the amendment workdocs/specs/agent.md— The spec documenting the harness-side hook firing points- vectorize-io/hindsight — The reference integration shape this ADR enables hosting