Claude Code Mastery Editor's pick

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.

  • nameskill-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

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.

~/.claude/skills/skill-authoring/SKILL.md

--- name: skill-authoring description: Creates new Agent Skills for Claude Code and claude.ai from scratch, covering folder layout, SKILL.md frontmatter, trigger-ready… license: MIT ---

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

NeedUseWhy not a skill
Facts every session needs (build commands, repo layout)CLAUDE.mdAlways loaded; a skill only loads on demand
A repeatable procedure, checklist, or domain playbookSkill—
Deterministic enforcement (always format, never touch .env)Hook in settings.jsonSkills are advice; hooks are guaranteed
A separate context window with scoped toolsSubagent (.claude/agents/)A skill runs in the caller’s context unless context: fork
Live access to an external systemMCP serverA 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

ScopePathShared with
Personal~/.claude/skills/<name>/SKILL.mdYou, every project
Project.claude/skills/<name>/SKILL.md (commit it)Everyone on the repo
Nested<subdir>/.claude/skills/<name>/SKILL.mdWork inside that subtree
Plugin<plugin>/skills/<name>/SKILL.mdPlugin 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, not helper or utils.
  • If the skill will be uploaded to claude.ai or the API, the name must not contain claude or anthropic.
  • 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:

TierLoadedBudget
name + descriptionAlways, every session~100 tokens per skill
SKILL.md bodyWhen invoked< 5k tokens
references/, scripts/, assets/Only when the body tells Claude to open or run themUnbounded; 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.

FeatureUse for
disable-model-invocation: trueManual-only commands with side effects (deploy, release)
user-invocable: falseBackground knowledge Claude loads but users never type
argument-hint: "[issue-number]"Autocomplete hint for /name
$ARGUMENTS, $ARGUMENTS[0] / $0Inject what the user typed after /name
!`git status --short` in the bodyRuns before Claude sees the skill; output is inlined
allowed-tools / disallowed-toolsPre-approve or block tools while the skill is active
context: fork (+ agent)Run the skill in a subagent with its own context
model, effortPin a model or effort level for this skill
pathsLimit auto-loading to matching files
hooksHooks 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 /skills after 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

  1. 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.

  2. Registration: start a fresh session (or /reload-skills), run /skills, confirm the skill is listed with the expected description. /context shows its listing cost.

  3. 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.

  4. 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 180 lines · 7.5 KB
#!/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.

esc