Use a UserPromptSubmit hook to force Claude into evaluating and activating skills before every response.
Update (September 2026): I re-tested this hook on Claude Opus 5 with 288 headless runs. It no longer changed a single outcome — skills, multi-skill tasks and CLAUDE.md rules all worked just as well without it. See the full results.
Claude Code skills are supposed to activate autonomously based on their descriptions. In practice? They often stay dormant, quietly ignored while Claude proceeds with its own approach.
Scott Spence figured this out and shared a brilliant solution in his post How to Make Claude Code Skills Activate Reliably. All credit goes to him for the core idea.
The short version: his "forced eval hook" achieved 84% success rate in activating skills, compared to just 20% with simple instructions. That's a game changer.
Why It Works
The hook injects a mandatory pre-response sequence that forces Claude to:
- Evaluate each available skill explicitly (YES/NO with reasoning)
- Activate relevant skills using the
Skill()tool immediately - Implement only after activation is complete
The key insight is the commitment mechanism. Simple instructions become background noise. Forced evaluation creates accountability through explicit reasoning.
The Hook
The hook is built from three parts. Part B is Scott's original skill activation and the only part you strictly need. Part A and Part C are optional extensions — keep whichever fits your setup.
- Part A (optional): CLAUDE.md compliance check
- Part B: Skill activation (Scott's original approach)
- Part C (optional): Project-supplied rules
Create .claude/hooks/skill-forced-eval-hook.sh:
#!/bin/bash
# UserPromptSubmit hook that forces explicit skill evaluation and CLAUDE.md adherence.
#
# Parts A and B are universal. Part C is the extension point: the hook LOOKS UP
# rules the active project supplies and injects them verbatim. A project opts in
# by creating .claude/active-rules.md; projects without one see no Part C at all.
# The hook payload arrives on stdin; cwd tells us which project we are in.
# Never fail the hook on a parse problem — it fires on EVERY prompt.
_payload=""
if [ ! -t 0 ]; then _payload="$(cat 2>/dev/null || true)"; fi
_cwd=""
if command -v jq >/dev/null 2>&1 && [ -n "$_payload" ]; then
_cwd="$(printf '%s' "$_payload" | jq -r '.cwd // empty' 2>/dev/null || true)"
fi
[ -n "$_cwd" ] && [ -d "$_cwd" ] || _cwd="$PWD"
cat <<'EOF'
INSTRUCTION: MANDATORY PRE-RESPONSE SEQUENCE
=== PART A: CLAUDE.MD COMPLIANCE ===
Check BOTH levels of CLAUDE.md files:
1. Global: ~/.claude/CLAUDE.md (applies to ALL projects)
2. Repo-level: ./CLAUDE.md in the current repository (project-specific)
Step A1 - RECALL (do this in your response):
List each instruction from BOTH CLAUDE.md files that applies to this task:
- [source: global/repo] [instruction] - APPLIES/NOT APPLICABLE - [why]
Step A2 - COMMIT (state your commitment):
IF any instructions APPLY → State "Will follow: [list instructions]"
IF none apply → State "No CLAUDE.md instructions apply"
Step A3 - ENFORCE:
You MUST follow committed instructions throughout your response.
Example:
- [global] Run the test suite after code changes - APPLIES - editing code
- [repo] Use pnpm instead of npm - NOT APPLICABLE - no packages involved
Will follow: Run the test suite after code changes
=== PART B: SKILL ACTIVATION ===
Step B1 - EVALUATE (do this in your response):
For each skill in <available_skills>, state: [skill-name] - YES/NO - [reason]
Step B2 - ACTIVATE (do this immediately after Step B1):
IF any skills are YES → Use Skill(skill-name) tool for EACH relevant skill NOW
IF no skills are YES → State "No skills needed" and proceed
Step B3 - IMPLEMENT:
Only after Step B2 is complete, proceed with implementation.
CRITICAL: You MUST call Skill() tool in Step B2. Do NOT skip to implementation.
The evaluation (Step B1) is WORTHLESS unless you ACTIVATE (Step B2) the skills.
Example of correct sequence:
- research: NO - not a research task
- svelte5-runes: YES - need reactive state
- sveltekit-structure: YES - creating routes
[Then IMMEDIATELY use Skill() tool:]
> Skill(svelte5-runes)
> Skill(sveltekit-structure)
[THEN and ONLY THEN start implementation]
EOF
# --- PART C: project-supplied rules -----------------------------------------
# Walk up from cwd looking for .claude/active-rules.md, so a session started in
# a subdirectory still picks up the project's rules.
_rules=""
_dir="$_cwd"
while [ -n "$_dir" ] && [ "$_dir" != "/" ]; do
if [ -r "$_dir/.claude/active-rules.md" ]; then _rules="$_dir/.claude/active-rules.md"; break; fi
_dir="$(dirname "$_dir")"
done
if [ -n "$_rules" ] && [ -s "$_rules" ]; then
echo
echo "=== PART C: ACTIVE PROJECT RULES ==="
echo
echo "Standing rules for the project this session is working in ($_rules)."
echo "Treat them exactly like CLAUDE.md instructions: recall the ones that"
echo "apply in Part A, and follow them."
echo
# Cap the injection: this rides on every single prompt.
head -c 4000 "$_rules"
_size=$(wc -c < "$_rules")
if [ "$_size" -gt 4000 ]; then
echo
echo "[truncated at 4000 of $_size bytes — trim $_rules, it is injected on every prompt]"
fi
fi
Make it executable:
chmod +x .claude/hooks/skill-forced-eval-hook.sh
Optional: Enforce CLAUDE.md Too
Part A is an extension of Scott's hook. Global instructions often carry environment rules (which package manager to use, what to run before committing), and Claude occasionally ignores those just like it ignores skills. The same recall-and-commit pattern that makes skills activate works for instructions too.
If you only care about skills, delete Part A from the heredoc.
Optional: Project-Supplied Rules
Part C solves a different problem: rules that belong to one project, not to every session. Hardcoding them into the hook would make every prompt in every project carry one project's conventions.
Instead, the hook looks for .claude/active-rules.md, walking up from the working directory, and injects it only when it exists:
<!-- .claude/active-rules.md -->
- Never edit generated files under dist/ — change the source and rebuild.
- Every new API endpoint needs an integration test in the same change.
Two details matter here:
- Walk up, don't just check
cwd. A session started in a subdirectory still gets the project's rules. - Cap the size. The file rides on every single prompt, so an over-long rules file quietly eats the context budget. The hook truncates at 4 KB and says so.
Projects without the file pay nothing.
Configuration
Add the hook to your .claude/settings.json:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/skill-forced-eval-hook.sh"
}
]
}
]
}
}
Results
This has been a game changer in my workflow. Claude now consistently:
- Checks and follows global instructions from
CLAUDE.md - Evaluates available skills before diving into implementation
- Actually uses the skills I've carefully crafted
The aggressive language in the hook ("MANDATORY," "WORTHLESS," "CRITICAL") isn't accidental. It prevents Claude from treating the evaluation as optional and skipping straight to implementation.
Thanks again to Scott Spence for sharing this approach. Sometimes the best solutions are the ones that force explicit commitment rather than hoping for implicit understanding.
Update (September 2026): Is This Still Needed?
Eight months later the models changed, so I measured instead of assuming.
Setup. Claude Code 2.1.270, Claude Opus 5, headless claude -p. My real configuration: 185 skills and my global CLAUDE.md. Every task ran under three conditions:
- No hook
- Forced hook — the exact hook from this article
- Calm hook — a single plain paragraph: "If a listed skill clearly matches this task, invoke it with the Skill tool before doing other work. Name only the skills you invoke. Skip this for trivial or conversational prompts."
The hook-event stream confirmed the hook text reached the model in every hooked run. The model could only use the Skill and Read tools, so rule compliance was judged from the commands and config it wrote.
| Test | Runs | No hook | Forced hook | Calm hook |
|---|---|---|---|---|
| Explicit prompts ("create a mermaid flowchart…") + 6 prompts needing no skill | 54 | 12/12, 0 false positives | 12/12, 0 | 12/12, 0 |
| Implicit prompts (problem only: "our contact form gets bot spam") + 6 technical prompts needing no skill | 108 | 24/24¹, 0 | 24/24, 0 | 24/24, 0 |
CLAUDE.md rules: pnpm, pinned versions, example.com fixtures, .envrc before gcloud, --frozen-lockfile, --host | 54 | 18/18 | 18/18 | 18/18 |
| Tasks needing 2–3 different skills | 36 | 12/12 fully covered | 12/12 | 12/12 |
| Skill task after three unrelated turns in the same session | 36 | 12/12 | 12/12 | 12/12 |
SKILL.md with Read instead of the Skill tool and then used its content.
What the forced hook still costs:
- Latency on prompts that need no skill. Median 3.4 s → 6.1 s on trivial prompts and 9.4 s → 14.5 s on technical ones. The model walks through Parts A and B even for "what is 17 × 23".
- Extra context on multi-skill tasks. An average of 4.2 skills loaded instead of 3.6, mostly peripheral ones (for example
pythonandrepo-setupfor a CI security task), and about 14% higher cost.
Multi-skill loading doesn't need the hook either. Without it, Opus issued several Skill calls in parallel in one message in all 12 runs — up to four at once for "new static site with bot protection, optimized images and deployment to shared hosting".
Context. Scott re-tested in February 2026 with sandboxed evals: Sonnet 4.5 activated skills in 55% of runs without a hook and 100% with the forced eval. Anthropic's prompting guidance has since added a warning for newer models: "If your prompts were designed to reduce undertriggering on tools or skills, these models may now overtrigger. The fix is to dial back any aggressive language."
What the test doesn't cover:
- Only Opus 5 — no Sonnet or Haiku.
- Sessions of at most four turns, with no compaction.
- One to three runs per combination, so differences of a few percent would not show.
- Headless mode with only two tools.
On an older or smaller model, measure before you remove anything.
Bottom line. On Opus 5 with a large skill library, this hook no longer changed any result in my tests. It only added latency.