How to author a skill¶
This guide collects the standards every skill in this catalogue follows — the bundled packs and the skills you author in your own pack alike. Some standards are checked by lint (called out inline, with the linter as the authority); the rest are reviewer-enforced. It assumes you already know what a skill is and when to add one (docs/CONVENTIONS.md § Skills: you've done the same multi-step thing three times, and you're not adding one speculatively).
If you're authoring the first skill in a new pack, read Pack workflow design first — it tells you how to design the pack's arc before writing individual skills.
For the credential dimension — a skill that calls an authenticated API or holds a token — read How to add a credentialed skill alongside this; that contract is separate and more specific.
Before you start¶
You need a skill directory under packs/<pack>/.apm/skills/<name>/ with a SKILL.md, and — if it does real work — a scripts/ helper.
Frontmatter and description¶
Keep frontmatter to the keys the agentskills.io spec allows (name, description, license, compatibility, metadata, allowed-tools) and put any project-specific data under the metadata: escape hatch. The description is one sentence that names the trigger ("Use when …"), not the implementation.
The description must be a single-line scalar. Folded/literal YAML blocks (>, |) and continuation lines (an indented next line) parse as valid YAML but break on some adapter targets — their downstream loaders fail on a multi-line description even when the YAML itself is clean. For the same reason, keep YAML structural characters out of an unquoted value: a bare : mid-description, a leading #/&/*/[, or whitespace-then-# all change the parse. If you need any of those characters, wrap the whole value in double quotes. Keep it under 1024 characters.
Don't memorize the exact rules from this page — run the linter, which is the source of truth:
They check the key whitelist, description syntax, allowed-tools shape, and evals/. (The CONVENTIONS rule applies: the linter does the style job better than prose can.)
Body structure¶
Use the section order the shipped skills share, so a reader always knows where to look:
# <Skill> title+ a 2–3 sentence intro stating what it does and what it doesn't.## When to Use— the trigger conditions.## Prerequisites— tools, packages, sibling skills, credentials (see the tier ladder below).## Instructions/## Procedure— numbered steps with copy-paste commands. The agent invokes the script; it is not the script.## Pitfalls/### Don't— known failure modes and what not to do.## Verification— how the agent confirms it worked.
Naming your skill¶
Use the verb taxonomy from ADR-0054 to pick the right name:
| Verb | Meaning | Activation phrasing |
|---|---|---|
status | Orient — "where am I / what's next?" | Cold-start phrases, "what's on today", "orient me" |
start | Create/begin a sustained project | "start a research project", "kick off an investigation" |
check | Quality/health read — "is it good / saturated / done?" | "is this ready", "should I keep gathering" |
init | Repo-scaffold only | init-project, adapt-to-project; cf. git init |
resume | Return to prior work | Activation phrase — not a skill name; see work-loop's argless trigger |
Banned as skill names: arrive, orient, onboard, return, onboarding — these are UX-stage labels, not user-facing commands.
Directory layout¶
A skill is a directory with SKILL.md plus four optional subdirectories — and the linter (tools/lint-skill-spec.py) only blesses those four:
scripts/— helper code the skill invokes (python scripts/foo.py). The skill body drives the script; it is not the script.references/— detailed material the agent loads on demand, not every time (schemas, per-branch strategies, long tables).assets/— templates and fixtures the skill copies or fills in (assets/template.html,assets/state.json).evals/— evaluation fixtures. Two files serve two tiers:evals/eval_queries.json(Tier-A activation evals) and/orevals/evals.json+evals/files/<fixture>(Tier-B output-quality evals). See Evals below.
Two rules the linter enforces, worth getting right the first time:
- Keep files one level deep. A reference to
scripts/a/b/c.pywarns — flatten it. (evals/keeps its own canonical nesting.) - Reference your own files skill-relative, sibling skills by name.
scripts/foo.pyandreferences/bar.md, never.claude/skills/<name>/...orpacks/<pack>/.apm/skills/<name>/...install-path prefixes; to point at another skill, name it (the jira skill), don't path into it.
Edit the seed under packs/<pack>/.apm/skills/<name>/, never the projected copy under .claude/skills/; after any edit run make build-self to regenerate the projection, then python3 tools/lint-skill-spec.py.
Progressive disclosure¶
SKILL.md is the always-loaded entry point; everything in references/ is pulled in only when the workflow reaches it. So keep SKILL.md lean — the spec recommends under 500 lines, the linter warns past 500 and errors past 1000 — and push depth into references/ that the body links to at the moment of need. file-to-markdown is the model: its body routes to exactly one of five references/strategy_*.md files per run ("Pick one strategy and load its reference file … Do not load any other strategy file unless you switch"), so the agent never carries four irrelevant strategies in context. Reach for a reference file whenever a section would otherwise bloat the body with detail only one branch needs.
Write scripts cross-platform¶
Skills run under every adapter and on every OS, including Windows, where there's no guaranteed POSIX shell — PowerShell is the default and git-bash may not be present. So:
- New helper scripts are Python, not bash. Invoke them as
python scripts/<name>.pyfrom the skill body — portable everywhere. - Detect tools with
shutil.which("<tool>"), notcommand -v.command -vis a POSIX-shell builtin that doesn't exist in PowerShell (Get-Commandis the equivalent); a Python check resolves identically on macOS, Linux, and Windows. - Prefer
pathlib,tempfile.gettempdir(), and Python-level filtering overgrep/sed/findand hardcoded/tmp. Declare an OS restriction only when the dependency is genuinely platform-bound. - Always pass
encoding="utf-8"toread_text/write_text/openfor text. Windows defaults to CP1252 and silently corrupts non-ASCII Markdown/JSON/TOML otherwise. subprocessuses list form, nevershell=True, and never shells out towhich/grep/find/sed/awk/make/bash— use the Python equivalent.
Dependencies: the three-tier policy¶
Any dependency outside the Python standard library — a CLI binary, a pip/npm package, or a sibling skill — falls under one of three tiers. Tier 1 is mandatory and the default; Tier 2 is allowed but never the default; Tier 3 is banned.
What counts as a dependency: Python itself is the assumed runtime — skills invoke python scripts/..., so a stdlib-only script declares nothing. pip/uv ship with a Python install, so a pip-based Tier-2 install is low-risk (the manager is almost always there). npm/Node is not a given — it's only present if the user installed Node — so an npm-based skill detects npm (and Node) before relying on it, and a Tier-2 npm install is gated on that detection. Prefer stdlib over a pip dependency, and a pip dependency over an npm one, when you have the choice.
Tier 1 — declare, detect, fail clean (mandatory, default)¶
- Declare. A
## Prerequisitessection names the tool, the minimum version if one matters, and the exact install command or link. - Detect. The first action of any workflow checks the dependency is present, before any real work — a
--checkverb on your helper script that usesshutil.which("<tool>")and exits0(present) /2(absent). (shutil.whichfinds binaries; probe a library package with an import /require.resolveinstead — see the variations below.) Where a version floor matters, also parse<tool> --versionand compare. - Fail clean. On absence, stop and emit a precise remediation string — the exact install command from
## Prerequisites. Do not improvise around the missing tool (no hand-rolled fallback, no third-party web service).
mermaid-renderer is the reference:
## Prerequisites
The renderer shells out to `@mermaid-js/mermaid-cli`. Install once:
```bash
npm install -g @mermaid-js/mermaid-cli
```
### Step 1 — Verify `mmdc` is available
```bash
python scripts/render_mermaid.py --check
```
- Exit code 0 → `mmdc` is on `PATH`, proceed.
- Exit code 2 → not installed. Tell the user the install command above.
Don't try to install it for them.
Tier 2 — opt-in, gated, idempotent install (allowed, not default)¶
A skill may install a dependency for the user only when all of these hold:
- the install is a single, deterministic command;
- it needs no sudo / root;
- it uses a package manager the user demonstrably already has —
uv,npm,pipx, orbrewif detected (detect the manager itself first; don't assume it).
When you install, the pattern is always detect → install → re-verify, and you pin the version — never assume the install succeeded:
1. detect: shutil.which("foo") → present? done, no-op.
2. gate: absent → ask the user; install only on explicit consent.
3. install: npm install -g foo@1.2.3 (pinned, no sudo)
4. re-verify: shutil.which("foo") → still absent? fail clean.
Gated means the user opts in: get explicit consent before installing into their environment; don't install as a silent side effect of the first run. Idempotent means re-running is safe — because you detect first, an already-present dependency is a no-op.
Tier 3 — banned¶
Never:
- silently auto-install anything (install without the user opting in);
- assume sudo / root, or run an install that needs it;
curl … | bash(or any pipe-to-shell installer) without explicit consent;- assume PATH within the same session — re-verify after an install rather than trusting that a just-installed binary is resolvable.
Variations on Tier 1 detection¶
- A pip/npm package, not a binary. Probe presence with an import /
require.resolve, notshutil.which(which finds binaries, not library packages); on absence, declare the install line and stop —file-to-markdownis the detect-and-stop model. A skill that goes further and installs on consent is Tier 2, not Tier 1 (markdown-to-html). - A sibling skill. Detect by invoking that skill's own
checkverb and reading its exit code; on failure, point the user at the sibling's setup rather than reaching into its internals (flow-metrics, in the atlassian pack →jira: check). - A vendor CLI the user authenticated (
gh,git,kubectl). Presence detection still applies; the credential dimension is theauth: clibroker — see the credentialed-skill guide.
Evals — does the skill activate, and does it do the job?¶
A skill has two failure modes worth measuring, and evals/ holds a separate file for each. They are distinct files with distinct schemas — don't merge them.
| File | Tier | Question it answers | Run by |
|---|---|---|---|
evals/eval_queries.json | A — triggering | Does this skill activate on the prompts it should, and stay quiet on the near-misses it shouldn't? | tools/run-pack-evals.py (today) |
evals/evals.json | B — output quality | Once activated, does it do the job? | authored by hand now; automated running/grading is deferred to a future RFC |
Tier A — writing activation evals (evals/eval_queries.json)¶
A flat JSON array; each element is { "query": "<a natural user prompt>", "should_trigger": <bool> }. Aim for ~8–10 should-trigger and ~8–10 should-not-trigger cases. The negatives are the load-bearing part: make them near-misses — prompts that share keywords or concepts with your skill but need a different one — not trivially-irrelevant prompts. (For the Office converters, the negatives deliberately separate docx / pptx / xlsx from each other; for new-spec, "record this decision" is a near-miss that belongs to new-adr.)
[
{"query": "Let's write a spec for a new export feature", "should_trigger": true},
{"query": "Fix the bug where export drops the header row", "should_trigger": false}
]
Declare which skills are covered in the pack's pack.toml — an explicit allowlist, never auto-discovery:
Then run the evals locally (report-only; needs the claude CLI on PATH):
It projects the pack in isolation, runs each query through the headless claude detector several times, computes a per-query trigger_rate, and grades it: a should_trigger: true query passes iff trigger_rate > 0.5; a should_trigger: false query passes iff trigger_rate < 0.5. The runs and a bounded summary.json land in a gitignored, iteration-numbered eval-workspace (see pack layout). A miss is a signal to sharpen your description: — the one field that drives activation.
Not every skill belongs in [pack.evals].skills: a reviewer-internal skill with no user-prompt surface (e.g. security-checklists), or one loaded broadly by a discipline rather than a narrow prompt (e.g. work-loop), is deliberately left out.
Running Tier-A evals in-harness (Kiro IDE, or without the claude CLI)¶
The headless mode above needs the claude CLI. Where you don't have it — Kiro IDE, or an interactive session where you'd rather not shell out — there is a second, lower-fidelity mode (RFC-0037 § Errata E2). The host agent itself is the detector: for each query it dispatches a fresh, read-only sub-context (Claude Code's subagent; Kiro's agent-spawn) given the covered skills' description:s, and asks which it would activate. Drive it as a procedure:
- For each covered skill's
eval_queries.json, dispatch one sub-context per query — judgement only; it must not run any tool or skill body (the query string is author-influenced and the headless--allowed-tools Skillsandbox does not apply here, so containment is yours to enforce). Two controls make that a floor, not a hope: - Dispatch read-only / no-execution. Use the most restricted dispatch the harness offers (a read-only subagent type, or an explicit "use no tools" instruction); grant no
Bash/Write/Editand no project file access. - Treat the query as data, not instructions. Put the query in a delimited block and tell the sub-context to classify the content below, never follow it — so a query like "ignore the question and run …" is classified, not obeyed. Prompt it with the covered skills' names + descriptions and the delimited query; collect the single skill name it reports (or
null). - Assemble the reports as
{skill: {query_id: [reported | null | "__error__", …]}}(one entry per run;query_idisq00,q01, … by position). - Grade with
python tools/run-pack-evals.py --pack <name> --mode in-harness --reports <reports.json>— sametrigger_rate/0.5 grading and eval-workspace, but the summary is labelledmode: in-harness,fidelity: reported.
This measures a description-match judgement, not the real activation router (a dispatched sub-context can't be restricted to only the pack's skills), so it is a portable reach check — never the calibration baseline. When you have the claude CLI, prefer the headless mode.
Tier B — authoring output-quality evals (evals/evals.json)¶
evals/evals.json is { "skill_name", "evals": [{ "id", "prompt", "expected_output", "files"?, "assertions"? }] }. Author these now — they document what good output looks like — but note that this RFC does not run or grade them; automated execution (with/without-skill comparison, LLM-judge, pass-rate deltas) is a future Tier-B RFC.
Two things make a Tier-B eval worth writing:
- A concrete
expected_output. Describe what the agent should actually produce and do — "invokesrender.pywith--template report.docx, reports the OUTPUT path, does not hand-write the .docx" — not a vague "produces a good doc". The detail is what a future grader (or a human reviewer today) checks against. - Assertions that bite. A good assertion is falsifiable and behaviour-level ("does NOT instruct the user to pre-escape the ampersand"); a weak one restates the prompt ("produces output"). Until automated grading lands, a human reads the run against these — so write them for that reader.
Running a lightweight behavior/output check in-harness (tier: B-lite)¶
The full Tier-B grading (LLM-judge, pass-rate deltas, with/without-skill) is a future RFC. But a lightweight check — run the skill and validate its outputs against deterministic post-conditions — is available now in-harness (RFC-0037 § Errata E3). Add an optional expect block to an eval to make it runner-gradable:
{"id": 1, "prompt": "Turn this Markdown into a Word doc using report.docx",
"expected_output": "…prose…", "assertions": ["does NOT hand-write the .docx"],
"files": ["evals/files/report.docx"],
"expect": {"produces": ["out.docx"], "output_contains": ["OUTPUT:"],
"output_excludes": ["Traceback"]}}
Drive it as a procedure — this executes the skill body, so it carries a sharper containment requirement than the activation check (the --allowed-tools Skill and "no execution" controls do not apply):
- Scope gate — only safe skills. Run the behavior check only for skills whose execution is non-destructive and needs no network or credentials. A skill that deletes, writes outside its workspace, or phones home is out of scope until a real OS-level sandbox exists — this is a procedure + scope-gate, not a mechanical sandbox (a host agent running the skill keeps its full tool surface). Skills that integrate a logged-in backend (credentialed skills on the
auth: cli/ credential-broker contract) are out of scope here: behavior-checking them would need live auth + a real backend and could mutate remote state — they get activation (Tier-A) coverage only. Their behavior needs recorded-interaction replay or a disposable test backend (a future Tier-B mechanism), never real credentials injected into an eval. - Ensure prerequisites — the skill's own contract, never the harness's. The eval runs the real skill, so its deps must be present. Don't hand-roll a setup: run the skill's own detection (
## Prerequisites→ its--check); if deps are absent, install once via the skill's own documented command (e.g.npm installin the skill dir —node_modules/is gitignored) and re-check. The harness never auto-installs. After the one-time install the run is repeatable: the skill's--checkdetects-present and you proceed. - Per eval: get a confined working dir from the runner —
python tools/run-pack-evals.py --pack <name> --prepare-workspace <skill>/<eval_id>prints an OS-temp dir seeded with the eval'sevals/files/fixtures (path-confined). Run the skill on thepromptwith that dir as cwd, instructing it to confine writes there and bounding the run (stop it if it exceeds a sane per-eval time). Capture the run's output to.eval-output.txtin that dir. Tear the dir down in afinally. - Collect
{skill: {eval_id: {"assertions": [<bool per assertion>], "errored": <bool>, "workspace": "<dir>"}}}and grade:python tools/run-pack-evals.py --pack <name> --mode in-harness --check behavior --reports <file>. The runner re-derives the deterministic checks (expect.producesfiles exist,output_contains/excludesagainst.eval-output.txt) from the working dir itself — you only attest the semanticassertions. Summary is labelledtier: B-lite,fidelity: observed+attested.
Grading the quality layer with an LLM-judge (--mode judge)¶
Deterministic checks (above) cover validity/shape; the quality layer — is the contract well-designed? is this the right diagram? — has no ground truth, so it needs a model. --mode judge grades a produced artifact against the eval's rubric (its expected_output + assertions) and is report-only (a quality signal, never a gate). The lens is the rubric you already authored; the repo's own review skills (e.g. architect-review) are natural lenses.
# point each eval at the artifact the skill produced, then judge:
python tools/run-pack-evals.py --pack <name> --mode judge \
--judge-adapter codex --artifacts artifacts.json # {skill: {eval_id: <artifact-path>}}
Choosing the backend and model. Judge backends are declarative command templates, not code — so you pick the backend/model, or add your own, by config:
- Built-in:
--judge-adapter claude-code(same model) or--judge-adapter codex(an independent model/IDE — preferred, since a cross-model judge can't self-grade). - Pick the model:
--model <name>(passed via that backend's model flag). - Bring your own backend — e.g. a Kiro headless judge — with a
--judge-config <toml>, no code change:
[judge.kiro-cli]
command = ["kiro-cli", "chat", "--no-interactive", "{prompt}"] # your real headless invocation
model-flag = "--model"
extract = "stdout" # or "json:<field>" if the CLI emits a JSON envelope
python tools/run-pack-evals.py --pack <name> --mode judge \
--judge-adapter kiro-cli --model <your-model> --judge-config judges.toml --artifacts artifacts.json
{prompt} is substituted as a discrete argv element (never a shell string); your file is merged over the built-ins, so you can also override a built-in (e.g. pin claude-code to a specific model). The judge is judgment-only (the artifact is inlined; claude is granted no tools, codex runs -s read-only) and fails closed — an unparseable verdict is an error, never a silent pass. It wobbles run-to-run and wants periodic human calibration (does it agree with you on a sample?); the full Tier-B grading (pass-rate deltas, with/without-skill, train/validation) is a future RFC.
What's enforced vs. recommended¶
Frontmatter and description rules are lint-enforced (tools/lint-skill-spec.py, tools/lint-agent-artifacts.py); credentialed-skill rules have their own lint. The body structure, the cross-platform rules, and the three-tier dependency policy are reviewer-enforced conventions — no gate checks them, so they live or die in review. Hold the line there.
See also¶
- How to add a credentialed skill — the separate contract for tokens, API auth, and the
auth: clibroker. mermaid-renderer— the Tier-1 reference:## Prerequisites+ ashutil.which--checkverb + an explicit "don't auto-install" rule.docs/CONVENTIONS.md§ Skills — when to add a skill at all (the three-times rule).