The result first
| Before (long descriptions) | After (≤250 chars each) | |
|---|---|---|
| Behavioral tests passed | 88/96 (92%) | 90/96 (94%) |
| Total chars across all skill descriptions | 44,775 (114 skills) | 34,375 (116 skills) |
Two more tools, 10,400 fewer characters. And the trigger rate didn't drop.
(±2 cases at this sample size is noise, so I am not claiming it got better. What I can claim: cut 40% of the characters, no systematic decline.)
If you already have dozens of AI tools installed, the first half of this is worth reading — because some of yours may not be loaded at all right now, and nothing will tell you.
The setup: a failure with no error message
Claude Code's skill mechanism works like this. Each skill is a folder with a SKILL.md, and the file opens with frontmatter:
---
name: atomic-db-operations
description: Use when writing a DB RPC that touches stock/balance/order state...
---
At session start, every skill's name + description gets packed into the model's system prompt. That listing is the only way it knows what tools it has. It can't see your folders. It can't read the files. It sees the list.
And that listing has a character budget.
When you exceed it, something very specific happens: the description is dropped and the name is kept.
So that skill:
- is still on the list, and
/skill-namestill invokes it manually ✅ - but it will never surface on its own again ❌ — the model sees a name and has no idea what it does or when to reach for it
No error. No warning. It just quietly stops showing up.
I'd written before about a related blind spot — tools that never got loaded at all because of the load path. This is the second layer of the same problem: the thing loaded, but its self-introduction was thrown away in transit.
How big is the budget?
I decompiled the local binary to check, because the community blog posts saying otherwise were second-hand and wrong. The actual rule:
- Budget = 1% of the context window (a setting called
skillListingBudgetFraction, default0.01) - A 200K-token window ≈ 8,000 characters; a 1M window ≈ 40,000
- There's also a per-skill cap (
skillListingMaxDescChars, default 1536) - The env var
SLASH_COMMAND_TOOL_CHAR_BUDGEToverrides the whole calculation
What I measured at the time: 89 project skills ≈ 22,700 chars + 23 global skills ≈ 11,700 = about 34,400 characters, against a default budget of 8,000.
The way to see it is blunt: in my available-skills listing, roughly 70 entries were name-only.
Why this gets worse on its own
The docs spell out the eviction order: "starting with your least-used skills."
That reads as reasonable — keep the popular ones. It's actually a death spiral:
never used → description evicted → model can't see it → used even less → evicted first again
I'd separately measured my toolbox's usage rate: 79 of 89 had never been explicitly invoked. I wrote that up as its own post at the time. Looking back, that number was contaminated by two things at once:
- genuinely not useful
- budget overflow, so the model was never told it existed
Until you fix the second, the first number can't justify retiring anything. That's the mistake I nearly made — I was about to delete a batch of skills based on the "zero usage" list.
The decision: trim, don't cut
First I raised the budget (set that env var to 45000, in two config files), and verified end to end: opened a headless sub-session and forced it to quote verbatim the description of a skill that had been name-only. It quoted it, and the words matched the file.
Two weeks later the budget was full again (skills kept accumulating). This time 8 were stripped to names.
So the question became: cut tools, or trim descriptions?
I did the arithmetic. Squeezing every description under 250 characters saves 8,058 characters. Deleting all 51 "tier C" skills (the least-used batch) saves 11,295.
Trimming recovers nearly as much as deleting half the toolbox — and retires nothing.
That's why I trimmed. Not aesthetics. Arithmetic.
The hard part: how do I know I didn't cut a trigger word
This is the real technical risk.
That description is the basis on which the model decides whether to invoke the skill. Cut a trigger word and it silently stops firing in that situation — and you won't know. No error. It just never appears.
My first approach was "delete the redundant-feeling parts." The problem with that approach: I had no way to prove to myself that I hadn't cut something load-bearing.
So I changed to three steps.
Step 1: only trim three categories, touch nothing else
-
The same sentence written twice in two languages (e.g. a Chinese phrase followed by
adding/renaming a field across write→middleware→render) -
e.g. enumerations (
handleUpload + handleBatchUpload + handleDragDrop + handleUrlInput→ keep three) - Restatements of the SKILL.md body (the description doesn't need to re-explain what the five steps are; those words are in the file)
Trigger words — identifiers, symptom phrases, SKIP routing to other skills — untouched.
Step 2: mechanical reconciliation, not self-report
After trimming, run a script: split the old and new versions each into two sets — "ASCII identifiers" and "natural-language fragments" — then print every token present in the old and absent in the new.
The point of this step isn't automation. It's turning "did I cut a trigger word?" from an internal state only I can see into a list lying on the table. I can't reassure myself past a list.
Result: all 24 had token loss. Reading through, most were function words (the, and, that, use, when) and things I'd judged as restatement — but 8 had genuinely lost trigger words:
-
label map,trigger(two of four load-bearing positions in one rule) -
isInQuietHours,deferred:true(code-level signals — anyone reading or writing these symbols should trigger this skill) -
line-auth(the name of an Edge Function) - a natural phrasing for "change the unit price" — I'd kept only the identifier
single_price - a full sentence a client actually says: "I uploaded the images, please place them for me"
- two SKIP routes pointing at other skills
All restored. Without that list I would not have caught a single one of the eight.
Step 3: two stale claims fell out for free
Reconciliation forces you to read the old version word by word, so I picked up two errors unrelated to trimming:
- one description said "three-part report" while its own SKILL.md and my rules file both say four-part (the "three" referred to three markers inside part 3, misread as the whole report's structure)
- another described the precedence of a fallback mechanism that had been removed in a database migration months earlier
Reading word by word catches these. Skimming doesn't.
Then I needed behavioral evidence
At this point I had two pieces of evidence:
- every removed token reviewed line by line (mechanical, but indirect — it proves the words are still there, not that the skill still fires)
- after a system-prompt reload, all 24 new descriptions appeared verbatim (proves delivery, not trigger rate)
Neither is behavioral evidence. And I happened to have a question bank: 4 test cases per skill (should-trigger, strict variant, should-not-trigger, boundary), 96 total — run against a cheap model, three votes per case, majority wins.
(An aside: my handoff doc claimed "most of these 24 have no test cases." That sentence was wrong; all 24 had them. How I caught that wrong sentence is a story of its own, and I wrote it up separately: my test report printed "0/96, 0% pass rate" — the truth was my account was out of credit.)
Why "94% after" isn't evidence
The run came back 90/96 (94%). Looks fine.
But it can't answer the question I'm asking. The question is "did it drop?", and 94% is an absolute number — I have no "before" to compare it to.
So I opened a working copy of the old version (git worktree pointed at the pre-trim commit) and ran the same question bank, the same model, the same day, the same machine. The only difference was the length of those 24 descriptions.
288 API calls per arm, about US$0.32 each.
A/B result
| Passed / total | Before | After |
|---|---|---|
| Total | 88/96 (92%) | 90/96 (94%) |
Only 4 skills moved at all, each by exactly one case:
| skill | Before | After | Char change |
|---|---|---|---|
blog-content-block-contract |
3/4 | 4/4 ↑ | 460 → 239 |
breakpoint-taxonomy |
2/4 | 3/4 ↑ | 480 → 246 |
cross-layer-drift-rootcause |
3/4 | 4/4 ↑ | 286 → 237 |
data-contract-propagation-audit |
4/4 | 3/4 ↓ | 689 → 247 |
The other 20 did not move.
The honest reading
I will not say "trimming improved triggering." Net +2 cases, 96 cases total, one round per arm — that's inside the noise. Three up and one down looks like jitter, not a trend.
Here is what I can claim: cut the total characters across those 24 descriptions by 33% (8,407 → 5,653), and behavioral tests showed no systematic decline.
The one to watch is the one that fell — data-contract-propagation-audit, which also happens to be the one I squeezed hardest (689 → 247, −64%). It lost one case. If it's still down next round, that's genuinely over-trimmed and it should get more of the character budget back.
There's a practical lesson buried here: compression ratio correlates with risk. Everything I cut by 30% held steady. The only one that moved was the one I cut by 64%.
The global batch: a trap that almost wasted the whole exercise
After the 24 project skills, I still had 17 "global" skills (shared across all projects). That batch had an extra problem: some of them have their source of truth in a different repo.
Edit the wrong copy and the next sync overwrites it — the entire effort gone.
So I checked where each one's source lived. My first method was "find the folder whose name matches the skill name."
That method was wrong. One skill's source file sits in a folder that is not named after it. I came very close to editing a copy that gets overwritten on the next install.
The correct key is the name: field written inside the file, not the folder name. Re-running the classification with that key sorted it out.
And I made a second mistake: when I built the work list, I copied the 9 skills listed in my own previous report — and missed one. It had always been in that group; my earlier report just hadn't listed them all.
What caught it was re-measuring the over-budget list after applying the changes — it was still on there.
Working from your own last report ≠ working from a fresh measurement.
Neither mistake was technical. Both were "I used a proxy that looked good enough."
Net result
| Before | Now | |
|---|---|---|
| Total chars, all skill descriptions | 44,775 (114) | 34,375 (116) |
| Fixed-cost overage (I have a ratchet watching this) | +8,853 | +2,302 |
| 24 project skills | 8,407 | 5,653 |
| 17 global skills | 7,905 | 3,138 |
| Behavioral tests | 88/96 | 90/96 |
Not one tool was retired.
Five things you can take away
1. First check whether your listing overflows. Sum the description lengths across all your SKILL.md files and compare to "context window × 1%." If you're over, some of your tools are name-only right now.
2. The fix for overflow is trimming, not deleting. I did the math: squeezing to 250 chars saves as much as deleting more than half the toolbox. And before you delete anything — that "zero usage" list may be contaminated by the overflow itself.
3. Classify before you cut, reconcile mechanically after. Only trim bilingual restatement, e.g. enumerations, and body-text repetition — then print every token that existed before and doesn't now, and judge them one at a time. That's how I recovered 8 wrongly-cut trigger words. Without the list I'd have caught zero.
4. An absolute number can't answer "did it drop." 94% is a nice number and it is not evidence. A working copy of the old version running the same bank is. The extra cost was one round of API spend (US$0.32 here) in exchange for a sentence you can actually stand on.
5. The harder you compress, the closer you watch. Everything I cut 30% held. The only regression was the one I cut 64%.
Postscript: use the right key to find the source of truth
If you also have "the same skill exists in several repos," find the source using the name: field in the frontmatter, not the folder name. I have a separate audit script that compares content hashes across copies — after this change I ran it and confirmed the drift set was identical to before, zero additions. That is evidence the copies are still in sync, as opposed to my feeling that they ought to be.
This whole exercise is one layer of what I've elsewhere called the harness — the shell you wrap around the model, where the part only you have lives. If you want the wider version of that argument, I wrote it up here: NVIDIA's CEO says future companies will be built on harness engineering.
Originally published on my blog: I cut 41 AI tools' self-descriptions in half, then A/B tested that trigger rate didn't drop
I keep a running index of every pothole I've hit building a real production system solo — symptom on the left, what to grep in your own repo on the right: coffeeshooters.com/potholes
And if your team is shipping AI-written code faster than anyone can read it, that's the thing I do for a living: coffeeshooters.com/code-audit
Top comments (6)
What would make the +2 readable is a repeat of the unchanged arm - same commit, same bank, same model, run twice, before comparing arms at all. With three votes per case and majority wins, any case sitting near the 2-1 boundary can flip on sampling alone, so right now there is a between-arm delta with no within-arm noise floor to measure it against, and "three up, one down" is exactly the shape that churn produces. At roughly $0.32 a round that baseline is cheap, and it is also what tells you whether
data-contract-propagation-auditlosing another case next round is the over-trim signal you are watching for or just the same jitter twice.You were right, and I ran it. Same commit, same bank, same model, the unchanged arm twice, before comparing anything — exactly as you specified.
The noise floor is 2 flipped cases out of 96. The total score across those two identical runs was 89 and 89: a delta of zero, with two real flips hiding underneath it in opposite directions. So the +2 I reported is sitting exactly on the floor, and the sentence in my post asserting it was noise happened to land on the right answer for no reason I had earned.
Two things came out of it that I didn't expect, and both of them came from your point about the 2-1 boundary.
First: the noise isn't spread out. All of it lives in the should-NOT-trigger cases — 2 flips out of 24 negatives, versus 0 out of 48 positives and 0 out of 24 boundary cases. That makes sense in hindsight (with 116 skills competing, "don't reach for this one" is a much weaker decision than "obviously this one"), but it means my question bank spends 75% of its budget on the categories carrying none of the variance. The fix for readability isn't more rounds. It's more negative cases.
Second, and this is the one I didn't want: the skill I flagged as "the one to watch" is genuinely broken. Its lost case is at 0/3 — unanimous failure — in both of today's runs, seventeen days later, and it's a positive-trigger case, which is the category where the noise produced zero flips all day. To confirm that signature I deliberately over-cut six other descriptions by the same 64% and watched three cases collapse from 3/3 to 0/3, all of them positive or boundary. Real over-trimming and churn do not look alike once you can see the votes: churn is a 1-of-3 wobbling in a negative case, damage is a unanimous collapse in a positive one.
So the honest revision to my post is two-sided. The aggregate "no systematic decline" was unearned, exactly as you said — and it was also concealing a real loss, which the same score hid in the other direction. Meanwhile one of the improvements I reported (breakpoint-taxonomy, 2/4 to 3/4) turns out to be the very case that flipped between today's two identical runs. It was churn.
The instrumentation is fixed as well: per-case vote counts now survive into the JSON, the human-readable run ends with a count of non-unanimous cases, and there's a small comparator that takes two runs and prints the floor. It refuses to read the old format rather than silently reporting zero — since the old format is precisely where the votes were being dropped.
Caveats I'd rather state than have you find: two runs give a point estimate of the floor, not a distribution. The over-cut control was a single arm, single round; its hard signals are strong but its total is subject to the same ±2. And the six skills I over-cut were chosen by a proxy — "the disciplined trim could only compress them under 20%" — which measures how tight the description was, not how narrow the skill's scope is, and scope was Kane's actual question.
Thanks for this. It cost about a dollar and turned two of the post's conclusions over.
The part I would not lock in yet is the discriminator itself. Your control over-cut descriptions and watched three positive and boundary cases collapse unanimously, so it exercised only one of the two ways a trim can do damage: losing the specificity that wins its own case. The other direction is a description trimmed into something generic enough to start matching cases it should decline, and that lands as a unanimous wrong answer in a negative case, which is the category your rule assigns to churn. The separation your own numbers support is unanimous versus 1-of-3, not negative versus positive, so keeping unanimity as the signal and category as context is what keeps that second mode visible once you rebalance the bank toward negatives.
The part I found most useful here is the distinction between “the skill exists” and “the model can actually discover it.” Silent description eviction is a nasty failure mode because everything looks healthy until you realize the model has effectively lost the routing information.
I also like the A/B approach. The 90/96 result alone doesn’t tell much, but comparing the exact same test set against the pre-trim version makes the conclusion defensible.
The 64% compression case is especially interesting too. It suggests there’s probably a practical threshold where description optimization starts removing useful semantic signals, not just redundant text.
This is a good example of treating AI tooling like an engineering system: measure it, change one variable, and test the behavior rather than assuming the configuration is working because there’s no error.
Your threshold question turned out to be the one worth spending money on, so I ran a control for it. Short version: there is a threshold, but it isn't on the ratio.
I took the six skills whose descriptions the original disciplined trim could only compress by under 20% — meaning they had almost no redundancy left — and force-cut each by ~64%, the same ratio as the one that lost a case. If the ratio is what matters, they should all degrade about the same way.
They degraded much worse. The six went from 23/24 to 19/24, and one of them collapsed from 4/4 to 1/4 — three separate cases going from unanimous pass to unanimous fail. Compare that to the original 64% cut, which cost exactly one case.
So the same ratio was survivable in one place and catastrophic in another, and the difference is what the 64% removed. The skill I could cut by 64% with discipline could be cut that far because 689 of its characters were mostly restatement of a six-layer rule. On a tight description, reaching 64% means cutting into signal — there is nothing else left to take.
Which means compression ratio isn't a risk driver. It's a measurement of how much redundancy was there in the first place, and I'd been reading it backwards. The two variables aren't merely confounded, they're mechanically linked: how far a disciplined trim can go IS the redundancy. Across my 24, the achievable ratio ran 3% to 64%, and only one skill exceeded 49%.
And a correction to the post while I'm here. I wrote "everything I cut by 30% held steady; the only one that moved was the one I cut by 64%." That's wrong on my own data — the four that moved were cut 64%, 49%, 48%, and 17%. The 17% one moved by exactly as much as the 64% one. I should have checked that sentence against the table sitting two paragraphs above it.
I am glad that my opinion was helpful.
I would like to get to know you better. Would you please contact me? t_g_@kanelim1997