[BUG] Skill-scoped hooks defined in SKILL.md frontmatter are not triggered within plugins

Open 💬 23 comments Opened Jan 12, 2026 by yansfil

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Skill-scoped hooks defined in SKILL.md frontmatter (using the hooks YAML property) are not being triggered when the skill is loaded within a plugin.

I created a test skill with a PreToolUse hook that should trigger when Bash tool is called. The hook is defined in the SKILL.md frontmatter:

---
name: test
description: This skill should be used when the user says "test hooks"...
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "touch /tmp/skill-hook-triggered.txt"
          once: true
---

When I invoke the skill and then use the Bash tool, the hook is never executed - the file /tmp/skill-hook-triggered.txt is never created.

What Should Happen?

When a skill is loaded (via /test or natural language trigger), the hooks defined in that skill's SKILL.md frontmatter should be registered and executed when matching tools are used.

In this case, after loading the test skill and running any Bash command, the file /tmp/skill-hook-triggered.txt should be created.

Steps to Reproduce

  1. Create a plugin with the following structure:

``
plugins/eval/
├── .claude-plugin/
│ └── plugin.json
└── skills/
└── test/
└── SKILL.md
``

  1. Create SKILL.md with hooks in frontmatter:

```yaml
---
name: test
description: This skill should be used when the user says "test hooks", "hook 테스트", or wants to verify that skill-level hooks are working.
hooks:
PreToolUse:

  • matcher: "Bash"

hooks:

  • type: command

command: "touch /tmp/skill-hook-triggered.txt"
once: true
---

# Test Skill

Hook 동작 테스트를 위한 간단한 스킬입니다.
```

  1. Register and enable the plugin in settings.json
  1. Invoke the skill by typing /test or "hook 테스트"
  1. Run any Bash command (e.g., ls)
  1. Check if /tmp/skill-hook-triggered.txt exists

Result: File does not exist - hook was not triggered.

Claude Model

  • Opus

Is this a regression?

  • I don't know

Claude Code Version

2.1.5 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Cursor

Additional Information

The skill loads and displays correctly (the SKILL.md content is shown to the model), but the hooks defined in the frontmatter appear to be ignored.

This may be related to how skill-scoped hooks are parsed and registered when loaded dynamically via the skill invocation flow vs. static plugin hooks defined in hooks.json.

Plugin structure used for testing:

// .claude-plugin/plugin.json
{
  "name": "eval",
  "description": "Evaluation framework plugin",
  "version": "1.1.0"
}

View original on GitHub ↗

23 Comments

mavam · 5 months ago

This is quite painful for us, as all of our shared Claude Code infra in the team is shared via plugins. :-/

mavam · 5 months ago

Note that the same bug exists also for agents, not just skills.

We also tested whether this is related to how plugins are loaded:

  • Inline plugins (via --plugin-dir): hooks broken
  • Marketplace-installed plugins (via /plugin install): hooks broken

So the bug affects all plugin components regardless of installation method.

Summary:

  ┌─────────────────────────────┬───────────────────┬───────────────────┐
  │ Component Type              │     Location      │ Frontmatter Hooks │
  ├─────────────────────────────┼───────────────────┼───────────────────┤
  │ Project skill               │ .claude/skills/   │ Work              │
  ├─────────────────────────────┼───────────────────┼───────────────────┤
  │ Project agent               │ .claude/agents/   │ Work              │
  ├─────────────────────────────┼───────────────────┼───────────────────┤
  │ Plugin skill (inline)       │ --plugin-dir      │ Broken            │
  ├─────────────────────────────┼───────────────────┼───────────────────┤
  │ Plugin agent (inline)       │ --plugin-dir      │ Broken            │
  ├─────────────────────────────┼───────────────────┼───────────────────┤
  │ Plugin skill (marketplace)  │ /plugin install   │ Broken            │
  └─────────────────────────────┴───────────────────┴───────────────────┘
kentanakae · 5 months ago

Same problem here.

Butanium · 5 months ago

How is that not fixed yet?

Butanium · 5 months ago

Root Cause Analysis

I traced through the CLI source code (v2.1.34) and found the exact root cause. The bug also affects agent-scoped hooks defined in plugin agent .md frontmatter, not just skill-scoped hooks.

The Problem

There are two different functions that load agent definitions from .md files:

  1. dI2 — loads plugin agents (from plugins/*/agents/*.md)
  2. iH5 — loads local agents (from .claude/agents/*.md)

iH5 (local agents) correctly parses and includes hooks:

let S = cH5(B, Y);  // parses hooks from frontmatter via zod schema
// ...
return {
  // ...
  ...S !== void 0 ? {hooks: S} : {},  // includes hooks in definition
  // ...
}

dI2 (plugin agents) completely omits hooks:

return {
  agentType: V,
  whenToUse: K,
  tools: F,
  ...H !== void 0 ? {skills: H} : {},
  getSystemPrompt: () => L,
  source: "plugin",
  color: E,
  model: z,
  filename: W,
  plugin: G,
  ...{}  // ← empty spread where hooks should be
}

The frontmatter IS parsed (via QD), so X.hooks exists on the frontmatter object, but cH5() is never called and hooks are never included in the returned agent definition.

Why hooks never fire

Later, in the subagent spawn handler (cv async generator):

if (A.hooks) Rd2(B.setAppState, H, A.hooks, `agent '${A.agentType}'`, !0);

Since A.hooks is always undefined for plugin agents, Rd2 (which registers hooks into sessionHooks) is never called.

The fix

In dI2, add cH5() parsing and include hooks in the returned object:

// Add before the return:
let parsedHooks = cH5(X, W);  // X = frontmatter, W = agent name

// Add to the returned object:
...parsedHooks !== void 0 ? {hooks: parsedHooks} : {},

Additional note

dI2 is also missing several other frontmatter fields that iH5 supports: disallowedTools, mcpServers, permissionMode, forkContext, and maxTurns. These likely have the same issue — they're parsed from frontmatter for local agents but ignored for plugin agents.

Verified with

  • Confirmed local agent hooks (.claude/agents/*.md) fire correctly
  • Confirmed plugin agent hooks (plugins/*/agents/*.md) never fire
  • Manual testing of hook scripts confirms they work when invoked directly
  • Two independent analyses (Claude Code explorer + Gemini with full source context) reached the same conclusion

🤖 Generated with Claude Code

mavam · 5 months ago

(Is this based on some reverse engineering attempts, @Butanium? Cool.)

Honestly, I'm surprised it's not a P2 or higher. Hooks are critical for determinism. They simply don't work in plugins for agent and skills.

Plugins are the primary delivery vehicle for us—and I'm sure many other teams. We're basically stuck. It's incredibly frustrating. It shows a big Q/A gap and that composability of the various features isn't cleanly implemented.

Butanium · 5 months ago

Yeah Claude is just very good at reverse engineering. At this point I'm considering working on my own reversed engineer fork to fix all the annoying bugs like this one

runninghare · 5 months ago

@Butanium this reverse engineering is amazing!! 😁
The problem is still not fixed in 2.1.37. I spent the whole day messing around with this.

andkirby · 5 months ago

The same issue in 2.1.39.
Found the same in reddit.

kylesnowschwartz · 4 months ago

@dicksontsai - any chance you could weigh in on this one?

jason-rohman · 4 months ago

Still an issue on 2.1.62.

kuguma · 4 months ago

Still an issue on 2.1.63.

kuguma · 4 months ago

While no one at @anthropics may be paying attention to this bug, leaving it unaddressed is a clear reason to switch to the Codex CLI. For one thing, opus-4.6's ability to follow instructions is clearly worse than gpt-5.3's. Hook-based control is essential to guide Opus correctly, otherwise it would be difficult to put into production. (Humans would have to keep cleaning up after them.)
Currently, our company is debating whether we should primarily use Codex or Claude. I love Claude, but if the issue isn't resolved in the next patch version, we'll definitely switch to Codex. I hope a human reads this comment.

kuguma · 4 months ago

The biggest issue, after all, is that Claude Code is not open source.
I understand the business decision to keep it obfuscated for the sake of competitiveness, but that only works until a competitor surpasses it in quality. After that, the opposite force starts to work against you.

mavam · 4 months ago

@kuguma same here. Our entire team switched to pi because of this very bug remaining unaddressed for so long.

AviatorEngineer · 4 months ago

It's still not fixed in the latest release, we are also blocked because of this.

Butanium · 4 months ago

@mavam is it fixed for you?

mavam · 4 months ago

No. I've actually abandoned Claude Code as CLI tool because of this bug fest. I'm now a happy pi user where I have full control about any type of hook.

The way Anthropic implements hooks in skills is also non-standard. They should have been in metadata from day one. That's what I'm doing now.

johns10 · 3 months ago

Also have been grappling with this.

KostaGPT · 3 months ago

pls fix!

andkirby · 3 months ago

It's becoming a joke :D

Butanium · 3 months ago

Fwiw this is my crappy workaround that symlink on your current .claude the stuff from your plugin. Current limitation is that new skill files don't get added automatically you need to update manually with --force

#!/usr/bin/env bash
# Workaround for GH #17688: plugin agent/skill frontmatter hooks don't fire.
# Symlinks plugin agents, skills, hooks, tools, and examples into .claude/
# so they're loaded by the local agent loader (iH5) which correctly parses hooks.
#
# Prerequisites: hook commands must use "$CLAUDE_PROJECT_DIR"/... paths
# (not ${CLAUDE_PLUGIN_ROOT}) since local agents don't get CLAUDE_PLUGIN_ROOT.
#
# Usage:
#   ./scripts/install-plugin-locally.sh <plugin-dir> [--force] [--uninstall]

set -euo pipefail

PROJECT_ROOT="$(pwd)"
CLAUDE_DIR="$PROJECT_ROOT/.claude"

# --- Argument parsing ---

PLUGIN_DIR=""
FORCE=false
UNINSTALL=false

for arg in "$@"; do
    case "$arg" in
        --force) FORCE=true ;;
        --uninstall) UNINSTALL=true ;;
        -*) echo "error: unknown flag '$arg'" >&2; exit 1 ;;
        *) PLUGIN_DIR="$arg" ;;
    esac
done

if [[ -z "$PLUGIN_DIR" ]]; then
    echo "usage: $0 <plugin-dir> [--force] [--uninstall]" >&2
    exit 1
fi

PLUGIN_DIR="$(cd "$PROJECT_ROOT" && realpath "$PLUGIN_DIR")"

# --- Validation ---

if [[ ! -d "$PLUGIN_DIR" ]]; then
    echo "error: plugin directory does not exist: $PLUGIN_DIR" >&2
    exit 1
fi

PLUGIN_JSON="$PLUGIN_DIR/.claude-plugin/plugin.json"
if [[ ! -f "$PLUGIN_JSON" ]]; then
    echo "error: not a valid plugin (missing .claude-plugin/plugin.json): $PLUGIN_DIR" >&2
    exit 1
fi

PLUGIN_NAME="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['name'])" "$PLUGIN_JSON")"

# Check for CLAUDE_PLUGIN_ROOT references (should have been replaced with CLAUDE_PROJECT_DIR)
bad_refs="$(grep -rl 'CLAUDE_PLUGIN_ROOT' "$PLUGIN_DIR/agents" "$PLUGIN_DIR/skills" "$PLUGIN_DIR/hooks" "$PLUGIN_DIR/tools" "$PLUGIN_DIR/examples" 2>/dev/null || true)"
if [[ -n "$bad_refs" ]]; then
    echo "error: found \${CLAUDE_PLUGIN_ROOT} references (must use \"\$CLAUDE_PROJECT_DIR\" instead):" >&2
    echo "$bad_refs" | sed 's/^/  /' >&2
    exit 1
fi

echo "Plugin: $PLUGIN_NAME ($PLUGIN_DIR)"

# --- Helper: extract frontmatter 'name' field ---

extract_name() {
    python3 -c "
import sys, re
content = open(sys.argv[1]).read()
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not m: sys.exit(1)
for line in m.group(1).splitlines():
    k, _, v = line.partition(':')
    if k.strip() == 'name':
        print(v.strip().strip('\"').strip(\"'\"))
        sys.exit(0)
sys.exit(1)
" "$1"
}

# --- Collect agents and skills ---

declare -a SOURCES=() DESTS=() LABELS=() NAMES=()

if [[ -d "$PLUGIN_DIR/agents" ]]; then
    for src in "$PLUGIN_DIR/agents/"*.md; do
        [[ -f "$src" ]] || continue
        name="$(extract_name "$src")" || { echo "warning: skipping $src (no 'name')" >&2; continue; }
        SOURCES+=("$src"); DESTS+=("$CLAUDE_DIR/agents/$name.md"); LABELS+=("agent"); NAMES+=("$name")
    done
fi

if [[ -d "$PLUGIN_DIR/skills" ]]; then
    for skill_dir in "$PLUGIN_DIR/skills"/*/; do
        [[ -d "$skill_dir" ]] || continue
        src="$skill_dir/SKILL.md"
        [[ -f "$src" ]] || continue
        name="$(extract_name "$src")" || { echo "warning: skipping $src (no 'name')" >&2; continue; }
        SOURCES+=("$src"); DESTS+=("$CLAUDE_DIR/skills/$name/SKILL.md"); LABELS+=("skill"); NAMES+=("$name")
    done
fi

# --- Detect directory-level symlink dirs (hooks, tools, examples) ---

DIR_TYPES=("hooks" "tools" "examples")
declare -a DIR_SRCS=() DIR_DSTS=()

for dir_type in "${DIR_TYPES[@]}"; do
    if [[ -d "$PLUGIN_DIR/$dir_type" ]]; then
        DIR_SRCS+=("$PLUGIN_DIR/$dir_type")
        DIR_DSTS+=("$CLAUDE_DIR/$dir_type/$PLUGIN_NAME")
    fi
done

if [[ ${#SOURCES[@]} -eq 0 && ${#DIR_SRCS[@]} -eq 0 ]]; then
    echo "Nothing to install."; exit 0
fi

# --- Pre-flight: check ALL targets before writing anything ---

if ! $FORCE && ! $UNINSTALL; then
    conflicts=()
    for dst in "${DESTS[@]}"; do
        [[ -e "$dst" ]] && conflicts+=("$dst")
    done
    for dst in "${DIR_DSTS[@]}"; do
        [[ -e "$dst" ]] && conflicts+=("$dst")
    done
    if [[ ${#conflicts[@]} -gt 0 ]]; then
        echo "error: the following targets already exist:" >&2
        printf '  %s\n' "${conflicts[@]}" >&2
        echo "Use --force to overwrite, or --uninstall first." >&2
        exit 1
    fi
fi

# --- Uninstall ---

if $UNINSTALL; then
    echo "Uninstalling $PLUGIN_NAME..."
    for i in "${!DESTS[@]}"; do
        dst="${DESTS[$i]}"
        if [[ -L "$dst" ]]; then
            rm "$dst"
            echo "  removed ${LABELS[$i]}: ${NAMES[$i]}"
            # Clean up empty skill dirs
            parent="$(dirname "$dst")"
            rmdir "$parent" 2>/dev/null || true
        elif [[ -e "$dst" ]]; then
            echo "  skipped ${NAMES[$i]} (not a symlink, won't remove)" >&2
        fi
    done
    for i in "${!DIR_DSTS[@]}"; do
        dst="${DIR_DSTS[$i]}"
        dir_type="$(basename "$(dirname "$dst")")"
        if [[ -L "$dst" ]]; then
            rm "$dst"
            echo "  removed $dir_type: $PLUGIN_NAME"
        elif [[ -e "$dst" ]]; then
            echo "  skipped $dir_type (not a symlink, won't remove)" >&2
        fi
    done
    echo "Done."
    exit 0
fi

# --- Install ---

echo "Installing $PLUGIN_NAME..."
n_agents=0 n_skills=0

for i in "${!SOURCES[@]}"; do
    src="${SOURCES[$i]}" dst="${DESTS[$i]}" label="${LABELS[$i]}" name="${NAMES[$i]}"

    if [[ -e "$dst" ]]; then
        if $FORCE; then
            rm "$dst"
        fi
    fi

    mkdir -p "$(dirname "$dst")"
    rel="$(realpath --relative-to="$(dirname "$dst")" "$src")"
    ln -s "$rel" "$dst"
    echo "  ${label}: ${name} -> $(basename "$src")"

    case "$label" in agent) n_agents=$((n_agents + 1)) ;; skill) n_skills=$((n_skills + 1)) ;; esac
done

# --- Install directory-level symlinks (hooks, tools, examples) ---

n_dirs=0
for i in "${!DIR_SRCS[@]}"; do
    src="${DIR_SRCS[$i]}" dst="${DIR_DSTS[$i]}"
    dir_type="$(basename "$(dirname "$dst")")"

    if [[ -e "$dst" ]]; then
        if $FORCE; then
            rm -f "$dst"
        fi
    fi
    mkdir -p "$(dirname "$dst")"
    rel="$(realpath --relative-to="$(dirname "$dst")" "$src")"
    ln -s "$rel" "$dst"
    echo "  $dir_type: $PLUGIN_NAME -> $dir_type/"
    n_dirs=$((n_dirs + 1))
done

echo ""
echo "Installed: ${n_agents} agent(s), ${n_skills} skill(s), ${n_dirs} dir(s)"
echo "Restart Claude Code for changes to take effect."
liplus-lin-lay · 1 month ago

Project-context agents now also affected — Windows, 2026-05-18

The summary table in the OP (Project skill / Project agent = Work, Plugin = Broken) doesn't match observed behavior anymore. As of today on Windows + Claude Code Desktop, Project agent (.claude/agents/*.md) frontmatter hooks also fail to fire. Posting fresh reproduction; closely matches #18392 (locked, dup of this issue) / #19213 (locked) / #39468.

Setup

.claude/agents/parallel-evaluator.md:

---
name: parallel-evaluator
tools: Read, Grep, Glob, Bash, WebFetch, mcp__github-webhook-mcp__get_pending_status
hooks:
  UserPromptSubmit:
    - hooks:
        - type: command
          command: "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/on-user-prompt.sh\""
        - type: mcp_tool
          server: github-webhook-mcp
          tool: get_pending_status
          input: {}
  SessionStart:
    - matcher: startup
      hooks:
        - type: command
          command: "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/on-session-start.sh\""
    # resume / clear / compact identical
  PostToolUse:
    - matcher: Bash
      hooks:
        - type: command
          command: "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/probe-posttool.sh\""
---

probe-posttool.sh emits hookSpecificOutput.additionalContext JSON via jq, so injection would be observable.

Observation

When the subagent is spawned via Agent tool (subagent_type=parallel-evaluator):

  • UserPromptSubmit configured output → not delivered to subagent context
  • SessionStart (any matcher) → not delivered
  • PostToolUse:Bash probe marker → not delivered, even when the subagent uses the Bash tool

Notably, Claude Code host-side <system-reminder> blocks (MCP server instructions, "work without stopping for clarifying questions") DO appear in subagent context after tool use. So the subagent's reminder-injection channel is operational — user-configured hook output just doesn't enter it. (May be related to #43612's CLAUDE_CODE_SIMPLE / _R() observation.)

Regression timeline anchor

A downstream project relying on subagent + project-agent + hook integration was implemented and end-to-end verified working on 2026-03-26. The same setup no longer works as of 2026-05-18. Estimated window: ~7 weeks. Possibly correlated with v2.1.105 (PreCompact hook blocking), v2.1.118 (mcp_tool in hooks), or v2.1.133 (effort level passed to hooks) — not narrowed further.

Additional observation

.claude/agents/*.md modifications are not picked up mid-session — Claude Code Desktop restart is required for the agent registry to refresh.

Environment

  • Claude Code Desktop
  • OS: Windows
  • Context: Project agent (.claude/agents/*.md), not plugin