Skills are one of the best things to happen to Claude Code. You install a plugin, and suddenly you have a named capability you can fire off with a slash command. The problem is the auto-invocation part. Models are eager. Many skills ship with trigger descriptions like "use when the user asks about X, Y, or Z" — and the moment your prompt grazes one of those keywords, the skill fires whether you wanted it to or not.
How I Noticed
I have a few expensive skills installed. One is a forum sentiment research skill that fans out across multiple sites, pulls a lot of pages, and spends a small fortune in tokens before it produces anything. Another is a big coding bundle — superpowers — whose brainstorming sub-skill wants to be invoked before any creative task.
What kept happening:
- I'd say "can you plan a small change to this function" and the brainstorming skill would kick in, turning a 30-second ask into a multi-turn interview.
- I'd ask "what's the vibe around library X right now" out of casual curiosity, and the forum research skill would launch a full sweep before I could stop it.
Neither was what I wanted. The model wasn't wrong to consider them — the skill descriptions explicitly matched — but I only wanted those tools when I typed their name.
Attempt 1: CLAUDE.md Hints
First thing I tried, and the thing everyone tries: write it down in CLAUDE.md.
Do not invoke these skills unless the user explicitly asks: forum, superpowers, ...
This works sometimes. But skill descriptions are loaded as first-class instructions, and a strong "use when the user asks about X" line easily overpowers a general CLAUDE.md rule. You see the skill fire, you add stronger language, you see it fire again. The rule reads as a preference; the skill description reads as a command. Preferences lose.
Attempt 2: disable-model-invocation
The cleaner path is to just tell the harness not to let the model auto-invoke a skill. Claude Code supports a disable-model-invocation: true flag in a skill's SKILL.md frontmatter — set it, and the model can only reach the skill when the user types its slash command.
This worked perfectly for skills I'd authored locally under ~/.claude/skills/. I flipped the flag, the auto-triggers stopped, the slash command still worked.
Where it fell down: plugin skills. Plugins live under versioned cache directories like ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/<skill>/. Any frontmatter edit you make there gets wiped on the next plugin update. I could fork the plugin, flip the flag, and maintain my own branch — but that's a maintenance tax I didn't want to pay for three or four skills spread across different plugins.
I needed something that sat outside the plugin and survived plugin upgrades.
What Actually Worked: a PreToolUse Hook
Claude Code runs hook scripts at well-defined lifecycle points. PreToolUse fires before any tool call, and its matcher can be scoped to just the Skill tool so the script only runs where it's needed. The hook then returns a JSON permissionDecision: "deny" with a reason message, which Claude Code surfaces back to the model as feedback. That's exactly the shape of the lever I needed.
The hook does three things:
- Decide whether the skill about to fire is on a blocklist. There are two flavors: exact matches (
BLOCKED_EXACT, for user skills or full skill ids) and plugin matches (BLOCKED_PLUGINS, for any skill whoseSKILL.mdlives under a given plugin directory). - Read the latest real user prompt from the transcript, filtering out tool-result messages.
- Only let the call through if that prompt contains a real slash-command form of the skill —
/forum,/superpowers,/superpowers:brainstorming,/brainstorming. Casual prose mentions don't count.
The plugin-aware part is the piece disable-model-invocation can't give you for plugin skills. The hook globs the plugin cache to figure out whether a bare skill id (e.g. brainstorming) actually came from a blocked plugin, without touching any plugin files.
Here's the script. It lives at ~/.claude/hooks/skill-blocker.sh:
#!/bin/bash
# PreToolUse hook (matcher: Skill): block specific skills unless the latest
# user prompt explicitly invokes them as a slash command.
#
# Two block sources:
# BLOCKED_EXACT — user skills (or full skill ids) matched by name
# BLOCKED_PLUGINS — plugin dir names; any skill whose SKILL.md lives under
# ~/.claude/plugins/cache/<marketplace>/<plugin>/.../skills/<skill>/
# is blocked, whether invoked as bare name or with prefix.
#
# NOTE: For a STANDALONE user skill you own (under ~/.claude/skills/<name>/),
# prefer adding `disable-model-invocation: true` to its SKILL.md frontmatter —
# it's the native Claude Code mechanism and needs no hook. This script exists
# mainly because that flag is impractical for PLUGIN skills: plugins live under
# versioned cache dirs (~/.claude/plugins/cache/<marketplace>/<plugin>/<ver>/),
# and any frontmatter edit is wiped on the next plugin update. The hook lets
# you block plugin-origin skills declaratively via BLOCKED_PLUGINS without
# touching plugin files, and survives plugin upgrades.
shopt -s nullglob
INPUT=$(cat)
SKILL_NAME=$(echo "$INPUT" | jq -r '.tool_input.skill // empty')
TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty')
BLOCKED_EXACT="forum"
BLOCKED_PLUGINS="superpowers"
BASE_NAME="${SKILL_NAME##*:}"
PREFIX_NAME="${SKILL_NAME%%:*}"
skill_is_blocked() {
local skill="$1" base="$2" prefix="$3"
# Exact match on full id or bare name
for x in $BLOCKED_EXACT; do
[ "$skill" = "$x" ] && return 0
[ "$base" = "$x" ] && return 0
done
# Prefix-form match (e.g. skill id "superpowers:brainstorming")
if [ "$prefix" != "$skill" ]; then
for p in $BLOCKED_PLUGINS; do
[ "$prefix" = "$p" ] && return 0
done
fi
# On-disk plugin detection: skill id may arrive bare (e.g. "brainstorming")
# even when it actually comes from a blocked plugin's skills/ directory.
for p in $BLOCKED_PLUGINS; do
local matches=( "$HOME"/.claude/plugins/cache/*/"$p"/*/skills/"$base"/SKILL.md )
[ ${#matches[@]} -gt 0 ] && return 0
done
return 1
}
if ! skill_is_blocked "$SKILL_NAME" "$BASE_NAME" "$PREFIX_NAME"; then
exit 0
fi
# Grab the most recent real user prompt (ignore tool_result messages).
LATEST_USER=""
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
LATEST_USER=$(jq -rc '
select(.type=="user")
| .message.content
| if type=="string" then .
elif type=="array" then (map(select(.type=="text") | .text) | join(" "))
else "" end
| select(. != "")
' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
fi
# Allow only if the user prompt explicitly invoked the skill as a slash command
# (e.g. "/forum", "/superpowers:brainstorming", "/superpowers", "/brainstorming").
# Casual mentions without the leading "/" do NOT unblock.
if [ -n "$LATEST_USER" ]; then
if echo "$LATEST_USER" | grep -qE "(^|[^a-zA-Z0-9_-])/${SKILL_NAME}([^a-zA-Z0-9_:-]|$)"; then
exit 0
fi
if echo "$LATEST_USER" | grep -qE "(^|[^a-zA-Z0-9_-])/${BASE_NAME}([^a-zA-Z0-9_:-]|$)"; then
exit 0
fi
if [ "$PREFIX_NAME" != "$SKILL_NAME" ] && echo "$LATEST_USER" | grep -qE "(^|[^a-zA-Z0-9_-])/${PREFIX_NAME}([^a-zA-Z0-9_:-]|$)"; then
exit 0
fi
# Also honor a plugin-name slash when the skill resolved from that plugin
# but arrived bare (e.g. user typed "/superpowers" and skill id is "brainstorming").
for p in $BLOCKED_PLUGINS; do
plugin_matches=( "$HOME"/.claude/plugins/cache/*/"$p"/*/skills/"$BASE_NAME"/SKILL.md )
if [ ${#plugin_matches[@]} -gt 0 ] && echo "$LATEST_USER" | grep -qE "(^|[^a-zA-Z0-9_-])/${p}([^a-zA-Z0-9_:-]|$)"; then
exit 0
fi
done
fi
jq -n --arg skill "$SKILL_NAME" --arg base "$BASE_NAME" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Skill \"" + $skill + "\" is blocked — the user did not invoke it as a slash command (e.g. /" + $base + "). DO NOT retry this skill and DO NOT ask the user to re-authorize it. Fulfill the user'"'"'s original request without using this skill.")
}
}'
exit 0
And the hook registration in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/skill-blocker.sh"
}
]
}
]
}
}
Don't forget chmod +x ~/.claude/hooks/skill-blocker.sh.
Why This Works Where the Others Didn't
The hook sits one layer below the model. It doesn't care how persuasive the skill description is, how many "MUST" tags it has, or whether the plugin author thinks their skill should fire on every prompt. It sees the tool call, checks the rule, and returns a deny decision. The model gets a clear reason back ("did not invoke it as a slash command"), which is usually enough for it to pick a different path.
A few notes from using it for a while:
- Keep the blocklists short. Only the skills that actually cost you time or money. Over-blocking makes the model flail.
- Slash-command form is the allowlist signal. If the skill's name — bare or plugin-prefixed — appears as a slash command in the most recent user message, it's allowed through. That means
/forumunlocks the forum skill but a casual mention of "the forum" does not. - JSON decision, not exit code. The hook returns
permissionDecision: "deny"with a reason. Claude Code forwards the reason to the model as feedback, so you get a visible explanation of the block rather than a silent failure. - Plugin cache globbing. Skills from plugins sometimes arrive bare (just
brainstorming), without theplugin:prefix. The script resolves this by checking whether a matchingSKILL.mdexists under~/.claude/plugins/cache/*/<plugin>/*/skills/<skill>/. One entry inBLOCKED_PLUGINScovers every skill inside that plugin.
Summary
| Approach | Stops auto-invocation | Works on plugin skills | Maintenance |
|---|---|---|---|
| CLAUDE.md hint | Sometimes | Yes | Low |
disable-model-invocation | Yes | Only by forking the plugin | Medium (wiped on plugin update) |
| PreToolUse hook | Yes | Yes | Low |
If a skill description is louder than your CLAUDE.md, and the plugin doesn't give you a flag to flip, a small shell script is the smallest thing that will actually make the model shut up and wait until you ask.