Skip to content

A skills system for coding agents: on-demand instruction files at scale

A coding agent’s skill corpus is a set of on-demand instruction files. Each skill carries one description line that the model sees on every turn; the body loads only when the description matches the current task. This page covers how one harness keeps a large corpus useful at scale: what goes into a skill file, how the description doubles as the trigger surface, what a lint checks and why each check exists, how a subset of skills reaches a second harness, and how two extensions nudge the model into actually opening the matching file.

The worked example is the pi harness skill tree, the subject of the hub at The pi harness journey. Every mechanism, count and threshold was read from the harness source on 2026-10-20. The lint’s checks and config are read from the linter itself and its JSON config file; the extension behaviours are read from their header comments and source. The last section lists the properties that apply to any skill corpus, regardless of the harness.

TL;DR:

  • The description plus name are the parts the model sees every turn. The name is light (~30 characters per skill); the description is the expensive half. At about 500 characters per skill across 84 skills, the description set costs about 42,000 characters of context on every request - so the cap is a cost ceiling, not a style rule.
  • The description lists triggers (“Use when…”), not workflow. A description that summarises the skill’s process becomes a shortcut the agent follows instead of reading the body.
  • A lint runs on every skill at commit time: it checks the description length, the body size, smart punctuation, retired terms, stale freshness markers, dangling supporting files, dead paths, and references to skills that do not exist. The pre-commit hook blocks on errors.
  • 74 of the 84 skills reach a second harness through per-skill symlinks. The directory listing is the allowlist; no config file, no registry, no sync pipeline.
  • Two extensions bridge the gap between a passive description and the model actually opening the file: skill-guard blocks a tool call once with a nudge, and skill-first re-injects a context reminder across up to three turns until any skill file is read.
  • After a milestone ships, a stocktake pass reconciles the skill corpus with the systems it documents. Skills track the system, so the skill edit and the system change belong in the same commit.

one skill directory (84 in the corpus)stocktake aftereach milestoneSKILL.md body(<= 500 lines)update withthe systemskills-lint(pre-commit gate)checks everycommitdescription(trigger text)system prompt, every turn:all descriptionsloadedsibling files(reference)links toread on a match;skill-guard / skill-firstnudge if skippedsecond harness:74 per-skill symlinkspromoted(allowlist)

The same thing as text:

  1. Every skill is a directory under .pi/agent/skills/<name>/ containing a SKILL.md file and optional sibling files. Only the description is loaded into the system prompt on every turn; the body and siblings are read on demand.
  2. skill-guard.ts matches the user’s prompt against a static rule table and blocks the first tool call that falls into a skill’s domain with a nudge to read the file.
  3. skill-first.ts re-injects an anchored context reminder across up to three turns until the model reads any skill file.
  4. skills-lint.py runs on every commit that touches a skill; the pre-commit hook blocks on errors.
  5. 74 skills reach Claude Code through per-skill symlinks in .claude/skills/. The directory listing is the allowlist.
  6. After each milestone, the stocktake pass reconciles the skill corpus with the systems it documents.

PartWhen it is loadedContext costCap
nameEvery turn, every session~30 characters per skillMust equal the directory name
descriptionEvery turn, every sessionUp to 500 characters per skill (warrn), 1024 hard limitLists triggers, not workflow
Body (SKILL.md)When the description matches the current taskLoaded on demand500 lines (warn); reference over ~100 lines goes in a sibling file
Sibling filesWhen the body points at themLoaded on demand, one level deepNo per-file cap; linked by plain filename, never @-force-loaded
metadata.verifiedLint only, never in contextZero per-turn costWarrns past 90 days

The description is the load-bearing part. Across 84 skills, the description set costs about 42,000 characters of context on every request - roughly the equivalent of a 10,000-word document - before any skill body is loaded. The 500-character cap is a cost ceiling, not a style rule: a corpus that grows by one skill per month adds about 500 characters per month to every request forever.

The description and name are the parts of a skill the model sees on every turn. The description must answer one question: “should I read this file for the task I am about to do?”

The authoring rules, from the writing-skills skill1:

  • Start with “Use when…” (or “Use ONLY when the user explicitly asks” for skills that should not auto-fire). The lint warns on descriptions that do not start this way.
  • List concrete triggers: symptoms, error text, tool names, file names, user phrases. These are the search terms the model matches against the prompt.
  • No workflow summary. A description that says “covers the X workflow, providing Y and handling Z” is a shortcut the agent follows instead of reading the body. The observed failure: a description that summarised the review process as “code review between tasks” produced one review where the body specified two.
  • End with a “NOT for <sibling>” clause where confusion with another skill is possible. An agent that reaches for the wrong sibling wastes a turn reading the wrong file.
  • Third person, under 500 characters. The lint warns past 500 and errors past 1024 (pi refuses to load the skill).

A calibrated description from the writing-skills skill itself1:

Use when creating a new SKILL.md, making substantial structural edits to an existing one, or deciding whether a technique deserves a skill at all - frontmatter format, description-trigger calibration, body length, sibling reference files, pressure-testing a skill with subagents, running skills-lint. Do not auto-fire on minor description tweaks. NOT for the prose voice of a doc (erfi-voice) or the loop harness a skill might describe (self-correcting-loop).

It names the concrete actions (“creating a new SKILL.md”, “running skills-lint”), the anti-trigger (“do not auto-fire on minor description tweaks”), and the siblings it should not be confused with.

The body loads only when the skill fires, but then it competes with the conversation for context. Two rules:

  • Body under 500 lines. The lint warns past that threshold.
  • Reference over about 100 lines moves into a sibling file, linked from SKILL.md by plain filename with a “read when…” cue. The self-correcting-loop skill is the canonical case: its body is the overview, the manifest reference and a file table; the governor, models, sensors and lessons each live in their own file under docs/.

Sibling files are one level deep (SKILL.md -> sibling.md), never chained (SKILL.md -> sibling1.md -> sibling2.md). A chain forces the reader to follow links to find the rule.

Why no @-links. The @path/file.md syntax force-loads a file immediately, consuming context before the agent needs it. The lint flags every @-link as a warning, and the authoring skill says to use a plain filename with a read-when cue instead.

skills-lint.py runs on every skill in the corpus. The pre-commit hook blocks on errors; warnings print but do not block. The current run (2026-10-20): 84 skills, 0 errors, 1 warning, 10 waived.

CheckLevelWhy
Frontmatter parses and name equals directoryERRORA broken frontmatter means the harness cannot load the skill
Description over 1024 charactersERRORThe harness refuses to load a skill with a description over 1024 characters
Description over 500 charactersWARNEvery turn of every session pays for it; 500 is the cost ceiling
Description starts with “Use when” / “Use ONLY when”WARNA description that does not name its triggers is a passive label, not a trigger surface
Description has no workflow/coverage verbsWARN“covers the X workflow” is a summary the agent treats as a shortcut instead of reading the body
Body over 500 linesWARNA body that competes with the conversation for context; reference goes in a sibling file
No smart punctuation (em/en dash, curly quotes, ellipsis, nbsp)ERRORThese mis-decode when pasted into non-UTF-8 composers
No @-force-load linksWARN@-links burn context immediately; use plain filenames with read-when cues
Every supporting file referenced from SKILL.mdWARNAn unreferenced sibling is dead weight the agent never finds
Every ~/path exists on diskWARNA path that does not exist is a dead link the agent follows and gets nothing from
No retired terms (from .lint.json)ERRORA retired term in a skill is a stale fact an agent will act on; per-skill allow exceptions for historical mentions
name skill` references resolveERRORA reference to a skill that does not exist is a broken navigation link
Dated entries under max_dates (default 8)WARNA skill that accumulates dated anecdotes grows monotonically; fold lessons that became defaults
metadata.verified under verified_max_days (default 90)WARNA stale verified date signals a skill that has not been re-read since the system it documents changed
Claude Code symlinks not danglingERRORA promoted skill whose source was deleted leaves a dead symlink that breaks the second harness
docs source names are real docs-mirror topicsWARNA skill that names a docs source that does not exist sends the agent on a dead search

The retired-terms check is the one that catches the most drift. When a system is retired or renamed, its old name is added to .lint.json’s retired map with a hint naming the replacement:

{
"retired": {
"opencode": "harness retired 2026-08-15; say pi / Claude Code",
"Unraid": "servarr is NixOS + ZFS since 2026-08-26 (say 'servarr NAS'; Unraid only as dated history)",
"Vaultwarden": "no longer the canonical secret store; SOPS-encrypted .env in git is (2026-09-04)"
}
}

Any skill that contains a retired term and does not have an allow exception for it fails the lint with an error. The allow list exists for skills that mention a retired term as history: the writing-skills skill mentions “superpowers” (a retired skill set) in its anti-patterns section as a warning, so it gets an allow entry. The pre-commit hook blocks the commit until the term is gone or allowed.

The metadata.verified field is optional frontmatter that gives the lint a freshness signal:

metadata:
verified: 2026-09-30

A skill whose verified date is older than 90 days gets a warning. The date means “a human re-read this skill and confirmed it matches the system it documents.” Skills track the system, so a stale verified date is a signal that the skill may have drifted.

The pre-commit hook in .config/git/hooks/pre-commit runs skills-lint.py when a commit stages files under .pi/agent/skills/. It prints warnings but only blocks on errors. The exit-1 message names the file and the error count, and the hook suggests --no-verify as the bypass. This is the same hook that runs the contextual-confidentiality reminder on every commit to a repo with a remote.

A passive description is not enough. The miss rate is worst for skills that overlap the model’s trained behaviour - git, Docker, Terraform, Fly - where the built-in default out-competes the registered skill. Cross-harness evidence from the Claude Code issue tracker reports about a 50% miss rate on trained-overlap skills while novel-tool skills fire reliably2.

Two extensions bridge the gap. They are complements, not duplicates:

skill-guard.ts (action-time, blocking). On before_agent_start, it matches the user’s prompt against a static rule table and injects a non-blocking message pointing at the matching skill. On tool_call, when the model is about to write, edit or run a command in a domain covered by a skill, it blocks the call once with a nudge to read the skill file. The retry passes. Each rule fires at most 3 times per session; the exact same command never blocks twice.

skill-first.ts (intent-time, non-blocking). Skill-guard’s nudge fires once per turn. A model that ignores it never hears about the skill again. Skill-first re-injects a context reminder at the end of the context on every turn until the model actually reads a skill file - up to 3 injections per session. It uses pi’s anchor mechanism so the copy stays in the same position across requests, keeping the local engine’s single-checkpoint prompt cache stable. Once any skill file is read, the system disarms: the model has engaged with the skill tree, and further nagging is noise.

A discipline skill - one that constrains agent behaviour under pressure - gets a baseline test before it ships. The method, from the writing-skills skill1:

  1. Run the scenario with a subagent that does NOT have the skill. Record the exact rationalisations and failures.
  2. Write the skill against those specific failures.
  3. Re-run with the skill. Close the loopholes that appear.
  4. Build a rationalisation table from all test iterations and a red-flags list the agent can self-check against.

The common rationalisations for skipping the baseline:

ExcuseReality
“Skill is obviously clear”Clear to you is not clear to other agents. Test it.
“It’s just a reference”References can have gaps. A retrieval check is five minutes.
“I’ll test if problems emerge”Problems mean agents cannot use the skill. Test before deploying.
“Academic review is enough”Reading is not using. Test application scenarios.

A discipline rule you never watched an agent break is a guess about what agents do. The testing-skills-with-subagents.md sibling file in the writing-skills directory has the full methodology and worked iterations.

Skills live canonically in .pi/agent/skills/<name>/ (pi is the primary harness). A skill reaches Claude Code only through an explicit per-skill symlink in .claude/skills/. The directory listing is the allowlist: 74 of the 84 skills are promoted, plus a real lsp-servers/ plugin directory that is not a skill symlink.

The promotion rules, from the dotfiles AGENTS.md “Agent-surface routing” section3:

  • Methodology, infrastructure and operations skills are eligible when they are useful from the second harness.
  • Private-corpus tools (anything touching personal session data) are never promoted.
  • Media/GPU generation and personal operations stay pi-only by default.

There is no config file, no registry, and no sync pipeline. A new skill that should reach both harnesses gets one ln -s command plus a stow run. A skill that should stay pi-only gets no symlink. The mechanism is the directory listing, and ls is the query.

The lint checks that no symlink dangles: if a promoted skill’s source directory is deleted but the symlink remains, the lint fails with an error.

After a milestone ships - plan tasks checked off, a feature merged, a phase closed - the stocktake pass reconciles the skill corpus with the systems it documents. The rule, from the authoring discipline in the harness rules4:

Skills track the system they document. Any skill in the record set whose system changed gets its update in this pass, and skils-lint runs on it.

The algorithm, from the stocktake skill5:

  1. Define the record set by searching, not by memory: in-repo docs, cross-repo mentions, standing instructions, memory, the board, session findings, and local scratch.
  2. Inventory the facts: what the code actually is now, what shipped since the last stocktake, what is deployed.
  3. Diff and record every item: stale task status, missing command, deviation shipped without a note, wrong path or number.
  4. Fix the records, not the code. Drain every open item found outside the board onto it.
  5. Reconcile the board: every open card reviewed, stale ones moved back or commented, new ones created.
  6. Separate docs: commit before the next feature commit.

A skill whose system changed but whose text did not becomes stale. The stocktake catches this: the “Skills” section of the pass includes every skill in the record set, and the lint runs on any skill touched.


The skill counts, the lint thresholds, the retired-term list and the symlink counts are specific to this setup. These properties are not:

  1. The description is a cost ceiling. Every turn of every session pays for every description. The cap is a budget decision: how many characters of context you are willing to spend per skill, forever.
  2. Descriptions list triggers, not workflow. A description that summarises the process becomes a shortcut. The model reads the summary instead of the body, and the body’s rules never reach it.
  3. A lint at commit time catches drift before it ships. The checks that matter most: description length, body size, retired terms, dead paths, and references to skills that do not exist. A lint that only prints warnings without blocking is a lint nobody reads.
  4. Retired terms are enforced mechanically. When a system is retired or renamed, its old name goes into a blocklist. Any skill that still mentions it fails the lint until the text is updated or an explicit allow exception is added. A prose rule (“do not mention the old name”) is a rule the model forgets; a lint error is a rule the pre-commit hook enforces.
  5. Freshness markers prevent silent rot. A verified date on each skill, checked against a max age, surfaces skills that have not been re-read since the system they document changed. The date means “a human confirmed this matches reality,” not “the file was last edited.”
  6. A second harness gets a subset, not the whole tree. Per-skill symlinks as an allowlist means the promotion decision is explicit per skill, the mechanism is the directory listing, and there is no config file to drift.
  7. Nudges bridge the gap between passive description and active use. Two extensions - one that blocks a tool call once, one that re-injects a context reminder - are the minimum that turns “the model skips skills it should read” into “the model reads the skill.” A description alone is passive progressive disclosure, and passive disclosure has a measured miss rate.
  8. Skills track the system. When the system changes, the skill updates in the same commit. A stocktake pass after each milestone reconciles the corpus with reality. A stale skill is treated as a defect and fixed in that pass.

New technique or ruleDeserves a skill(not one-off,not project-specific)?Constrains agentbehaviour underpressure?yesPut in repo AGENTS.mdor automate mechanicallynoPressure-test withsubagents firstyesWrite SKILL.md:- description = triggers- body <= 500 lines- reference in siblingsnoUseful from thesecond harness?Add per-skill symlinkin second harnessyesLeave pi-only.No symlink.noRun skills-lint.Commit with the system change.

The same decision as text:

  1. If it is a one-off solution or a project-specific convention, put it in the repo’s AGENTS.md or automate it mechanically. Do not create a skill.
  2. If it is a discipline skill (constrains agent behaviour under pressure), pressure-test it with subagents before writing. Run the scenario without the skill, record the rationalisations, write the skill against them.
  3. Write the skill: the description lists triggers and ends with NOT-for siblings, the body stays under 500 lines, reference over about 100 lines goes in a sibling file.
  4. Run skills-lint. Commit the skill together with the system change it documents.
  5. If the skill is useful from the second harness, add a per-skill symlink. Otherwise, leave it pi-only.

ClaimHow it was checkedStatus
84 skills in the pi corpusls on .pi/agent/skills/, 2026-10-20measured
74 skills promoted to Claude Codels on .claude/skills/ minus the real lsp-servers directory, 2026-10-20; cross-referenced against the hub pagemeasured
Lint: 0 errors, 1 warning, 10 waived`python3 scripts/skills-lint.py 2>&1tail -5`, 2026-10-20
Description cap: 500 chars warn, 1024 errorskills-lint.py:83-84read from source
Body cap: 500 lines warnskills-lint.py:90read from source
Retired terms: 17 entries in .lint.json.lint.json retired map, counted 2026-10-20read from source
metadata.verified max age: 90 days.lint.json verified_max_days; skills-lint.py:43read from source
max_dates: 8.lint.json max_datesread from source
Pre-commit hook blocks on lint errors.config/git/hooks/pre-commit:52-58read from source
skill-guard blocks once per exact command+rule, up to 3 per ruleskill-guard.ts header + MAX_FIRES_PER_RULEread from source
skill-first re-injects up to 3 times per sessionskill-first.ts header + loadMaxFires()read from source
“50% miss rate on trained-overlap skills”anthopics/claude-code#30387, cited in skill-guard.ts headerread from source
Description as shortcut failure modewriting-skills/SKILL.md CSO sectionread from source
Stocktake: “Skills track the system” ruledotfiles AGENTS.md “Skills” section; dotfiles tool-routing.md “Stocktake at milestone boundaries”read from source
Stocktake algorithm (6 steps, verify checklist)stocktake/SKILL.mdread from source
Pressure-testing method (baseline, rationalisation table, close loopholes)writing-skills/SKILL.md “Bulletproofing Discipline Skills” + testing-skills-with-subagents.mdread from source
Promotion rules: private-corpus never, methodology and operations eligibledotfiles AGENTS.md “Agent-surface routing”read from source
Skill authoring rules (description shape, body size, sibling files, no @- links)writing-skills/SKILL.mdread from source

  1. The writing-skills skill, .pi/agent/skills/writing-skills/SKILL.md, in the dotfiles repo (private). Read as of 2026-10-20. ↩ ↩2 ↩3

  2. anthopics/claude-code#30387, “Custom skills are not reliably auto-triggered by the model” (closed not-planned). Cited in skill-guard.ts header comment. Read 2026-10-20. ↩

  3. AGENTS.md in the dotfiles repo (private), “Agent-surface routing” section. Read as of 2026-10-20. ↩

  4. AGENTS.md in the dotfiles repo (private), “Implementation discipline” section, and tool-routing.md “Stocktake at milestone boundaries” rule. Read as of 2026-10-20. ↩

  5. The stocktake skill, .pi/agent/skills/stocktake/SKILL.md, in the dotfiles repo (private). Read as of 2026-10-20. ↩