CCLearn

Route to the right skill with a scoring hook

Past a dozen skills, description matching picks the wrong one. A UserPromptSubmit hook can score each prompt against a checked-in rules file (keywords, path patterns, intent regexes) and inject a suggestion: the routing is deterministic, the invocation still is not.

docs corroborate

The official documentation describes the mechanism this relies on. The technique itself is community practice.

What this costs you if it is wrong

The hook runs on every single prompt, so a slow script taxes every turn, and its output is a second source of truth about every skill that rots silently when a skill is renamed. Injected text is also replayed stale on --resume, and imperative phrasing in it can trip Claude Code's prompt-injection defenses. Exhaust the built-in levers first.

You wrote fifteen skills. Three of them fire reliably. The rest sit there while Claude reaches for Grep and Edit and does the work by hand — or worse, invokes the almost right one. You sharpen a description, it helps for a week, then a sixteenth skill lands and something else stops matching.

The docs' answer to this is a four-step list: put the keywords users actually say in the description, confirm the skill is listed, rephrase your request, invoke it as /skill-name. All reasonable. All still asking the model to make the choice.

Why it degrades specifically at a dozen skills

There is a mechanical reason this gets worse with scale, and it is documented. Claude Code loads a listing of every skill name and description into context. That listing has a character budget — 1% of the model's context window by default — and when it overflows, Claude Code drops descriptions starting with the skills you invoke least. The skills that aren't firing are exactly the skills whose keywords get trimmed first. The failure feeds itself.

Reach for the built-in levers before you build anything:

LeverWhat it doesWhere
when_to_use frontmatterTrigger phrases appended to the descriptionSKILL.md
skillListingBudgetFractionRaises the listing budget above 1%settings
skillOverrides: "name-only"Frees budget by delisting low-value skillssettings
disable-model-invocation: trueTakes a noisy skill out of the running entirelySKILL.md
skill-creator description tuningMeasures hit rate on should-trigger / should-not-trigger promptsplugin
.claude/rules/ with paths:Loads instructions when Claude touches matching files.claude/rules/

Run /skill-doctor first. If a skill is never invoked, deleting it buys budget for the ones that matter, and that costs nothing to maintain. It is a recent command — the docs say it "requires Claude Code v2.1.252 or later and feature-flag fetching" — so on an older build run /doctor instead, which the skills page offers "for an estimate of the listing's context cost and its biggest contributors."

The community pattern

Teams that ran out of road there built a router. The shape, as submitted: a checked-in .claude/hooks/skill-rules.json describing every skill's signals, and a skill-eval.sh wired to UserPromptSubmit that scores the incoming prompt against it and injects a suggestion when the score clears a floor.

The reported weighting table from ChrisWiles/claude-code-showcase:

SignalWeight
directoryMatch5
pathPattern4
intentPattern4
keywordPattern3
contentPattern3
keyword2
contextPattern2

with minConfidenceScore: 3, maxSkillsToShow: 5, and a directoryMappings block tying src/graphql to a graphql skill, .github/workflows to a GitHub Actions skill, and so on.

Do not copy the numbers. Copy the ordering. The claim embedded in that table is that which directory the work is in predicts the right skill better than any word in the prompt — which is why directoryMatch is 5 and a bare keyword is 2. If that is the only signal you want, you may not need a hook at all.

The word "deterministic" is doing too much work

Here is the correction that matters, and it is straight from the hooks reference: UserPromptSubmit can't replace the prompt; it only injects additionalContext alongside it. There is no field that makes Claude invoke a skill. Your router deterministically decides what to suggest and then hands the model a suggestion it is still free to ignore.

That is a genuine improvement — a targeted "the graphql-schema skill applies here" beats a truncated listing entry — but sell it to your team as better signal, not as enforcement. If you need enforcement, the lever is PreToolUse blocking the tool you don't want used, not UserPromptSubmit nudging toward the one you do.

What it costs

  • Every turn pays. UserPromptSubmit blocks model processing until the hook returns. Its command timeout default is already lowered to 30 seconds for this event; the showcase repo sets 5. On timeout the output is discarded silently-ish — the prompt goes through without your context and the transcript notes it.
  • Resume replays stale. Claude Code saves injected text in the transcript and replays it on --continue or --resume rather than re-running the hook for past turns. Scores computed against yesterday's working directory come back verbatim.
  • Two sources of truth. Rename a skill and the router keeps recommending a name that no longer resolves, with nothing to tell you. If you build this, add a check that every skill named in the rules file still exists on disk, and run it in CI.

What is unverified here

Nobody on this side has run this. The docs corroborate every mechanism involved — the UserPromptSubmit event, hookSpecificOutput.additionalContext, the listing budget, path-scoped rules, and the one clause in the skills page recommending hooks "to enforce behavior deterministically" — but no page in the corpus describes prompt scoring, confidence thresholds, or skill routing as a practice. The specific weights, the schema version, and the 5-second timeout are reported from one repository and were not fetched or executed during review.

What would settle it: pull the two files and read them yourself.

bash
curl -sf "https://raw.githubusercontent.com/ChrisWiles/claude-code-showcase/main/.claude/hooks/skill-rules.json" \
  | jq '.version, .scoring, .directoryMappings' \
  && curl -sf "https://raw.githubusercontent.com/ChrisWiles/claude-code-showcase/main/.claude/settings.json" \
  | jq '.hooks.UserPromptSubmit'

What that still won't tell you is whether the router actually improves hit rate over a well-tuned set of descriptions. The honest experiment is skill-creator's description tuning: it generates should-trigger and should-not-trigger prompts and measures the hit rate. Get that number without the hook, then with it. If the gap is small, you have bought yourself a maintenance burden.

Checkpoint

  • Why does skill matching get worse as you add skills, rather than staying flat?
    Show answer

    Because the skill listing has a budget and overflowing it is not neutral. Claude Code loads every skill's name and description into context under a character budget — 1% of the model's context window by default — and on overflow it drops descriptions starting with the skills you invoke least, which are exactly the ones already failing to fire. The failure feeds itself, which is why the first fixes are budget fixes: delete what /skill-doctor shows is never invoked (or /doctor on a build older than v2.1.252), raise skillListingBudgetFraction, delist low-value skills with skillOverrides: "name-only", or take a noisy one out of the running with disable-model-invocation: true.

  • Your hook scores a prompt at 9 and injects a suggestion. Can Claude still ignore it? Why?
    Show answer

    Yes, and that is the correction this tip exists to make. The hooks reference is explicit that UserPromptSubmit cannot replace the prompt — it only injects additionalContext alongside it, and there is no field that makes Claude invoke a skill. So the scoring is deterministic and the invocation is not: what you bought is better signal, not enforcement, and that is how to sell it to your team. If you need enforcement the lever is PreToolUse blocking the tool you do not want used, and phrase the injected text as a factual statement, since wording framed as an out-of-band command can trip the prompt-injection defenses and get surfaced to you instead of used.

  • You want a rule to apply whenever work touches src/api/. When does a paths:-scoped rule fire, and why is that a different moment from a UserPromptSubmit hook?
    Show answer

    A paths: rule in .claude/rules/ fires when Claude reads a file matching the pattern — the docs are explicit that it triggers on file access, not on every tool use — so it lands once work is under way and a plan is already forming. A UserPromptSubmit hook fires before the first token, which is the moment at which a suggestion can still change which skill gets picked. Same intent, different moment and different job: path rules carry conventions that apply to the code, the hook influences the routing decision itself. Both mechanisms are documented; the scoring router built on top of them is not, and its weights come from one repository that was never fetched or run during review.

Where this came from

Official pages that back this