Skill Authoring skill for Claude Code
Write skills that load when needed and teach something. Turns a repeated prompt or tribal procedure into a working Agent Skill. Claude first runs your real requests without the skill to find the actual gaps, then writes a description built from the words users type, a body limited to procedure, thresholds and traps, and supporting files that load only when needed. A bundled validator checks frontmatter, name rules, body size and every referenced file.
- name
skill-authoring - size222 lines · ≈2,550 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: Creates new Agent Skills for Claude Code and claude.ai from scratch, covering folder layout, SKILL.md frontmatter, trigger-ready descriptions, progressive disclosure with references/ and scripts/, dynamic context, and validation before sharing. Use when the user asks to write, scaffold, or package a skill, turn a repeated prompt or slash command into a skill, convert .claude/commands files, or asks why a SKILL.md is not loading or is too long. Try asking
- Turn our release checklist into a project skill the whole team can use
- Convert the files in .claude/commands into proper skills
- My new skill doesn't show up in /skills — what's wrong with it?
Use it when
- You keep pasting the same instructions into sessions
- A team procedure should be shared through .claude/skills in the repo
- Migrating old .claude/commands markdown files to skills
- A skill fails to register or has grown past 500 lines
Skip it when
- The skill exists but triggers at the wrong times — use skill-description-tuning instead
- The rule must be enforced every time without exception — use hooks-automation instead
- The content is project facts every session needs — use memory-file-architect instead
How to check the result
The validator reports zero errors, /skills lists the skill, and in a fresh session the should-trigger prompts load it while the should-not prompts do not.
The common mistake it prevents
Writing a description that summarizes the body instead of naming the phrases users actually type, so the skill never loads.
A skill is done when it loads for the requests it was built for and stays quiet for everything else, its body teaches only what Claude would not reliably do unprompted, and it passes the validator below. A skill that restates general knowledge is context cost with no payoff.
Decide that a skill is the right container
| Need | Use | Why not a skill |
|---|---|---|
| Facts every session needs (build commands, repo layout) | CLAUDE.md | Always loaded; a skill only loads on demand |
| A repeatable procedure, checklist, or domain playbook | Skill | — |
Deterministic enforcement (always format, never touch .env) | Hook in settings.json | Skills are advice; hooks are guaranteed |
| A separate context window with scoped tools | Subagent (.claude/agents/) | A skill runs in the caller’s context unless context: fork |
| Live access to an external system | MCP server | A skill can document an API, not hold a connection |
If the user only wants a shortcut they type, a skill with disable-model-invocation: true
replaces the old .claude/commands/<name>.md file. Both create /name; new work goes
in skills.
Workflow
- [ ] 1. Collect 3-5 real requests the skill must handle, plus 2 it must not
- [ ] 2. Run those requests without the skill; note what Claude gets wrong
- [ ] 3. Choose scope, name, and folder
- [ ] 4. Write the description (the trigger)
- [ ] 5. Write the body: standard, workflow, decisions, gotchas, verification
- [ ] 6. Move branch-only depth into references/, deterministic work into scripts/
- [ ] 7. Validate structure, then test triggering and behavior in a fresh session
1-2. Ground the skill in observed failures
Ask the user for real prompts (or pull them from recent sessions). Run two or three without the skill. Write down the concrete gaps: skipped steps, wrong defaults, missing thresholds, invented flags. Those gaps are the body. Everything Claude already did correctly stays out.
3. Scope, name, folder
| Scope | Path | Shared with |
|---|---|---|
| Personal | ~/.claude/skills/<name>/SKILL.md | You, every project |
| Project | .claude/skills/<name>/SKILL.md (commit it) | Everyone on the repo |
| Nested | <subdir>/.claude/skills/<name>/SKILL.md | Work inside that subtree |
| Plugin | <plugin>/skills/<name>/SKILL.md | Plugin installers; invoked as /<plugin>:<name> |
- On a name clash the order is enterprise > personal > project. Pick a distinctive name.
- Name rules: 1-64 chars, lowercase letters, digits, single hyphens, equal to the folder
name. Prefer a gerund or noun phrase that names the task:
postgres-indexing,release-notes, nothelperorutils. - If the skill will be uploaded to claude.ai or the API, the name must not contain
claudeoranthropic. - Edits to existing skill folders hot-reload. A brand-new top-level
skills/directory created mid-session needs/reload-skills(or a restart).
4. Write the description
Only name and description sit in context before invocation. The description is the
whole routing decision. Formula:
<Verb phrase: what it does, with concrete nouns, tools, file types>. <Scope or output>.
Use when <phrases a user actually types>, <situations>, <file types or errors>.
- Third person, plain text, no XML or markdown. Hard limit 1024 chars; aim for 250-600.
- Name the technologies and artifacts (
Dockerfile,EXPLAIN,pyproject.toml), the verbs users say (“slow”, “broken”, “migrate”, “review”), and error strings when a skill exists to fix one. - Add a boundary when a sibling skill overlaps: “Not for X; use Y.”
- Never: “Helps with”, “Useful for various”, “A skill for”. These never trigger.
If triggering misbehaves after release, use the skill-description-tuning workflow.
5. Write the body
Skeleton that works for most procedural skills:
---
name: example-skill
description: ...
license: MIT
---
# Title
One to three lines: what "done well" means. No introduction.
## Ground rules (hard constraints, 3-6 bullets)
## Workflow (numbered steps + copyable checklist)
## Decisions (tables or if X -> do Y; one default plus escape hatch)
## Gotchas (non-obvious traps that bite in practice)
## Verification (commands to run, what passing looks like, what to do on failure)
## Output (template for the report, PR, or file it produces)
Rules for the body:
- Imperative voice to Claude: “Run”, “Check”, “Never”.
- Keep under 500 lines and roughly 5k tokens; 120-350 lines is the usual sweet spot.
- Exact commands, flags, config keys, and thresholds. If unsure a flag exists, describe the approach instead of guessing syntax.
- Destructive steps (force push,
DROP,rm -rf, production deploys) must say “confirm with the user first” inside the step, not in a footnote. - One recommended path. A list of five equal options pushes the decision back to Claude.
6. Progressive disclosure
Three tiers load at different times:
| Tier | Loaded | Budget |
|---|---|---|
name + description | Always, every session | ~100 tokens per skill |
| SKILL.md body | When invoked | < 5k tokens |
references/, scripts/, assets/ | Only when the body tells Claude to open or run them | Unbounded; script output, not source, enters context |
- Link references from the step that needs them and say when to read them:
Read [references/rollback.md](references/rollback.md) only if the deploy fails. - Keep references one level deep from SKILL.md. Chains (ref links to ref links to ref) get partially read.
- Add a short table of contents at the top of any reference longer than ~100 lines.
- Use
scripts/for deterministic work (parsing, validating, measuring). Scripts should use the standard library, print a clear result, and exit non-zero on failure. Document the exact command in SKILL.md. - Refer to bundled files with
${CLAUDE_SKILL_DIR}in commands so they work from any working directory:python3 ${CLAUDE_SKILL_DIR}/scripts/check.py src/.
Claude Code-only features
These work in Claude Code but are rejected by claude.ai uploads (which accept only
name, description, license, compatibility, metadata, allowed-tools). Use them
only for skills that will live in .claude/skills/ or a plugin.
| Feature | Use for |
|---|---|
disable-model-invocation: true | Manual-only commands with side effects (deploy, release) |
user-invocable: false | Background knowledge Claude loads but users never type |
argument-hint: "[issue-number]" | Autocomplete hint for /name |
$ARGUMENTS, $ARGUMENTS[0] / $0 | Inject what the user typed after /name |
!`git status --short` in the body | Runs before Claude sees the skill; output is inlined |
allowed-tools / disallowed-tools | Pre-approve or block tools while the skill is active |
context: fork (+ agent) | Run the skill in a subagent with its own context |
model, effort | Pin a model or effort level for this skill |
paths | Limit auto-loading to matching files |
hooks | Hooks that apply only while the skill runs |
Example manual-only command skill:
---
name: fix-issue
description: Fixes a GitHub issue end to end from its number. Use when invoked as /fix-issue.
argument-hint: "[issue-number]"
disable-model-invocation: true
allowed-tools: Bash(gh issue view:*), Bash(npm test:*)
---
Issue details:
!`gh issue view $ARGUMENTS --json title,body,labels`
1. Reproduce the bug with a failing test.
2. Fix it, run `npm test`, and summarize the root cause.
Dynamic ! commands run with the user’s permissions at load time. Keep them read-only
and fast; a failing command inlines its error text.
Gotchas
- A description that summarizes the body instead of naming triggers is the top reason skills never load. Users do not type “apply the deployment methodology”; they type “ship this to staging”.
- Frontmatter must be valid YAML. A description containing
:must be quoted, or the whole skill silently fails to register. Check/skillsafter writing. - Do not put secrets, tokens, or machine-specific absolute paths in a shared skill.
- Do not copy CLAUDE.md content into skills or vice versa; one source of truth per fact.
- Scripts are executed, not read. If Claude needs to understand the code, say so; otherwise only its output costs context.
- A skill that tries to cover a whole field (e.g. “frontend”) triggers too often and helps too little. Split by task.
Verification
-
Structure: run the bundled validator from this skill’s folder:
python3 ${CLAUDE_SKILL_DIR}/scripts/validate_skill.py path/to/skill-folder [--portable]It checks frontmatter parsing, name/folder match, name charset and reserved words (with
--portable), description length, unknown keys, body length, and that every relative link and script reference resolves. Fix all errors; read every warning. -
Registration: start a fresh session (or
/reload-skills), run/skills, confirm the skill is listed with the expected description./contextshows its listing cost. -
Triggering: send each should-trigger prompt from step 1 in a fresh session and confirm the skill loads; send each should-not prompt and confirm it does not.
-
Behavior: rerun the baseline prompts from step 2. Each gap you wrote down must now be handled. If a gap persists, the instruction is buried or vague; move it into the workflow step where it applies and make it concrete.
Output
Finish with a short report:
**Skill:** release-notes (project scope, .claude/skills/release-notes/)
**Files:** SKILL.md (184 lines), references/breaking-changes.md, scripts/collect_prs.py
**Triggers on:** "write release notes for v2.3", "summarize merged PRs since last tag"
**Does not trigger on:** "write a changelog entry for this commit" (commit-craft)
**Validator:** 0 errors, 1 warning (description 640 chars, accepted)
**Baseline gaps closed:** groups by user impact, flags breaking changes, links PRs Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/validate_skill.py
#!/usr/bin/env python3
"""Validate an Agent Skill folder (SKILL.md frontmatter, links, size).
Usage:
python3 validate_skill.py <skill-folder> [--portable]
--portable also enforce claude.ai / API upload rules (only portable frontmatter
keys, no "claude"/"anthropic" in the name).
Exit code 0 when there are no errors (warnings allowed), 1 otherwise.
Standard library only.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
PORTABLE_KEYS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}
CLAUDE_CODE_KEYS = {
"when_to_use", "argument-hint", "arguments", "disable-model-invocation",
"user-invocable", "disallowed-tools", "model", "effort", "context", "agent",
"hooks", "paths", "shell",
}
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
LINK_RE = re.compile(r"\]\(([^)#\s]+)(?:#[^)]*)?\)")
SCRIPT_REF_RE = re.compile(r"(?:\$\{CLAUDE_SKILL_DIR\}/)?((?:scripts|references|assets)/[\w./-]+)")
def parse_frontmatter(text: str) -> tuple[dict[str, str], str, list[str]]:
"""Minimal YAML subset parser: top-level `key: value`, block scalars, nested maps skipped."""
errors: list[str] = []
if not text.startswith("---"):
return {}, text, ["SKILL.md must start with '---' frontmatter"]
parts = text.split("\n")
try:
end = next(i for i in range(1, len(parts)) if parts[i].strip() == "---")
except StopIteration:
return {}, text, ["frontmatter is not closed with '---'"]
fm_lines = parts[1:end]
body = "\n".join(parts[end + 1:])
data: dict[str, str] = {}
key = None
block: list[str] | None = None
for raw in fm_lines:
if block is not None and (raw.startswith((" ", "\t")) or not raw.strip()):
block.append(raw.strip())
continue
if block is not None and key:
data[key] = " ".join(x for x in block if x)
block = None
if not raw.strip() or raw.lstrip().startswith("#"):
continue
if raw.startswith((" ", "\t", "-")):
continue # nested map / list content (metadata, hooks) - not validated here
m = re.match(r"^([A-Za-z0-9_-]+):(.*)$", raw)
if not m:
errors.append(f"unparseable frontmatter line: {raw!r}")
continue
key, val = m.group(1), m.group(2).strip()
if val in (">", "|", ">-", "|-", ">+", "|+"):
block = []
continue
if val and val[0] in "\"'":
if len(val) < 2 or val[-1] != val[0]:
errors.append(f"{key}: unterminated quoted value")
val = val[1:-1]
elif ": " in val:
errors.append(f"{key}: unquoted value contains ': ' (invalid YAML); wrap it in double quotes")
data[key] = val
if block is not None and key:
data[key] = " ".join(x for x in block if x)
return data, body, errors
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
portable = "--portable" in sys.argv
if len(args) != 1:
print(__doc__.strip())
return 2
folder = Path(args[0]).resolve()
skill = folder / "SKILL.md"
errors: list[str] = []
warnings: list[str] = []
if not skill.is_file():
print(f"ERROR: {skill} not found")
return 1
text = skill.read_text(encoding="utf-8")
fm, body, parse_errors = parse_frontmatter(text)
errors += parse_errors
name = fm.get("name", "")
desc = fm.get("description", "")
if not name:
errors.append("missing 'name'")
else:
if not NAME_RE.match(name) or len(name) > 64:
errors.append(f"name {name!r} must be 1-64 chars of lowercase letters, digits, single hyphens")
if name != folder.name:
errors.append(f"name {name!r} does not match folder {folder.name!r}")
if re.search(r"claude|anthropic", name):
(errors if portable else warnings).append(
f"name {name!r} contains a reserved word; claude.ai/API uploads reject it")
if not desc:
errors.append("missing 'description'")
else:
if len(desc) > 1024:
errors.append(f"description is {len(desc)} chars (max 1024)")
elif len(desc) < 100:
warnings.append(f"description is only {len(desc)} chars; add concrete triggers")
if re.search(r"<[A-Za-z/][^>]*>", desc):
errors.append("description contains XML/HTML-like tags")
if not re.search(r"\buse (it )?(when|for|if)\b", desc, re.I):
warnings.append("description has no 'Use when ...' trigger clause")
if re.match(r"^(helps|a skill|this skill|useful)", desc, re.I):
warnings.append("description opens vaguely; start with what it does")
for key in fm:
if key in PORTABLE_KEYS:
continue
if key in CLAUDE_CODE_KEYS:
if portable:
errors.append(f"key {key!r} is Claude Code-only; claude.ai/API uploads reject it")
else:
warnings.append(f"unknown frontmatter key {key!r}")
body_lines = body.strip("\n").count("\n") + 1 if body.strip() else 0
approx_tokens = len(body) // 4
if body_lines == 0:
errors.append("SKILL.md body is empty")
elif body_lines > 500:
errors.append(f"body is {body_lines} lines (keep under 500; move depth to references/)")
elif body_lines > 350:
warnings.append(f"body is {body_lines} lines; consider moving branch-only detail to references/")
if approx_tokens > 5000:
warnings.append(f"body is ~{approx_tokens} tokens (target < 5000)")
# Links in prose must resolve (error). Paths inside code may be examples (warning).
prose = re.sub(r"```.*?```", "", body, flags=re.S)
prose = re.sub(r"`[^`\n]*`", "", prose)
checked: set[str] = set()
candidates = [(t, True) for t in LINK_RE.findall(prose)]
candidates += [(t, False) for t in LINK_RE.findall(body) + SCRIPT_REF_RE.findall(body)]
for target, strict in candidates:
target = target.rstrip(".,)")
if target.startswith(("http://", "https://", "mailto:")) or target in checked:
continue
checked.add(target)
path = (folder / target).resolve()
if folder not in path.parents and path != folder:
warnings.append(f"reference points outside the skill folder: {target}")
elif not path.exists():
if strict:
errors.append(f"linked file does not exist: {target}")
else:
warnings.append(f"path mentioned in code does not exist (fine if it is an example): {target}")
for sub in ("references", "scripts", "assets"):
d = folder / sub
if d.is_dir():
for p in d.rglob("*"):
if p.is_file() and len(p.relative_to(d).parts) > 1 and sub == "references":
warnings.append(f"nested reference {p.relative_to(folder)}; keep references one level deep")
if p.is_file() and sub != "assets" and f"{sub}/{p.name}" not in body:
warnings.append(f"{sub}/{p.name} is never mentioned in SKILL.md, so Claude will not use it")
print(f"Skill: {name or '?'} ({folder})")
print(f" description: {len(desc)} chars body: {body_lines} lines, ~{approx_tokens} tokens")
for e in errors:
print(f" ERROR {e}")
for w in warnings:
print(f" WARNING {w}")
print(f"Result: {'FAIL' if errors else 'PASS'} ({len(errors)} errors, {len(warnings)} warnings)")
return 1 if errors else 0
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.