You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Show and Tell] canon-guardian: selective workspace canon re-injection on model switch / native fallback / restart
Author:@smonett (intensive daily user since 2026.2) Audited against: OpenClaw 2026.4.27 (verified against dist/ package internals — line refs below) Companion to:#65824 item 1 ("Silent model fallback + no system-prompt re-injection") Status: Working in production. Sharing as a reference implementation, not a feature ask.
The problem
Long-lived OpenClaw sessions running on agents.defaults.contextInjection: "continuation-skip" skip re-injecting workspace bootstrap (AGENTS.md, SOUL.md, etc.) on every turn — saves a lot of tokens.
The trade-off shows up during native model fallback: when the primary 402s / rate-limits and the fallback chain fires, the fallback model inherits the conversation transcript but not the workspace bootstrap. Same for manual /model switches mid-session, and for sessions that drift past the 6+ hour mark where we'd want a sanity refresh.
Result: the model-that-actually-answers wakes up partially blind to operating procedures and persona — the "session suddenly feels different" failure mode. (We filed this as #65824 item 1; this plugin is our local mitigation while we wait on a native solution.)
What canon-guardian does
A small native plugin that re-injects the workspace canon only on events that justify it:
Trigger
Reason string
Detection
New session (first turn)
new-session
no prior state for sessionId
Model switch (manual or fallback)
model-switch
${providerId}:${modelId} changed since last turn
Long idle / restart
restart-or-idle
now - lastInjectMs > restartIdleMs (default 6h)
Periodic safety net
periodic-50
turnCount % 50 === 0
If none of those fire, the hook returns nothing. On a quiet turn the plugin's overhead is one Map lookup.
Why we picked before_prompt_build
We tried llm_input first based on outside advice. That was wrong, and the cost was a couple of bad turns where we thought it was working but our fallback model was still booting blind. Receipts:
dist/hook-runner-global-BmRBtwY4.js lines ~140-160 — mergeBeforePromptBuild is the only merge function that exposes prependSystemContext, the field that ends up in the cache-eligible prefix of the system prompt.
dist/status-DjPbn_Lx.js line ~111 — runtime explicitly warns plugin authors: "still uses legacy before_agent_start; keep regression coverage on this plugin, and prefer before_model_resolve / before_prompt_build for new work." That's the maintainer-blessed direction; we followed it.
dist/extensions/active-memory/index.js line ~1190 — the first-party active-memory extension uses before_prompt_build with the same try/catch shape. Same pattern, same hook, validated upstream.
Sharing the misstep here so the next plugin author doesn't repeat it. If there's appetite for a docs note explicitly steering people away from llm_input for system-prompt mutation, happy to PR one.
Multi-model behavior (audited)
dist/lifecycle-hook-helpers-g--XRrcl.js lines 4-18 — buildAgentHookContext populates modelProviderId + modelId provider-agnostically. Every provider flows through the same builder.
The plugin keys on the composite ${providerId}:${modelId}, so all the following count as a model-switch event:
Anthropic ↔ xAI / Grok
Anthropic ↔ Google / Gemini
Anthropic ↔ OpenAI
Same provider, different tier (e.g. anthropic:claude-opus-4-7 → anthropic:claude-haiku-4-5)
On native fallback specifically: the hook context reflects the model that's about to answer, not the originally-configured primary. That's the behavior we want — re-inject for whoever is on the call.
How it composes with other plugins
mergeBeforePromptBuild (same file as above) concatenatesprependSystemContext segments from every registered hook rather than overwriting. So canon-guardian composes cleanly with active-memory and any other plugin that uses the same hook surface — order is registration order, separator is the runtime's concatOptionalTextSegments joiner.
When this beats contextInjection: "always"
contextInjection: "always" is the brute-force alternative: re-inject canon on every turn. That works but pays the canon's input-token cost on every turn. On a 50KB canon × 200-turn session at Opus rates, the difference is measurable.
canon-guardian re-injects on roughly 2–4% of turns in our usage (new session + occasional fallback + periodic safety net). Same correctness, fraction of the cost.
If your sessions are short or your canon is small, contextInjection: "always" is the simpler answer and we'd recommend that first.
What 4.27 already gets right
buildFallbackNotice emits ↪️ Model Fallback: <active> lifecycle events on transition (visible to verbose users)
The before_prompt_build contract is well-defined and stable; the mergeBeforePromptBuild semantics (concat, not overwrite) make plugin composition safe by design
Default hook timeout (DEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK.before_prompt_build = 15000) is generous enough for canon load even on cold cache
The canon-guardian plugin slots in cleanly because the platform already gave us the right shape to slot into.
Restart the gateway. Look for [canon-guardian] loaded on startup, and [canon-guardian] re-injecting canon (reason=...) on the first turn of each session.
Sample output during a primary→fallback transition:
Cache friendliness: prepending via prependSystemContext puts canon at the head of the system prompt, where Anthropic / OpenAI / Google all cache stable prefixes. Re-inject turns are roughly one cache-write, not a full re-encode. (Anthropic specifically: stable system-prompt prefix → 90% cost reduction on cached tokens.)
Workspace path safety: rejects paths that escape workspaceDir via prefix check; symlinks not followed beyond the workspace root.
Failure isolation: entire hook body in try/catch; on error logs and returns no injection rather than crashing the prompt build. Same shape as active-memory.
Memory profile: per-session state is ~50 bytes; canon cache is one entry per workspace and is invalidated on gateway restart.
Composition:prependSystemContext from multiple plugins is concatenated (mergeBeforePromptBuild), not overwritten. Safe to run alongside active-memory or other prepend-context plugins.
Acknowledged trade-offs
In-memory state. Survives across turns within a process, dies on gateway restart. That's intentional but it does mean the first turn after a restart re-injects even on a continuation — by design, but worth flagging.
Periodic safety net. Default 50 turns is empirical, not theoretical. If you've never seen drift in a long session, you can disable periodic by setting injectionInterval: 0.
Workspace-dir cache. Caches canon file content in memory; if you vi AGENTS.md mid-session the change isn't picked up until restart. Acceptable for our usage; trivially defeatable by adding an mtime check if needed.
Would a docs note explicitly steering people away from llm_input for system-prompt mutation be welcome? Happy to draft a PR if so. The runtime warning on before_agent_start is excellent; an analogous "this is the right hook for X" note in plugin docs would have saved us the misstep.
— Posted from a 2026.4.27 install on macOS arm64. Thanks to the maintainers for the clean hook surface — once we found before_prompt_build, the rest fell out of the design.
[Show and Tell] canon-guardian: selective workspace canon re-injection on model switch / native fallback / restart
Author: @smonett (intensive daily user since 2026.2)
Audited against: OpenClaw 2026.4.27 (verified against
dist/package internals — line refs below)Companion to: #65824 item 1 ("Silent model fallback + no system-prompt re-injection")
Status: Working in production. Sharing as a reference implementation, not a feature ask.
The problem
Long-lived OpenClaw sessions running on
agents.defaults.contextInjection: "continuation-skip"skip re-injecting workspace bootstrap (AGENTS.md,SOUL.md, etc.) on every turn — saves a lot of tokens.The trade-off shows up during native model fallback: when the primary 402s / rate-limits and the fallback chain fires, the fallback model inherits the conversation transcript but not the workspace bootstrap. Same for manual
/modelswitches mid-session, and for sessions that drift past the 6+ hour mark where we'd want a sanity refresh.Result: the model-that-actually-answers wakes up partially blind to operating procedures and persona — the "session suddenly feels different" failure mode. (We filed this as #65824 item 1; this plugin is our local mitigation while we wait on a native solution.)
What canon-guardian does
A small native plugin that re-injects the workspace canon only on events that justify it:
new-sessionsessionIdmodel-switch${providerId}:${modelId}changed since last turnrestart-or-idlenow - lastInjectMs > restartIdleMs(default 6h)periodic-50turnCount % 50 === 0If none of those fire, the hook returns nothing. On a quiet turn the plugin's overhead is one
Maplookup.Why we picked
before_prompt_buildWe tried
llm_inputfirst based on outside advice. That was wrong, and the cost was a couple of bad turns where we thought it was working but our fallback model was still booting blind. Receipts:dist/hook-runner-global-BmRBtwY4.jslines ~140-160 —mergeBeforePromptBuildis the only merge function that exposesprependSystemContext, the field that ends up in the cache-eligible prefix of the system prompt.dist/status-DjPbn_Lx.jsline ~111 — runtime explicitly warns plugin authors: "still uses legacybefore_agent_start; keep regression coverage on this plugin, and preferbefore_model_resolve/before_prompt_buildfor new work." That's the maintainer-blessed direction; we followed it.dist/extensions/active-memory/index.jsline ~1190 — the first-partyactive-memoryextension usesbefore_prompt_buildwith the same try/catch shape. Same pattern, same hook, validated upstream.Sharing the misstep here so the next plugin author doesn't repeat it. If there's appetite for a docs note explicitly steering people away from
llm_inputfor system-prompt mutation, happy to PR one.Multi-model behavior (audited)
dist/lifecycle-hook-helpers-g--XRrcl.jslines 4-18 —buildAgentHookContextpopulatesmodelProviderId+modelIdprovider-agnostically. Every provider flows through the same builder.${providerId}:${modelId}, so all the following count as amodel-switchevent:anthropic:claude-opus-4-7→anthropic:claude-haiku-4-5)How it composes with other plugins
mergeBeforePromptBuild(same file as above) concatenatesprependSystemContextsegments from every registered hook rather than overwriting. So canon-guardian composes cleanly withactive-memoryand any other plugin that uses the same hook surface — order is registration order, separator is the runtime'sconcatOptionalTextSegmentsjoiner.When this beats
contextInjection: "always"contextInjection: "always"is the brute-force alternative: re-inject canon on every turn. That works but pays the canon's input-token cost on every turn. On a 50KB canon × 200-turn session at Opus rates, the difference is measurable.canon-guardian re-injects on roughly 2–4% of turns in our usage (new session + occasional fallback + periodic safety net). Same correctness, fraction of the cost.
If your sessions are short or your canon is small,
contextInjection: "always"is the simpler answer and we'd recommend that first.What 4.27 already gets right
buildFallbackNoticeemits↪️ Model Fallback: <active>lifecycle events on transition (visible to verbose users)before_prompt_buildcontract is well-defined and stable; themergeBeforePromptBuildsemantics (concat, not overwrite) make plugin composition safe by designDEFAULT_MODIFYING_HOOK_TIMEOUT_MS_BY_HOOK.before_prompt_build = 15000) is generous enough for canon load even on cold cacheThe canon-guardian plugin slots in cleanly because the platform already gave us the right shape to slot into.
Code
Two files. ~140 LOC total.
~/.openclaw/plugins/canon-guardian/openclaw.plugin.json:{ "id": "canon-guardian", "name": "Canon Guardian", "version": "1.0.0", "description": "Selective canon re-injection on model switch, new session, gateway restart, and periodic intervals.", "main": "./index.js", "type": "module" }~/.openclaw/plugins/canon-guardian/index.js:Configuration
Add to
openclaw.json:{ "plugins": { "allow": ["canon-guardian"], "search": ["/Users/<you>/.openclaw/plugins/canon-guardian"], "config": { "canon-guardian": { "canonFiles": ["AGENTS.md", "SOUL.md", "OPERATING_RULES.md"], "injectionInterval": 50, "restartIdleMs": 21600000, "logInjections": true } } } }Restart the gateway. Look for
[canon-guardian] loadedon startup, and[canon-guardian] re-injecting canon (reason=...)on the first turn of each session.Sample output during a primary→fallback transition:
Operational notes
prependSystemContextputs canon at the head of the system prompt, where Anthropic / OpenAI / Google all cache stable prefixes. Re-inject turns are roughly one cache-write, not a full re-encode. (Anthropic specifically: stable system-prompt prefix → 90% cost reduction on cached tokens.)workspaceDirvia prefix check; symlinks not followed beyond the workspace root.try/catch; on error logs and returns no injection rather than crashing the prompt build. Same shape asactive-memory.prependSystemContextfrom multiple plugins is concatenated (mergeBeforePromptBuild), not overwritten. Safe to run alongsideactive-memoryor other prepend-context plugins.Acknowledged trade-offs
injectionInterval: 0.vi AGENTS.mdmid-session the change isn't picked up until restart. Acceptable for our usage; trivially defeatable by adding an mtime check if needed.Questions for the maintainers
agents.defaults.fallback.reinjectBootstrap: truewould obsolete this plugin and we'd happily retire it. (Captured in [Meta] Feature request bundle: 11 platform gaps from intensive daily use #65824 item 1.)llm_inputfor system-prompt mutation be welcome? Happy to draft a PR if so. The runtime warning onbefore_agent_startis excellent; an analogous "this is the right hook for X" note in plugin docs would have saved us the misstep.— Posted from a 2026.4.27 install on macOS arm64. Thanks to the maintainers for the clean hook surface — once we found
before_prompt_build, the rest fell out of the design.