Skill Description Tuning skill for Claude Code

Make skills fire on the right prompts, and only those. Treats a skill description as a routing classifier and tests it like one. Claude builds a labeled set of realistic prompts and near-miss negatives, runs each in a fresh headless session to see whether the skill actually fires, diagnoses the miss pattern, and rewrites one variable at a time until recall is high, false positives are zero, and sibling skills still win their own requests.

  • nameskill-description-tuning
  • size175 lines · ≈2,050 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Diagnoses Agent Skills that never trigger, trigger too often, or lose to a sibling skill, then rewrites the description against a labeled set of should-trigger and should-not-trigger prompts run in fresh headless sessions. Use when a skill does not load when expected, loads on unrelated requests, the wrong skill fires, or before publishing a skill whose description has never been tested.

Try asking

  • My deploy skill never loads unless I type /deploy — figure out why
  • The testing skill keeps firing on every code question, tighten it
  • Write trigger tests for this skill's description before I publish it

Use it when

  • A skill works when invoked by name but not when the task is described
  • A skill loads on requests it has nothing to do with
  • Two skills compete and the wrong one wins
  • You want evidence a description works before sharing the skill

Skip it when

  • You are creating a skill from nothing — start with skill-authoring
  • The skill loads correctly but its instructions are weak — edit the body instead

How to check the result

Rerun the full labeled prompt set in fresh sessions: positives load the skill, near-miss negatives do not, and held-out prompts behave the same way.

The common mistake it prevents

Testing in the same session where the skill was written, so its body is already in context and every prompt appears to trigger it.

~/.claude/skills/skill-description-tuning/SKILL.md

--- name: skill-description-tuning description: Diagnoses Agent Skills that never trigger, trigger too often, or lose to a sibling skill, then rewrites the description against a labeled… license: MIT ---

A description is tuned when a labeled prompt set passes in fresh sessions: at least 9 of 10 should-trigger prompts load the skill, zero near-miss prompts do, and no sibling skill regressed. Judging by “it worked when I tried it once” is not tuning.

How routing works (what you are optimizing)

  • Before any skill loads, Claude sees only each skill’s name and description in a listing. The body, references, and scripts are invisible at decision time.
  • Claude picks a skill when the request plausibly matches the description’s stated triggers. Keywords that appear only in the body do nothing for routing.
  • Manual /skill-name invocation bypasses the description entirely. A skill that works when typed but not when described is a description problem.
  • The total skill listing has a size budget. With many installed skills, long descriptions cost everyone; check /context to see the listing’s weight.

Workflow

- [ ] 1. Confirm the skill registers at all
- [ ] 2. Build a labeled prompt set (positives + near-miss negatives)
- [ ] 3. Run a baseline and record the confusion matrix
- [ ] 4. Diagnose the failure pattern
- [ ] 5. Rewrite the description (one hypothesis per revision)
- [ ] 6. Re-run; check siblings; stop at the pass bar or after 3 rounds

1. Confirm registration first

Run /skills. If the skill is missing, no description change will help:

SymptomCauseFix
Not listedYAML error (often an unquoted : inside the description)Quote the description or remove the colon
Not listedname differs from folder, or uppercase/underscores in nameRename to match the folder, lowercase-hyphen
Not listedNew top-level skills dir created mid-session/reload-skills or restart
Listed, never auto-loadsdisable-model-invocation: trueRemove it if model invocation is wanted
Listed, loads only in some dirspaths restricts itWiden or remove paths
A different skill with the same name loadsPrecedence: enterprise > personal > projectRename one of them

2. Build the prompt set

Write it to a file so it can be rerun. 8-12 positives, 6-10 negatives:

[
  {"prompt": "the orders query takes 4 seconds, can you speed it up", "expect": true},
  {"prompt": "EXPLAIN shows a seq scan on events, what index do I need", "expect": true},
  {"prompt": "review the indexes in migrations/0042_add_orders.sql", "expect": true},
  {"prompt": "which of our postgres indexes are unused", "expect": true},
  {"prompt": "design the schema for a new invoices table", "expect": false},
  {"prompt": "the API is slow because it runs a query per row", "expect": false},
  {"prompt": "set up a redis cache in front of the product endpoint", "expect": false}
]

Positives must vary along these axes, or the test only proves the obvious case:

  • Explicit (“use the indexing skill”) vs. implicit (describes the problem only).
  • Casual, terse, and detailed phrasing; typos and lowercase.
  • Pasted artifacts: an error string, a file path, a config snippet.
  • The task embedded mid-request (“after the migration, also check the indexes”).

Negatives must be near misses: same domain words, different job (usually a sibling skill’s territory). “Write a poem” as a negative proves nothing.

Never put the skill’s name in more than one positive. Users rarely type it.

3. Run the baseline

Automated (requires the claude CLI on PATH and the skill installed where the run’s working directory will see it):

python3 ${CLAUDE_SKILL_DIR}/scripts/trigger_eval.py cases.json --skill postgres-indexing --runs 3

The script runs each prompt in a fresh claude -p session with stream-json output, detects whether the target skill (or a different one) was invoked in the first turns, prints a per-prompt table plus recall, false-positive count, and sibling hits, and exits non-zero on any mismatch. Edit, Write, and Bash are disallowed during the run so test prompts cannot change files; run it from a scratch checkout anyway. Each prompt costs a short session, so keep sets small and use --runs 3 only on the cases that flip.

Manual fallback: open a new session per prompt (/clear is not enough if the skill was already loaded earlier in the same session), send the prompt, and watch for the skill being invoked before any other work.

4. Diagnose

PatternLikely causeFix
Low recall, implicit prompts missDescription names the solution (“applies indexing methodology”), not the problem users stateAdd symptom phrases: “slow query”, “seq scan”, “takes N seconds”
Low recall, artifact prompts missNo file types, tool names, or error stringsName them: EXPLAIN, .sql migrations, pg_stat_user_indexes
Loads after Claude already startedTrigger phrasing is weak or buried at the endLead with the task verb; add “Use before writing X” when timing matters
False positives on generic requestsGeneric nouns (“code”, “files”, “performance”) carry the descriptionReplace with domain-specific nouns; drop “any”/“all”
A sibling winsOverlapping keywords, no boundaryAdd “Not for <their job>; use <sibling>.” to both descriptions
Flips run to runDescription is borderline for that phrasingStrengthen one concrete trigger; do not add five vague ones

5. Rewrite

Formula:

<What it does: verb + concrete nouns + tools/file types>. <Scope, output, or method>.
Use when <user phrases>, <symptoms/errors>, <file types or situations>. [Not for <X>; use <sibling>.]

Before (never triggers on “this query is slow”):

Provides database optimization expertise and best practices for improving performance.

After:

Designs and audits PostgreSQL indexes from EXPLAIN plans: B-tree, partial, covering,
GIN, and expression indexes, and finds unused ones safely. Use when a query is slow,
EXPLAIN shows a Seq Scan on a large table, a migration adds indexes, or the user asks
which indexes to add or drop. Not for N+1 query patterns in application code.

Rules:

  • Change one thing per revision (add symptoms, or add a boundary, not both), so the rerun tells you what worked.
  • Harvest vocabulary from real prompts in the user’s history, issue titles, and error messages, not from a thesaurus.
  • Stay within 1024 characters; 300-600 is usually enough. Every added word taxes the shared listing budget.
  • Keep it plain text, third person, no markdown or XML.
  • Claude Code also reads a when_to_use field. It is useful for local skills but is rejected by claude.ai uploads, so portable skills keep triggers in description.

6. Re-run and stop

  • Rerun the full set, not only the failures. Fixes for recall often create false positives.
  • If a boundary sentence was added, rerun the sibling skill’s set too.
  • Stop when the pass bar is met. After three rounds without convergence, the skill’s scope is probably wrong: split it, or merge it with the sibling it keeps colliding with.
  • Hold out 2-3 prompts you never tune against and check them last to catch overfitting.

Gotchas

  • Testing inside the session where you wrote the skill: the body is already in context, so everything looks like it triggers.
  • Tuning against prompts that all contain the skill’s own keywords. Real users describe symptoms.
  • Treating a single run as truth. Routing is probabilistic; borderline cases need 3 runs.
  • Fixing over-triggering by making the description vague. Narrow it with specific nouns and a “Not for” clause instead.
  • Editing the body to fix routing. Only the description participates in routing.

Output

**Skill:** postgres-indexing
**Prompt set:** 10 positive, 8 negative (2 held out)
| Round | Recall | False positives | Sibling hits | Change |
|---|---|---|---|---|
| 0 | 5/10 | 1/8 | n-plus-one-queries x1 | baseline |
| 1 | 8/10 | 1/8 | 0 | added symptom phrases + EXPLAIN/seq scan |
| 2 | 10/10 | 0/8 | 0 | added "Not for N+1 queries" boundary |
**Held-out:** 2/2 correct
**Final description:** (paste)

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/trigger_eval.py 138 lines · 5.3 KB
#!/usr/bin/env python3
"""Measure whether a skill triggers on a labeled prompt set, using headless Claude Code.

Usage:
    python3 trigger_eval.py cases.json --skill NAME [--runs N] [--max-turns N]
                            [--timeout SECONDS] [--model MODEL] [--allow-side-effects]

cases.json is a list of {"prompt": str, "expect": bool}.

Each prompt runs in a fresh `claude -p` session with `--output-format stream-json`.
A case counts as triggered when the stream contains a Skill tool call naming the target
skill, or a Read of .../<skill>/SKILL.md. Other skills invoked are reported as sibling
hits. With --runs N a case is "triggered" when the majority of runs triggered.

By default Edit, Write, NotebookEdit and Bash are disallowed so test prompts cannot
change files. Exit code 0 when every case matches its expectation, 1 otherwise.
Standard library only.
"""
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys


def scan(obj, skill: str, hits: set[str]) -> bool:
    """Walk a stream-json event looking for tool_use blocks."""
    found = False
    if isinstance(obj, dict):
        if obj.get("type") == "tool_use":
            name = obj.get("name", "")
            inp = obj.get("input", {}) or {}
            blob = json.dumps(inp)
            if name == "Skill":
                invoked = str(inp.get("skill") or inp.get("command") or inp.get("name") or blob)
                invoked = invoked.split(":")[-1].strip("/ ")
                if invoked == skill or f'"{skill}"' in blob:
                    found = True
                else:
                    hits.add(invoked)
            elif name == "Read" and f"/{skill}/SKILL.md" in blob:
                found = True
        for v in obj.values():
            found = scan(v, skill, hits) or found
    elif isinstance(obj, list):
        for v in obj:
            found = scan(v, skill, hits) or found
    return found


def run_once(prompt: str, args) -> tuple[bool, set[str], str]:
    cmd = ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose",
           "--max-turns", str(args.max_turns)]
    if args.model:
        cmd += ["--model", args.model]
    if not args.allow_side_effects:
        cmd += ["--disallowedTools", "Edit", "Write", "NotebookEdit", "Bash"]
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=args.timeout)
    except subprocess.TimeoutExpired:
        return False, set(), "timeout"
    hits: set[str] = set()
    triggered = False
    for line in proc.stdout.splitlines():
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            event = json.loads(line)
        except json.JSONDecodeError:
            continue
        triggered = scan(event, args.skill, hits) or triggered
    note = "" if proc.stdout.strip() else (proc.stderr.strip().splitlines() or ["no output"])[-1][:80]
    return triggered, hits, note


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cases")
    ap.add_argument("--skill", required=True)
    ap.add_argument("--runs", type=int, default=1)
    ap.add_argument("--max-turns", type=int, default=2)
    ap.add_argument("--timeout", type=int, default=180)
    ap.add_argument("--model")
    ap.add_argument("--allow-side-effects", action="store_true")
    args = ap.parse_args()

    if not shutil.which("claude"):
        print("ERROR: `claude` CLI not found on PATH")
        return 2
    with open(args.cases, encoding="utf-8") as f:
        cases = json.load(f)
    if not isinstance(cases, list) or not all("prompt" in c and "expect" in c for c in cases):
        print("ERROR: cases file must be a list of {\"prompt\": ..., \"expect\": true|false}")
        return 2

    tp = fn = fp = tn = 0
    sibling_total: dict[str, int] = {}
    print(f"{'result':8} {'expect':6} {'rate':5}  prompt")
    for case in cases:
        prompt, expect = case["prompt"], bool(case["expect"])
        triggered_runs = 0
        hits_all: set[str] = set()
        notes = []
        for _ in range(args.runs):
            trig, hits, note = run_once(prompt, args)
            triggered_runs += trig
            hits_all |= hits
            if note:
                notes.append(note)
        triggered = triggered_runs * 2 > args.runs
        ok = triggered == expect
        if expect and triggered: tp += 1
        elif expect: fn += 1
        elif triggered: fp += 1
        else: tn += 1
        for h in hits_all:
            sibling_total[h] = sibling_total.get(h, 0) + 1
        extra = f"  [other: {', '.join(sorted(hits_all))}]" if hits_all else ""
        extra += f"  [{notes[0]}]" if notes else ""
        label = "ok" if ok else "MISMATCH"
        print(f"{label:8} {str(expect):6} {triggered_runs}/{args.runs:<3}  {prompt[:70]}{extra}")

    pos, neg = tp + fn, fp + tn
    print()
    print(f"Recall:          {tp}/{pos}" + (f" ({tp / pos:.0%})" if pos else ""))
    print(f"False positives: {fp}/{neg}")
    if sibling_total:
        print("Sibling hits:    " + ", ".join(f"{k} x{v}" for k, v in sorted(sibling_total.items())))
    passed = fn == 0 and fp == 0
    print(f"Result: {'PASS' if passed else 'FAIL'}")
    return 0 if passed else 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc