Plugin hooks not loaded from external hooks.json file
Open 💬 23 comments Opened Jan 4, 2026 by ImproperSubset
Description
Hooks defined in an external hooks.json file referenced from a plugin's plugin.json are not being loaded or executed.
Steps to Reproduce
- Create a plugin with this structure:
my-plugin/
├── .claude-plugin/
│ └── plugin.json
└── hooks/
└── hooks.json
- In
plugin.json, reference the hooks file:
{
"name": "my-plugin",
"version": "1.0.0",
"hooks": "./hooks/hooks.json"
}
- In
hooks/hooks.json, define a hook:
{
"hooks": {
"SubagentStop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo 'Hook fired' >> /tmp/hook-test.log"
}
]
}
]
}
}
- Install and enable the plugin
- Trigger a subagent (Task tool)
- Check
/tmp/hook-test.log- file does not exist
Expected Behavior
The hook should fire and write to the log file when a subagent completes.
Actual Behavior
The hook never fires. The log file is never created.
Workaround
Defining the same hook directly in ~/.claude/settings.json works correctly - the hook fires as expected. This confirms:
- The hook event (SubagentStop) works
- The hook command is valid
- Only plugin-based hook discovery is broken
Environment
- Claude Code version: Latest
- OS: Linux (WSL2)
- Plugin installed via marketplace
23 Comments
Having exactly same bug, its destroying our productivity potential at our company, pleaseeee investigate this bug 🙏🏼 @dicksontsai
Confirming this issue
We hit the exact same bug with our team plugin.
Plugin: claude-code-team-plugin
Version: 2026-01-25-11-09
Setup:
Symptoms:
CLAUDE_PLUGIN_ROOTenv var is emptyCLAUDE_CONVERSATION_PATHnever set by SessionStart hookVerified:
plugin.jsoncorrectly references"hooks": "./hooks/hooks.json"hooks/hooks.jsonexists with valid content~/.claude/settings.jsonWorkaround: Users must manually copy hooks to their settings.json until this is fixed.
This is blocking plugin distribution - we can't ship hooks via plugins.
This issue blocks our internal plugin distribution. External hooks.json files are not loaded, making our plugins' hooks unusable.
Manually copying the hooks to settings.json is not a viable workaround for us
+1 Docs clearly state that hooks are to be registered this way, but they don't get invoked at all.
Confirmed that this is an active issue on mac as well
Additional data: SubagentStart also affected, plus workaround
We independently hit this same bug with
SubagentStart(not justSubagentStop). Confirmed on macOS Darwin 24.6.0, Claude Code with marketplace plugins.Reproduction
hooks/hooks.jsonregistersSubagentStartwith"matcher": "*"and a command hookPath("/tmp/debug.log").write()) inside the hook script.claude/settings.json→ debug file created immediately, subagent receivedadditionalContextWhat we tested
| Hook event | Plugin hooks.json | settings.json |
|---|---|---|
|
SessionStart| ✅ fires | ✅ fires ||
UserPromptSubmit| ✅ fires | ✅ fires ||
PreToolUse| ✅ fires | ✅ fires ||
PostToolUse| ✅ fires | ✅ fires ||
SubagentStart| ❌ silent no-op | ✅ fires ||
SubagentStop| ❌ (per this issue) | ✅ fires |Workaround
We use the plugin's
SessionStarthook (which fires correctly from plugin hooks) to auto-install theSubagentStarthook into.claude/settings.local.json:This runs on every session start and is idempotent (checks before writing). Not ideal but functional until plugin-scoped hooks support all events.
Adding WorktreeCreate and WorktreeRemove to @mikeparcewski's matrix:
| Hook event | Plugin hooks.json | settings.json |
|---|---|---|
| WorktreeCreate | ❌ silent no-op | ✅ fires |
| WorktreeRemove | ❌ silent no-op | ✅ fires |
Same pattern as SubagentStart/SubagentStop — infrastructure/lifecycle hooks outside the core tool/session events don't fire from plugin scope.
Reproduced on macOS Darwin 25.2.0 and Linux, Claude Code v2.1.62.
Able to reproduce on 2.1.72.
Confirming — also affects SubagentStart hooks on macOS (CLI, not Cowork)
Environment: Claude Code 2.1.104, macOS Darwin 25.3.0, CLI (not Cowork/Desktop)
Plugin: Two marketplace plugins (
nxandsn) withhooks/hooks.jsondefiningSubagentStarthooks. Both plugins are enabled insettings.jsonunderenabledPlugins. Convention-based discovery (no explicit"hooks"field inplugin.json).Symptoms:
SessionStarthooks from the samehooks/hooks.jsondo fire (visible assystem-remindertags in session context)SubagentStarthooks from the same file never fire — confirmed via marker file test (touch /tmp/markeras line 2 of the hook script; file never created after spawning agents)claudeMd+currentDatein their context — zero hook-injected contentPreToolUse,PostToolUse,PermissionRequest,Stop) appear to work/reload-pluginsreports the correct hook count (16 hooks across all plugins), so discovery/counting works — dispatch doesn'tBinary analysis:
executeSubagentStartHooks(functionE68) exists and is called from the agent spawn path with(agentId, agentType, signal). The hook resolution functionEA8→Mk5correctly handles empty matchers. The code path looks complete but hooks are never invoked.Workaround: For
SessionStart, thenx hook session-startCLI command provides an alternative dispatch path that works. No equivalent workaround exists forSubagentStart.This blocks injecting context into subagents from plugins — a core use case for the
SubagentStartevent.+1
Update: Root cause identified from binary analysis of Claude Code 2.1.104
Extracted the minified JS from the Bun-compiled binary (
strings $(which claude)). The dispatch chain is fully wired:g9_(executeSubagentStartHooks) exists and is called fromxx(runAgent) with(agentId, agentType, signal)g9_yields from_X(executeHooks) which callsAw8(getMatchingHooks)getMatchingHooksreads both snapshot hooks (settings.json) and registered hooks (plugins) viaGV()(getRegisteredHooks)""is correctly handled:!k.matcheris truthy for""in JSThe bug:
xx(runAgent) callsg9_without first awaitingloadPluginHooks(). Plugin hooks are loaded fire-and-forget at startup (void m.loadPluginHooks()). If that promise hasn't resolved — or ifclearAllCaches()has been called between startup and agent spawn —STATE.registeredHooksis null and plugin hooks are invisible to the dispatch.By contrast,
otH(executeSessionStartHooks) is always preceded byawait loadPluginHooks()inprocessSessionStartHooks. This is why SessionStart plugin hooks work but SubagentStart plugin hooks don't.The fix is adding the same guard that
sessionStart.tsuses:This is safe because
loadPluginHooksis memoized — theawaitserializes against any in-flight load (including hot-reload races) with no redundant work.The same missing guard likely affects other hook events dispatched outside
processSessionStartHooks(e.g.,SubagentStop,Stop— see gh-29767 comment inloadPluginHooks.ts:142).Plugin developer here. My plugin distill-me relies on
UserPromptSubmitandStophooks fromhooks/hooks.jsonfor real-time learning capture — every user message goes through a detection pipeline and gets queued for later pattern extraction.Impact on my plugin:
| Hook | Purpose | Plugin hooks.json (CLI) | settings.json |
|------|---------|:-:|:-:|
|
UserPromptSubmit| Capture corrections, preferences, style signals | ✅ works | ✅ works ||
Stop| Summarize learnings at session end | ❌ never fires | ✅ works |So
Stopfollows the same pattern asSubagentStart/Stop— lifecycle hooks don't fire from plugin scope. Per @Hellblazer's root cause analysis, this is the missingawait loadPluginHooks()guard before dispatch.This blocks plugin distribution. I can't ship a plugin that tells users "also manually copy these hooks into your settings.json". The whole point of
hooks/hooks.jsonin the plugin spec is to avoid that.For Cowork specifically, the
--setting-sources userflag (#27398) excludes ALL plugin hooks — neitherUserPromptSubmitnorStopfires. That's a separate issue but compounds the same problem.Minimal repro with my plugin:
+1 for @Hellblazer's proposed fix — adding the
await loadPluginHooks()guard before all hook dispatchers, not justprocessSessionStartHooks.Confirming this blocks org-wide plugin adoption tracking
Setup:
PostToolUsematcherSkill, plugin-scopedhooks/hooks.jsonpython "${CLAUDE_PLUGIN_ROOT}/hooks/log_skill.py"→ Azure App InsightsBehavior:
--setting-sources userroot cause from #27398+1 for prioritising — this is blocking real org-level rollouts, not just individual dev tooling.
+1 for prioritizing!!, Claude Cowork hooks do not work and this is blocking observability checks at an organization level. Claude code hooks in settings.json work, but that doesn't work for Claude Cowork which uses the plugin approach.
Workaround that has been reliable for us (not a fix for the plugin loader bug, but solves the same need):
We run hooks via
settings.json(which works) but generate the hooks entries programmatically from plugin manifests at install time. This bridges the gap until the plugin loader is fixed:Why this works: The issue is specifically in the plugin loader resolving relative paths from
plugin.json. The hooks engine itself is fine — it happily executes anything registered insettings.json. So the fix is: skip the broken loader, inject directly.For org-wide deployment (the Cowork use case several people mentioned): we ship hooks as part of a
.claude/settings.jsontemplate that gets provisioned per-workspace. Each workspace gets its hooks at creation time, not via plugin discovery. More explicit and easier to debug.Architecture note: In our system we have 12+ hooks (PreToolUse, PostToolUse, session lifecycle) all declared in
settings.jsondirectly. The hooks themselves are Python scripts managed by the backend. Never hit the plugin loader path at all. If you are building a hooks-heavy system, consider this pattern — more work upfront but zero surprise at runtime.---
12+ hooks in production: SwarmAI. Discussion: Six Self-X Properties
Thanks we have hooks in ~/.claude/settings.json as well for Claude code but they do not fire for Claude cowork, can you share redacted examples of your settings.json for the hooks that work for Claude cowork? What is the corresponding plugin manifest and structure that is installed for Cowork? Thank you
My settings.json has the following which works for Claude Code, but not for Cowork:
This is a really irritating bug. I've built significant tooling around hooks working and not being able to use any of that for cowork is a significant limitation. i ended up writing a skill and putting mandatory script calls in the project instructions, but realistically its terrible in comparison.
Nice approach. Two workaround patterns in the wild now:
settings.jsonat install time (ours) — works for both Claude Code and Cowork since settings.json is the one hook path that reliably loads. Downside: requires an install script that rewrites settings on every plugin update.Both are duct tape over the same gap: plugin-scoped hooks should just work. The hooks dispatcher is clearly wired internally (Hellblazer's binary analysis above confirms
executeSubagentStartHooksexists) — it's a loader bug, not a missing feature.For anyone reading: if you need guaranteed execution (observability, security scanning), go with pattern 1. If you need best-effort execution (convenience, soft guardrails), pattern 2 is less friction.
I'm experiencing this issue with
CwdChangedandFileChanged. See #63148 for details.This comment was written by Claude on my behalf. My fault if it sucks, the bot is working for me. Disclosing so y'all know.
...
Hitting this on macOS Claude Desktop today with a custom (GitHub-backed) account-scoped marketplace — plugin's
hooks/hooks.jsondefines aPreToolUse/Bashhook,/hookslists it, the script never runs. Confirmed by instrumenting the script with an early append-to-log line: zero invocations even after a full app restart.Identifying log lines from
~/Library/Logs/Claude/main.logat Desktop startup (worth grepping for if anyone else hits this):The custom-marketplace plugin is sourced from
remote; its commands / skills / MCP server load fine, but the hooks declared in itshooks/hooks.jsonnever wire to the event pipeline.Things that did not help:
/plugin update(CLI)/plugin disablethen/plugin enable(CLI)/plugin uninstallthen/plugin install(CLI)/hooksto re-scan (CLI)What did work (Workaround #1 from @xg-gh-25's comment): install the same hook directly into
~/.claude/settings.json(user scope). Fires immediately — settings.json is watched, so no restart needed. For our plugin we baked this into the install-time setup wizard so teammates get it without having to know about the bug; the bridge command resolves the latest installed guard at hook-invocation time so it survives/plugin update.Possibly-useful diagnostic crumb: the per-plugin state directory
~/.claude/plugins/data/<plugin>-<marketplace>was not created for the affected plugin, even after uninstall+reinstall. The same directory is present for our other (working) plugin from the same marketplace — they differ in that the working one ships only an MCP server, while the broken one ships hooks. Might indicate an incomplete-registration step upstream of--setting-sources userfiltering.Happy to provide additional logs or a minimal repro if it helps prioritize.
Good to see the pattern 1 workaround (settings.json injection) getting traction — @priyankabot's Cowork constraint is exactly why we went this route. One update from our side: we now version-stamp the generated hooks entries with the plugin version that produced them, so on plugin update you can detect stale hooks and regenerate. Saves debugging "why did my hook stop working after update" — it's always a stale settings.json entry. For Cowork orgs, the template-provisioning approach (hooks baked into workspace creation) remains the cleanest path until this loader bug ships a fix.
@ImproperSubset
for claude code, instead of referring hook.json from plugin.json, try writing this hook configuration directly in the plugin.json, I was also facing the same issue, and doing this worked for me.
A Cowork-specific data point that may help scope this. On Windows Cowork VM (July 2026), for our marketplace plugin
sweusing the convention-basedhooks/hooks.json, plugin hooks are loaded only partially in Cowork:SessionStartdoes load and fire — our hook injects context and the instance acts on it as its first output (deterministic; our opening "session card" appears before any user input).UserPromptSubmitandUserPromptExpansionfrom the samehooks/hooks.jsondo not fire in Cowork.So at least in Cowork it isn't "no plugin hooks load at all" —
SessionStartloads while the prompt-lifecycle hooks don't. This looks tied to Cowork spawning the CLI with--setting-sources user(see the now-closed #27398, auto-marked as a duplicate of this issue), which excludes plugin scope for some events but apparently notSessionStart.Related: #63360 (user-scope hooks in Cowork), #69750 (request for a pre-exit event that can trigger a Claude turn — blocked by the same gap). Environment: Claude Desktop on Windows 11 (MSIX), Cowork VM. Happy to share the plugin source and the
cowork_vm_node.logspawn line if useful.