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

  1. Create a plugin with this structure:
my-plugin/
├── .claude-plugin/
│   └── plugin.json
└── hooks/
    └── hooks.json
  1. In plugin.json, reference the hooks file:
{
  "name": "my-plugin",
  "version": "1.0.0",
  "hooks": "./hooks/hooks.json"
}
  1. In hooks/hooks.json, define a hook:
{
  "hooks": {
    "SubagentStop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Hook fired' >> /tmp/hook-test.log"
          }
        ]
      }
    ]
  }
}
  1. Install and enable the plugin
  2. Trigger a subagent (Task tool)
  3. 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

View original on GitHub ↗

23 Comments

Summonair · 6 months ago

Having exactly same bug, its destroying our productivity potential at our company, pleaseeee investigate this bug 🙏🏼 @dicksontsai

MariusWilsch · 5 months ago

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:

plugins/claude-code-team-plugin/
├── .claude-plugin/
│   └── plugin.json    # has "hooks": "./hooks/hooks.json"
└── hooks/
    └── hooks.json     # SessionStart, PreToolUse, PostToolUse hooks

Symptoms:

  • CLAUDE_PLUGIN_ROOT env var is empty
  • CLAUDE_CONVERSATION_PATH never set by SessionStart hook
  • Hooks simply don't fire

Verified:

  • plugin.json correctly references "hooks": "./hooks/hooks.json"
  • hooks/hooks.json exists with valid content
  • Same hooks work when copied to ~/.claude/settings.json

Workaround: 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.

Talbalash-legit · 5 months ago

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

ro0sterjam · 5 months ago

+1 Docs clearly state that hooks are to be registered this way, but they don't get invoked at all.

DataDrivenAngel · 5 months ago

Confirmed that this is an active issue on mac as well

mikeparcewski · 4 months ago

Additional data: SubagentStart also affected, plus workaround

We independently hit this same bug with SubagentStart (not just SubagentStop). Confirmed on macOS Darwin 24.6.0, Claude Code with marketplace plugins.

Reproduction

  1. Plugin hooks/hooks.json registers SubagentStart with "matcher": "*" and a command hook
  2. Added a debug marker (Path("/tmp/debug.log").write()) inside the hook script
  3. Spawned subagent via Task tool → debug file never created — hook script never invoked
  4. Moved identical hook config to .claude/settings.jsondebug file created immediately, subagent received additionalContext

What 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 SessionStart hook (which fires correctly from plugin hooks) to auto-install the SubagentStart hook into .claude/settings.local.json:

def _ensure_subagent_hook():
    """Plugin hooks.json doesn't fire SubagentStart. Auto-install into settings."""
    cwd = Path.cwd()
    claude_dir = cwd / ".claude"
    if not claude_dir.exists():
        return

    # Skip if already configured in any settings file
    for name in ("settings.json", "settings.local.json"):
        path = claude_dir / name
        if path.exists():
            try:
                data = json.loads(path.read_text())
                if "SubagentStart" in data.get("hooks", {}):
                    return
            except Exception:
                continue

    # Install into settings.local.json (gitignored, non-invasive)
    local_path = claude_dir / "settings.local.json"
    local = json.loads(local_path.read_text()) if local_path.exists() else {}
    local.setdefault("hooks", {})["SubagentStart"] = [{
        "matcher": "*",
        "hooks": [{"type": "command", "command": "python3 /path/to/hook.py", "timeout": 3000}]
    }]
    local_path.write_text(json.dumps(local, indent=2))

This runs on every session start and is idempotent (checks before writing). Not ideal but functional until plugin-scoped hooks support all events.

hmerrilees · 4 months ago

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.

DataDrivenAngel · 4 months ago

Able to reproduce on 2.1.72.

Hellblazer · 3 months ago

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 (nx and sn) with hooks/hooks.json defining SubagentStart hooks. Both plugins are enabled in settings.json under enabledPlugins. Convention-based discovery (no explicit "hooks" field in plugin.json).

Symptoms:

  • SessionStart hooks from the same hooks/hooks.json do fire (visible as system-reminder tags in session context)
  • SubagentStart hooks from the same file never fire — confirmed via marker file test (touch /tmp/marker as line 2 of the hook script; file never created after spawning agents)
  • Subagents see only claudeMd + currentDate in their context — zero hook-injected content
  • Other hook events from the same plugin (PreToolUse, PostToolUse, PermissionRequest, Stop) appear to work
  • /reload-plugins reports the correct hook count (16 hooks across all plugins), so discovery/counting works — dispatch doesn't

Binary analysis: executeSubagentStartHooks (function E68) exists and is called from the agent spawn path with (agentId, agentType, signal). The hook resolution function EA8Mk5 correctly handles empty matchers. The code path looks complete but hooks are never invoked.

Workaround: For SessionStart, the nx hook session-start CLI command provides an alternative dispatch path that works. No equivalent workaround exists for SubagentStart.

This blocks injecting context into subagents from plugins — a core use case for the SubagentStart event.

Hellblazer · 3 months ago

+1

Hellblazer · 3 months ago

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 from xx (runAgent) with (agentId, agentType, signal)
  • g9_ yields from _X (executeHooks) which calls Aw8 (getMatchingHooks)
  • getMatchingHooks reads both snapshot hooks (settings.json) and registered hooks (plugins) via GV() (getRegisteredHooks)
  • Empty matcher "" is correctly handled: !k.matcher is truthy for "" in JS

The bug: xx (runAgent) calls g9_ without first awaiting loadPluginHooks(). Plugin hooks are loaded fire-and-forget at startup (void m.loadPluginHooks()). If that promise hasn't resolved — or if clearAllCaches() has been called between startup and agent spawn — STATE.registeredHooks is null and plugin hooks are invisible to the dispatch.

By contrast, otH (executeSessionStartHooks) is always preceded by await loadPluginHooks() in processSessionStartHooks. This is why SessionStart plugin hooks work but SubagentStart plugin hooks don't.

The fix is adding the same guard that sessionStart.ts uses:

// In runAgent, before executeSubagentStartHooks:
try {
  await loadPluginHooks()
} catch (error) {
  logForDebugging(`Failed to load plugin hooks: ${error}`, { level: 'error' })
}

This is safe because loadPluginHooks is memoized — the await serializes 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 in loadPluginHooks.ts:142).

LewenW · 3 months ago

Plugin developer here. My plugin distill-me relies on UserPromptSubmit and Stop hooks from hooks/hooks.json for 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 Stop follows the same pattern as SubagentStart/Stop — lifecycle hooks don't fire from plugin scope. Per @Hellblazer's root cause analysis, this is the missing await 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.json in the plugin spec is to avoid that.

For Cowork specifically, the --setting-sources user flag (#27398) excludes ALL plugin hooks — neither UserPromptSubmit nor Stop fires. That's a separate issue but compounds the same problem.

Minimal repro with my plugin:

git clone https://github.com/LewenW/claude-distill-me.git
cd claude-distill-me && pip install -e .
# hooks/hooks.json defines UserPromptSubmit + Stop
# Stop hook never fires from plugin scope

+1 for @Hellblazer's proposed fix — adding the await loadPluginHooks() guard before all hook dispatchers, not just processSessionStartHooks.

figgsoftware · 3 months ago

Confirming this blocks org-wide plugin adoption tracking

Setup:

  • PostToolUse matcher Skill, plugin-scoped hooks/hooks.json
  • Command: python "${CLAUDE_PLUGIN_ROOT}/hooks/log_skill.py" → Azure App Insights
  • Script + App Insights pipeline verified working when invoked manually, so the failure is strictly in hook discovery

Behavior:

  • ✅ CLI / IDE sessions: hook fires as expected
  • ❌ Claude Desktop / Cowork sessions: silent no-op on every skill invocation
  • Matches the --setting-sources user root cause from #27398

+1 for prioritising — this is blocking real org-level rollouts, not just individual dev tooling.

priyankabot · 2 months ago

+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.

xg-gh-25 · 2 months ago

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:

#!/bin/bash
# install-plugin-hooks.sh — merges plugin hooks into settings.json
PLUGIN_DIR="$1"
HOOKS_FILE="$PLUGIN_DIR/hooks/hooks.json"
SETTINGS="$HOME/.claude/settings.json"

if [[ ! -f "$HOOKS_FILE" ]]; then
  echo "No hooks.json in $PLUGIN_DIR"; exit 0
fi

# Use python to merge (avoids jq escaping issues)
python3 -c "
import json, sys
with open(sys.argv[1]) as f: settings = json.load(f)
with open(sys.argv[2]) as f: plugin_hooks = json.load(f)
settings.setdefault(\"hooks\", {}).update(plugin_hooks.get(\"hooks\", {}))
with open(sys.argv[1], \"w\") as f: json.dump(settings, f, indent=2)
" "$SETTINGS" "$HOOKS_FILE"

echo "Merged hooks from $PLUGIN_DIR into settings.json"

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 in settings.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.json template 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.json directly. 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

priyankabot · 2 months ago
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: #!/bin/bash # install-plugin-hooks.sh — merges plugin hooks into settings.json PLUGIN_DIR="$1" HOOKS_FILE="$PLUGIN_DIR/hooks/hooks.json" SETTINGS="$HOME/.claude/settings.json" if [[ ! -f "$HOOKS_FILE" ]]; then echo "No hooks.json in $PLUGIN_DIR"; exit 0 fi # Use python to merge (avoids jq escaping issues) python3 -c " import json, sys with open(sys.argv[1]) as f: settings = json.load(f) with open(sys.argv[2]) as f: plugin_hooks = json.load(f) settings.setdefault(\"hooks\", {}).update(plugin_hooks.get(\"hooks\", {})) with open(sys.argv[1], \"w\") as f: json.dump(settings, f, indent=2) " "$SETTINGS" "$HOOKS_FILE" echo "Merged hooks from $PLUGIN_DIR into settings.json" 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 in settings.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.json template 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.json directly. 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.

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:

{
  "hooks": {
"UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 /Users/home/.claude/hooks/claude_hooks.py"
          }
        ]
      }
    ]
}
averydev · 1 month ago

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.

xg-gh-25 · 1 month ago

Nice approach. Two workaround patterns in the wild now:

  1. Generate hooks into settings.json at 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.
  1. Embed mandatory script calls inside skill instructions (yours) — simpler setup, no install step. Downside: relies on agent compliance — the model choosing to run your script vs the harness guaranteeing it runs. Under token pressure or long context, compliance degrades.

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 executeSubagentStartHooks exists) — 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.

justfalter · 1 month ago

I'm experiencing this issue with CwdChanged and FileChanged. See #63148 for details.

bonds · 1 month ago

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.json defines a PreToolUse/Bash hook, /hooks lists 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.log at Desktop startup (worth grepping for if anyone else hits this):

[remoteUploadsMigration] upload_migration.gate_check gate=cowork_plugin_host_ops value=true
[PluginsFetcher] fetchAccountScopedRemotePlugins: N plugins from 1 marketplace(s)
[LocalPluginsReader] Found 2 local plugins
[CCD] Plugin "<plugin>@<custom-marketplace>" exists in both remote and local. Using remote.
[CCD] Passing N plugin(s) to SDK (skills: 1, remote: 2, local: 1)

The custom-marketplace plugin is sourced from remote; its commands / skills / MCP server load fine, but the hooks declared in its hooks/hooks.json never wire to the event pipeline.

Things that did not help:

  • /plugin update (CLI)
  • /plugin disable then /plugin enable (CLI)
  • /plugin uninstall then /plugin install (CLI)
  • /hooks to re-scan (CLI)
  • Full Desktop quit + relaunch (after every state mutation above)

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 user filtering.

Happy to provide additional logs or a minimal repro if it helps prioritize.

xg-gh-25 · 1 month ago

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.

Saurav-Sutaria · 21 days ago

@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.

lucatrading21-debug · 10 days ago

A Cowork-specific data point that may help scope this. On Windows Cowork VM (July 2026), for our marketplace plugin swe using the convention-based hooks/hooks.json, plugin hooks are loaded only partially in Cowork:

  • SessionStart does 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).
  • UserPromptSubmit and UserPromptExpansion from the same hooks/hooks.json do not fire in Cowork.

So at least in Cowork it isn't "no plugin hooks load at all" — SessionStart loads 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 not SessionStart.

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.log spawn line if useful.