Matching is a retrieval problem, not a routing table
Once a runtime has more than a handful of skills installed, the question stops being "which skill should run" and becomes "how does the runtime decide, cheaply, for every single request, without a human writing a rule for each new skill." That's a retrieval problem: given a request and a catalog of short description strings, find the closest match. Get the retrieval step wrong and the rest of the skills architecture -- however cleanly decoupled -- never actually fires correctly.
This is the mechanism half of the skills architecture piece: not why skills are decoupled from the runtime, but the concrete matcher that sits between a request and a skill's body being loaded into context.
Three ways to implement the match
Keyword and heuristic matching. The cheapest option: tokenize the request, tokenize each skill's name and description, score overlap. It's fast and needs no model call, but it's brittle -- a request phrased as "can you look over this diff before I merge" won't token-match a description that only says "review a PR," even though a human reads the two as identical intent.
Embedding similarity. Encode the request and every skill description into the same vector space, rank by cosine distance. This closes most of the keyword-matching gap (paraphrases land close together) at the cost of maintaining an embedding index and a similarity threshold that needs tuning per catalog size. It scales well to large catalogs because the expensive part -- encoding the descriptions -- happens once, offline, not per request.
LLM-as-router. Hand the model the request plus the full list of name/description pairs and ask it to pick the best match, or none. This is the most accurate of the three because the router shares the same language understanding as the model that will eventually execute the skill, and it's what Claude Code and comparable tools actually do at moderate catalog sizes. The cost is a model call before the model call, which is real but usually small relative to running the matched skill itself, since the router only ever sees metadata, never full skill bodies.
In practice the three aren't exclusive: a cheap keyword or embedding pass narrows a large catalog to a handful of plausible candidates, and an LLM call makes the final call among those few. That two-stage design is the same coarse-then-precise pattern that shows up in agent request routing generally.
Why the description matters more than the algorithm
No matching algorithm rescues a bad description. A description that says only "helps with code" will collide with half the catalog under any of the three approaches above, because there's no signal in it to discriminate on. The two properties that actually move match quality are independent of which matcher you pick:
Specific triggers. "Review a diff, PR, or set of changed files for correctness bugs, security issues, and unnecessary complexity" gives every matcher concrete nouns to latch onto -- diff, PR, changed files, bugs -- that a vague "helps with code" doesn't.
Explicit negative scope. The single highest-leverage addition to a description is a clause stating what the skill is not for. This site's own code-review skill description ends with "not for general code questions" specifically because without that clause, any message that merely mentions code risked a false match. A negative clause does something a matching algorithm structurally cannot do on its own: it tells the retrieval step to actively rule a candidate out, not just rank it lower.
# weak -- true of nearly every request in a coding tool
description: Helps with code.
# strong -- specific triggers, explicit negative scope
description: Review a diff, PR, or set of changed files for correctness
bugs, security issues, and unnecessary complexity. Use when asked to
review code or check a PR before it merges -- not for general code
questions or explaining how existing code works.When two skills both plausibly match
Overlap is the normal failure mode in a catalog that's grown past a few dozen entries, not an edge case. Two patterns cause it: two skills genuinely covering adjacent ground (a code-review skill and a security-review skill both plausibly fire on "check this PR for problems"), or one skill's description drifting broad enough to shadow a more specific one that should have won.
The second case is a maintenance bug and the fix is narrowing the broad description. The first case is a real design decision, and it has two honest resolutions: let both fire and combine their outputs (reasonable when the skills are genuinely complementary, like review + security-review producing two independent passes over the same diff), or add a tie-breaking signal -- usually a stated priority order, or a more specific negative-scope clause on one of the two so it backs off when the other's trigger is present. What doesn't work is silently picking whichever skill happens to sort first alphabetically or load first: that produces an intermittent bug that looks like flakiness in the model rather than what it actually is, an unresolved catalog design question.
The context-budget cost of a large catalog
Checking a request against N skill descriptions is not free, even when only one skill's full body ever gets loaded. The LLM-as-router approach pays for it directly: the router call's prompt has to contain every candidate's name and description, so a catalog of 200 skills means 200 short strings in the router's context on every single request, before any actual work happens. At a few dozen tokens per description that's a few thousand tokens of pure overhead paid every time, independent of which skill (if any) ends up matching.
Three mitigations are standard once this starts to matter: cache the router's decision for identical or near-identical requests within a session; pre-filter with a cheap embedding pass so the LLM router only ever sees a shortlist rather than the full catalog (the two-stage design from the first section, motivated here by cost rather than accuracy); and split a large catalog into scoped sub-catalogs (per-project skills vs. org-wide skills, loaded separately) so no single request pays for relevance-checking against skills that could never apply to that project anyway.
Static per-project catalogs vs. a shared registry
A per-project catalog -- skills committed alongside the code they apply to, like .claude/skills/ in a repository -- has the router only ever consider skills relevant to that codebase, which keeps the catalog small and the negative-scope problem manageable by construction. The cost is duplication: a genuinely general skill like code-review gets copied into every project's catalog, and the versioning problem compounds by the number of copies.
A shared, org-wide registry inverts the trade: one copy of each skill, one place to update it, but now every project's router is checking against every team's skills, and the negative-scope discipline from the second section stops being optional -- it's the only thing keeping an org-wide catalog from degrading into constant false matches as it grows. The two aren't mutually exclusive in practice: a shared registry for genuinely general-purpose skills (code review, commit messages, PR descriptions) plus a small per-project catalog for anything specific to that codebase's own conventions is the combination that scales past a few dozen skills without either duplication or catalog bloat becoming the dominant failure mode.
The matching algorithm is replaceable -- keyword, embedding, or LLM-router all work at small catalog sizes -- but a skill's description quality is not: specific triggers plus an explicit negative-scope clause are what keep retrieval accurate as a catalog grows, and no amount of matcher sophistication substitutes for that discipline.