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-guardblocks a tool call once with a nudge, andskill-firstre-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.
Topology
Section titled “Topology”The same thing as text:
- Every skill is a directory under
.pi/agent/skills/<name>/containing aSKILL.mdfile and optional sibling files. Only the description is loaded into the system prompt on every turn; the body and siblings are read on demand. skill-guard.tsmatches 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.skill-first.tsre-injects an anchored context reminder across up to three turns until the model reads any skill file.skills-lint.pyruns on every commit that touches a skill; the pre-commit hook blocks on errors.- 74 skills reach Claude Code through per-skill symlinks in
.claude/skills/. The directory listing is the allowlist. - After each milestone, the stocktake pass reconciles the skill corpus with the systems it documents.
What a skill costs and where
Section titled “What a skill costs and where”| Part | When it is loaded | Context cost | Cap |
|---|---|---|---|
name | Every turn, every session | ~30 characters per skill | Must equal the directory name |
description | Every turn, every session | Up to 500 characters per skill (warrn), 1024 hard limit | Lists triggers, not workflow |
Body (SKILL.md) | When the description matches the current task | Loaded on demand | 500 lines (warn); reference over ~100 lines goes in a sibling file |
| Sibling files | When the body points at them | Loaded on demand, one level deep | No per-file cap; linked by plain filename, never @-force-loaded |
metadata.verified | Lint only, never in context | Zero per-turn cost | Warrns 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 as trigger surface
Section titled “The description as trigger surface”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.
Body length and sibling files
Section titled “Body length and sibling files”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.mdby plain filename with a “read when…” cue. Theself-correcting-loopskill 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 underdocs/.
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.
The lint: every check and why
Section titled “The lint: every check and why”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.
| Check | Level | Why |
|---|---|---|
Frontmatter parses and name equals directory | ERROR | A broken frontmatter means the harness cannot load the skill |
| Description over 1024 characters | ERROR | The harness refuses to load a skill with a description over 1024 characters |
| Description over 500 characters | WARN | Every turn of every session pays for it; 500 is the cost ceiling |
| Description starts with “Use when” / “Use ONLY when” | WARN | A description that does not name its triggers is a passive label, not a trigger surface |
| Description has no workflow/coverage verbs | WARN | “covers the X workflow” is a summary the agent treats as a shortcut instead of reading the body |
| Body over 500 lines | WARN | A body that competes with the conversation for context; reference goes in a sibling file |
| No smart punctuation (em/en dash, curly quotes, ellipsis, nbsp) | ERROR | These mis-decode when pasted into non-UTF-8 composers |
No @-force-load links | WARN | @-links burn context immediately; use plain filenames with read-when cues |
| Every supporting file referenced from SKILL.md | WARN | An unreferenced sibling is dead weight the agent never finds |
Every ~/path exists on disk | WARN | A path that does not exist is a dead link the agent follows and gets nothing from |
No retired terms (from .lint.json) | ERROR | A retired term in a skill is a stale fact an agent will act on; per-skill allow exceptions for historical mentions |
name skill` references resolve | ERROR | A reference to a skill that does not exist is a broken navigation link |
Dated entries under max_dates (default 8) | WARN | A skill that accumulates dated anecdotes grows monotonically; fold lessons that became defaults |
metadata.verified under verified_max_days (default 90) | WARN | A stale verified date signals a skill that has not been re-read since the system it documents changed |
| Claude Code symlinks not dangling | ERROR | A promoted skill whose source was deleted leaves a dead symlink that breaks the second harness |
docs source names are real docs-mirror topics | WARN | A 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-30A 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 gate
Section titled “The pre-commit hook gate”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.
Making the model actually open the skill
Section titled “Making the model actually open the skill”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.
Pressure-testing a skill with subagents
Section titled “Pressure-testing a skill with subagents”A discipline skill - one that constrains agent behaviour under pressure - gets a baseline test before it ships. The method, from the writing-skills skill1:
- Run the scenario with a subagent that does NOT have the skill. Record the exact rationalisations and failures.
- Write the skill against those specific failures.
- Re-run with the skill. Close the loopholes that appear.
- 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:
| Excuse | Reality |
|---|---|
| “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.
Promoting a subset to a second harness
Section titled “Promoting a subset to a second harness”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.
The stocktake practice
Section titled “The stocktake practice”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-lintruns on it.
The algorithm, from the stocktake skill5:
- 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.
- Inventory the facts: what the code actually is now, what shipped since the last stocktake, what is deployed.
- Diff and record every item: stale task status, missing command, deviation shipped without a note, wrong path or number.
- Fix the records, not the code. Drain every open item found outside the board onto it.
- Reconcile the board: every open card reviewed, stale ones moved back or commented, new ones created.
- 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.
What carries over to another harness
Section titled “What carries over to another harness”The skill counts, the lint thresholds, the retired-term list and the symlink counts are specific to this setup. These properties are not:
- 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.
- 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.
- 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.
- 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.
- Freshness markers prevent silent rot. A
verifieddate 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.” - 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.
- 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.
- 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.
Decision guide
Section titled “Decision guide”The same decision as text:
- 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.
- 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.
- 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.
- Run
skills-lint. Commit the skill together with the system change it documents. - If the skill is useful from the second harness, add a per-skill symlink. Otherwise, leave it pi-only.
Evidence
Section titled “Evidence”| Claim | How it was checked | Status |
|---|---|---|
| 84 skills in the pi corpus | ls on .pi/agent/skills/, 2026-10-20 | measured |
| 74 skills promoted to Claude Code | ls on .claude/skills/ minus the real lsp-servers directory, 2026-10-20; cross-referenced against the hub page | measured |
| Lint: 0 errors, 1 warning, 10 waived | `python3 scripts/skills-lint.py 2>&1 | tail -5`, 2026-10-20 |
| Description cap: 500 chars warn, 1024 error | skills-lint.py:83-84 | read from source |
| Body cap: 500 lines warn | skills-lint.py:90 | read from source |
Retired terms: 17 entries in .lint.json | .lint.json retired map, counted 2026-10-20 | read from source |
metadata.verified max age: 90 days | .lint.json verified_max_days; skills-lint.py:43 | read from source |
max_dates: 8 | .lint.json max_dates | read from source |
| Pre-commit hook blocks on lint errors | .config/git/hooks/pre-commit:52-58 | read from source |
skill-guard blocks once per exact command+rule, up to 3 per rule | skill-guard.ts header + MAX_FIRES_PER_RULE | read from source |
skill-first re-injects up to 3 times per session | skill-first.ts header + loadMaxFires() | read from source |
| “50% miss rate on trained-overlap skills” | anthopics/claude-code#30387, cited in skill-guard.ts header | read from source |
| Description as shortcut failure mode | writing-skills/SKILL.md CSO section | read from source |
| Stocktake: “Skills track the system” rule | dotfiles AGENTS.md “Skills” section; dotfiles tool-routing.md “Stocktake at milestone boundaries” | read from source |
| Stocktake algorithm (6 steps, verify checklist) | stocktake/SKILL.md | read from source |
| Pressure-testing method (baseline, rationalisation table, close loopholes) | writing-skills/SKILL.md “Bulletproofing Discipline Skills” + testing-skills-with-subagents.md | read from source |
| Promotion rules: private-corpus never, methodology and operations eligible | dotfiles AGENTS.md “Agent-surface routing” | read from source |
| Skill authoring rules (description shape, body size, sibling files, no @- links) | writing-skills/SKILL.md | read from source |
Related docs
Section titled “Related docs”- The pi harness journey - the series hub, the extension catalogue and skill counts
- Guard extensions that enforce prose and process rules -
skill-guard,skill-firstand the other guards that hook the tool call - A kanban board for coding agents - the Vikunja board the stocktake reconciles against
- Loop or graph: a sensor-gated coding-agent loop - the loop harness whose ladders are documented in a skill
References
Section titled “References”-
The
writing-skillsskill,.pi/agent/skills/writing-skills/SKILL.md, in the dotfiles repo (private). Read as of 2026-10-20. ↩ ↩2 ↩3 -
anthopics/claude-code#30387, “Custom skills are not reliably auto-triggered by the model” (closed not-planned). Cited in
skill-guard.tsheader comment. Read 2026-10-20. ↩ -
AGENTS.mdin the dotfiles repo (private), “Agent-surface routing” section. Read as of 2026-10-20. ↩ -
AGENTS.mdin the dotfiles repo (private), “Implementation discipline” section, andtool-routing.md“Stocktake at milestone boundaries” rule. Read as of 2026-10-20. ↩ -
The
stocktakeskill,.pi/agent/skills/stocktake/SKILL.md, in the dotfiles repo (private). Read as of 2026-10-20. ↩