Skip to content

8 min read

Cross-referencing two skills builds a routing table nobody can read

On this page

On 3 March, release 2.41 removed the legacy_id field from the response of GET /v2/accounts. Four mobile clients on versions older than 6.2 read that field on their login path and crashed on the null. The field had been marked deprecated in a comment nine months earlier, and the team's deprecation policy — two releases of overlap, a Sunset header telling clients the date after which the response will change, a note in the changelog — was written down, current, and sitting in a skill called api-compat.

The post-mortem asked why the agent that reviewed PR #1180 had not flagged it. The transcript shows the review ran, took nine minutes, produced eleven comments on naming and test coverage, and loaded exactly one skill: pr-review. In the six weeks before the incident, api-compat had fired three times, all on requests where somebody typed the word "deprecate".

Both descriptions are competently written.

yaml
# .claude/skills/pr-review/SKILL.md
name: pr-review
description: >-
  Use when reviewing a pull request: checks style, tests and API
  compatibility.

# .claude/skills/api-compat/SKILL.md
name: api-compat
description: >-
  Use when changing a public API: verifies backward compatibility and
  deprecation policy.

The trigger for one of them is not visible when selection happens

Selection is a decision made against the request text and the list of descriptions, before any tool has run. "Review PR 1180" is a complete match for pr-review and contains nothing that matches api-compat, because the fact that the diff changes a public endpoint is a property of a file the agent has not read yet. By the time that property is observable, the selection has already been made and the review is underway.

Sequence: User → Model (review PR 1180); Model → Skill list (resting) (match request against descriptions); Skill list (resting) → Model (pr-review (strong), api-compat (no signal)); Model → Model (load pr-review body); Model → Diff (gh pr diff 1180); Diff → Model (removes legacy_id from /v2/accounts); Model → User (11 comments, none about compatibility).

That ordering is the structural fault, and it explains why rewording does not help. You can push endpoint vocabulary into api-compat's description — "removing or renaming a response field, changing a status code, altering a request schema" — and it will still not match the string "review PR 1180", because the request contains none of those things and the user does not know which of them the diff does. You can also strip "API compatibility" out of pr-review's description, which is the more tempting move since it looks like the overlap. That makes pr-review narrower without making api-compat reachable, so the review now covers style and tests, compatibility is covered by nobody, and the observable outcome on PR #1180 is identical.

The overlap here is genuine in a way the merged frontend skill's was not. There, the boundary had been drawn around a team rather than around the traffic, and redrawing it fixed the selection. Here both skills are correctly scoped for the requests they were written for, and a single request needs both bodies.

The fix that looks free

The line everybody writes first goes at the top of pr-review's body: "If the diff adds, removes or changes a public HTTP endpoint, also read .claude/skills/api-compat/SKILL.md before commenting." It takes one line, ships in a minute, and it does raise the number.

The team instrumented it and measured 22 PRs over the following month whose diffs touched a file under api/. api-compat was read on nine of them. Nothing in the transcript distinguishes the thirteen misses from each other: the agent might have read the diff, judged the change internal, and skipped the pointer correctly on some of them, or it might have finished the review without ever revisiting the instruction. There is no record of a decision, because declining to follow a pointer is not an event. When the outcome is a review with no compatibility comments, that is consistent with the rules having been applied and found satisfied, which is exactly what the reviewer of PR #1180 assumed.

The scaling behaviour is the part that turns a shortcut into a structure. Every overlapping pair earns a pointer, and pointers are directional, so a library of six skills with four real overlaps produced eleven cross-reference lines in a quarter. Two of them had gone inconsistent: pr-review said to read api-compat whenever the diff touched api/, while api-compat's body said to defer to pr-review for changes confined to internal handlers, and api/internal/ satisfied both conditions. Nobody wrote that contradiction on purpose. Each line was added by a different person solving a different miss, and the union is a routing table distributed across six bodies, loaded in fragments, with no place to read it whole and no test that exercises it.

The descriptions are still there, still doing selection, and now describing a different routing than the bodies do. That divergence is silent, because the descriptions are evaluated by the model at rest and the bodies are evaluated only after a load.

One variant of the shortcut deserves its own refusal, because it looks like a design rather than a patch. The proposal is a routing skill: a small skills-index whose description matches broadly and whose body is the table of which skill applies when, on the theory that centralising the pointers fixes the distribution problem. It does centralise them, and it inherits every other defect. The index has to win selection against the specific skills it is meant to route to, which is the breadth problem from the previous chapter with the stakes raised, and once it wins, its instructions to load two other skills are pointers with the same unverifiable compliance. The eleven scattered lines become eleven lines in one file that is read on some fraction of requests, and the number of loads you cannot account for stays roughly where it was.

Two designs, and what each one costs

The first design merges. pr-review becomes the single entry point for reviewing a diff, and the compatibility rules move into references/compat.md inside it, read conditionally after the diff is in hand — the deferral pattern from topic 12.3, with the difference that the conditional read is inside one skill and therefore inside one reviewable procedure. api-compat survives as a separate skill for design-time requests ("we want to rename account_id to id in v3"), and both skills read the same references/compat.md, so there is one copy of the policy with two consumers.

The second design keeps two skills and stops relying on a pointer by making the trigger observable at the only moment the agent can act on it. pr-review's procedure gains a bundled script as its first step:

bash
#!/usr/bin/env bash
# .claude/skills/pr-review/bin/api-surface
# Prints the public API surface changes in a diff, or NONE.
set -euo pipefail
diff=$(gh pr diff "$1")
printf '%s' "$diff" \
  | git apply --numstat - 2>/dev/null \
  | awk '$3 ~ /^(api|openapi)\// {print $3}' \
  | while read -r f; do
      printf '%s' "$diff" | grep -E "^-.*(operationId|\"[a-z_]+\":|@Route)" \
        | sed "s|^|${f}: removed |"
    done | sort -u | grep . || echo NONE

The body then says: run bin/api-surface <pr> first; if the output is not NONE, read references/compat.md and check every listed removal against the deprecation policy. The conditional is now a comparison against a string the agent has printed, rather than a judgement about whether a diff counts as "public". Compliance measured on the same 22 PRs was 19, and the three misses were all cases where the script printed NONE incorrectly for a change under api/v2/handlers/, which is a bug with a stack trace rather than a probabilistic shortfall.

Cross-reference lineMerge into one skillBundled detector, two skills
Loaded on a compat-relevant PR9 / 2222 / 2219 / 22
Extra tokens on an ordinary PR review0~40 (the conditional)~120 (script call + output)
Extra tokens when compat applies~1,400~1,400~1,400
What a miss looks likesilencea read that did not happenscript printed NONE
Can you debug a missnopartlyyes, it is a script
Ownershiptwo teams, one line eachone owner for reviewshared script, shared policy file
Cost to build1 minute~2 hours of restructuring~1 day including the false-negative work

What ships, and what would take it back

The merge ships, with the detector script as the first step inside it, because the two designs are not really alternatives — the merge decides where the rules live and the script decides what triggers them. Taken together, one selection happens on "review PR 1180", one procedure runs, and its branch depends on a printed string rather than on a pointer between two skills.

The failure mode this buys is worth naming plainly. When the merged design fails, it fails by loading references/compat.md on a PR that did not need it and spending 1,400 tokens for nothing, which shows up in a transcript and costs money. When the cross-referenced design failed, it failed by shipping a breaking change to four mobile clients and looking exactly like success. Choosing a design whose failures are visible is worth a fair amount of token overhead.

Three things would reverse it within six months. If the request log shows that design-time compatibility questions — asked before any PR exists — grow past roughly a fifth of compat-relevant traffic, the standalone api-compat skill has its own constituency and its own vocabulary, and the shared reference file starts being pulled in two directions by two procedures. If references/compat.md grows past about 200 lines, the conditional read stops being cheap and the rules want a gate rather than a document. And if the platform gains a way to run a check before selection happens, so that the diff's properties are in context when descriptions are being matched, the whole ordering problem in the sequence diagram disappears and two narrow skills become strictly better than one wide one.

None of the three is a preference. Each is a threshold somebody can check, which is the point of writing them down at the time of the decision rather than reconstructing a rationale later.

It is worth being clear about how narrow this diagnosis is, because "merge them" is bad advice for most pairs of skills that occasionally both apply. Two skills overlap structurally only when one of them has a trigger that cannot be evaluated from the request — a property of a diff, a file, a query plan, a log — and the other reliably wins the request that carries it. A pair where both triggers are visible in the request text is the vocabulary problem from the second chapter, and it is fixed by naming situations and exclusions in the two descriptions; a pair where the boundary was drawn around an owner rather than around the traffic is the third chapter's problem, fixed by moving the boundary. Merging either of those pairs trades a fixable routing bug for a permanent breadth problem. The test that separates them is whether you can write a request, in words a user would actually type, that selects the missed skill; for api-compat on a PR review, no such request exists, and that absence is what licenses the merge.

Where this stops being about routing

The merged pr-review now has a body carrying its own procedure, a conditional branch, a script and a reference file, and it is 180 lines. Some of those lines are steps the agent follows. Some are a paragraph explaining why the deprecation policy is two releases rather than one, which arrived when the two bodies were pasted together and which nobody has read since. The next chapter is about telling those apart, and about the second sort that follows — what stays in the body against what moves into a file like references/compat.md in the first place.

Writing a skill — read the current version at https://daviddominguez.dev/learn/en/writing-a-skill/cross-referencing-two-skills-builds-a-routing-table-nobody-can-read

Back to top