← Back to Blog

Agent Skills: Write the Procedure Once, Load It Only When Needed

A release checklist usually lives in three places: a wiki page nobody opens, a Slack message you pinned two quarters ago, and a section of CLAUDE.md that loads on every single turn. The first two rot quietly. The third one charges you tokens on the days you are just renaming a variable.

Agent Skills exist for that third problem. A skill is a folder with a SKILL.md file inside. At startup the agent reads only the name and the description of each installed skill, roughly 100 tokens per skill by the spec's own estimate. The body loads when a task matches. Anthropic published the format as an open standard on December 18, 2025, and the same folder now works in Claude Code, claude.ai, and the Agent SDK.

The part that breaks is rarely the body. It is the description, because that string is everything the model sees before it decides to open the file.

What you need

  • Claude Code 2.x (claude --version), or another client that reads the Agent Skills format
  • A git repository to test against
  • python3 with PyYAML, only for the validator in step 6

Step 1: Create the folder

mkdir -p ~/.claude/skills/changelog-from-commits/{scripts,references}
~/.claude/skills/
└── changelog-from-commits/
    ├── SKILL.md            # required
    ├── scripts/collect.py  # deterministic work
    └── references/grouping.md

Personal skills in ~/.claude/skills/ load in every project on your machine. Project skills in .claude/skills/ load for anyone who clones the repo, which is the better default for team procedures. Both accept a symlinked folder if you keep your skills in a dotfiles repo.

That three-level structure is the whole design. Metadata sits in the prompt at all times. The body enters context on activation. Files in scripts/ and references/ are read only if the body tells the agent to read them, which is why the spec calls the amount of context you can bundle effectively unbounded.

Step 2: Write frontmatter that survives validation

The open standard requires two fields and allows four more:

  • name: max 64 characters, lowercase letters, digits, and hyphens only, no leading or trailing hyphen. Match the folder name. Packaging tools and anything else that walks the filesystem key the skill off the directory.
  • description: 1 to 1024 characters, non-empty, says what the skill does and when to use it.
  • license, compatibility (max 500 characters), metadata (string to string map), allowed-tools (marked experimental).

Claude Code is looser than the standard. Its name is optional and falls back to the directory name, and only description is recommended. That is fine while the skill stays on your laptop. The moment you upload it to claude.ai or ship it through the Skills API, only the six standard fields are accepted, and an extra key fails the upload with a hard error rather than being ignored. Claude Code extension fields like argument-hint, context, model, and paths are exactly the ones that trip this.

So write both required fields even when Claude Code would let you skip one.

Two more parsing rules that cost people an afternoon: the opening --- has to be the first line of the file, and the YAML has to actually parse. Break either one and the skill still loads with no metadata. Slash invocation works, automatic triggering never does.

Step 3: Make the description fire

Everything else in the file is invisible at decision time. Claude Code caps the combined description and when_to_use text at 1,536 characters per entry, and when the whole listing outgrows its budget, which defaults to 1 percent of the model's context window, it drops descriptions starting with the skills you use least. A skill whose description got dropped cannot trigger on its own, no matter how good the body is.

Compare:

description: Helps with deploys.
description: Builds a grouped changelog from git commits since the last tag and
  writes it to CHANGELOG.md. Use when the user asks for release notes, a changelog,
  "what shipped", or to prepare a release.

The second one names the artifact, the input, and the phrasings a person would actually type. Put the primary use case in the first sentence, because that is the part that survives truncation.

Step 4: Keep the body short, because it stays

Once a skill loads, its content stays in the conversation for the rest of the session and Claude Code does not re-read the file on later turns. Every line is a recurring cost. The spec recommends keeping the instruction body under 5,000 tokens, and 500 lines is a reasonable ceiling.

Numbered steps, imperative voice, no reasoning. If a line exists to explain why the rule matters, it belongs in a comment in the script or in references/, not in the body.

Step 5: Push deterministic work into scripts

Here is the actual skill file I used to test this, 21 lines of body and a 189-character description:

---
name: changelog-from-commits
description: Builds a grouped changelog from git commits since the last tag and
  writes it to CHANGELOG.md. Use when the user asks for release notes, a changelog,
  "what shipped", or to prepare a release.
license: MIT
metadata:
  author: arya
  version: "1.0"
---

# Changelog from commits

Turn the commits since the last tag into release notes grouped by type.

## Steps

1. Run `python3 ${CLAUDE_SKILL_DIR}/scripts/collect.py` to get the commit list
   as JSON. Do not call `git log` yourself.
2. Group the commits: `feat` under Features, `fix` under Fixes, `perf` under
   Performance, everything else under Other. Drop merge commits and `chore`.
3. Write the result above the previous entry in `CHANGELOG.md`. Keep the
   existing heading style and never rewrite older entries.
4. Show the user the new section and the commit count.

## Edge cases

- No previous tag: use the first commit as the baseline and say so.
- Empty list: report that nothing shipped and stop.
- A subject line with no `type:` prefix: put it under Other, do not invent a type.

See `references/grouping.md` for the exact heading format the repo uses.

The script does one job, and its source never enters the context window. Only the output does. That is the difference between paying for 40 lines of Python and paying for 48 compact JSON objects.

import json
import subprocess

def git(*args):
    out = subprocess.run(["git", *args], capture_output=True, text=True, check=True)
    return out.stdout.strip()

try:
    rev = git("describe", "--tags", "--abbrev=0") + "..HEAD"
except subprocess.CalledProcessError:
    rev = "HEAD"

commits = []
for line in filter(None, git("log", rev, "--no-merges", "--pretty=format:%h|%s").splitlines()):
    sha, subject = line.split("|", 1)
    kind, sep, rest = subject.partition(":")
    commits.append({
        "sha": sha,
        "type": kind.strip().lower() if sep else "other",
        "subject": rest.strip() if sep else subject,
    })

print(json.dumps(commits, indent=2))

I ran it against the repository that serves this blog, which has no tags yet, so it fell back to HEAD and returned 48 objects. The fallback matters: a skill that crashes on the first release because there is no previous tag is worse than no skill.

${CLAUDE_SKILL_DIR} expands to the directory holding the SKILL.md, so the path works no matter where the skill is installed. The same variable is substituted inside allowed-tools, which lets you pre-approve exactly one command:

allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/collect.py *)

Step 6: Know what the permission fields actually do

allowed-tools is a pre-approval, not a sandbox. In Claude Code it lists tools the model may use without asking during the turn that invokes the skill, and the grant clears the moment you send your next message. disallowed-tools is the opposite: it removes tools from the pool while the skill is active. Neither restricts what a bundled script can do once it runs.

The standard's version of allowed-tools is described as pre-approved tools and marked experimental, so expect the semantics to differ across clients. Treat the field as a convenience for read-only skills, and keep a real permission policy at the client level.

Skill content is instructions plus executable code, which means it is also an obvious place to hide something. Anthropic's own guidance is to install skills only from sources you trust and to read the bundled scripts before enabling one. Do that, especially with anything pulled from a marketplace.

Step 7: Validate before you trust it

The builtin checks are claude plugin validate ~/.claude/skills (Claude Code v2.1.233 or later) for frontmatter that does not parse, and /doctor for an estimate of what the skill listing costs in context.

I wanted the standard's own constraints too, so I wrote a small checker and put it next to the skill:

#!/usr/bin/env python3
"""Validate a SKILL.md against the Agent Skills open standard."""
import re
import sys
from pathlib import Path

import yaml

NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
SPEC_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}

path = Path(sys.argv[1]).expanduser() / "SKILL.md"
text = path.read_text(encoding="utf-8")

if not text.startswith("---"):
    sys.exit("FAIL  frontmatter must start on line 1")

fm = yaml.safe_load(text.split("---")[1]) or {}
problems = []
name, desc = str(fm.get("name", "")), str(fm.get("description", ""))

if not name:
    problems.append("missing required field: name")
elif not NAME_RE.match(name) or len(name) > 64:
    problems.append(f"name '{name}' must be lowercase-hyphenated, max 64 chars")
elif name != path.parent.name:
    problems.append(f"name '{name}' does not match directory '{path.parent.name}'")

if not desc:
    problems.append("missing required field: description")
elif len(desc) > 1024:
    problems.append(f"description is {len(desc)} chars, max is 1024")

listing = len(desc) + len(str(fm.get("when_to_use", "")))
if listing > 1536:
    problems.append(f"listing text is {listing} chars, Claude Code cuts at 1536")

extra = sorted(set(fm) - SPEC_FIELDS - {"when_to_use"})
if extra:
    problems.append(f"non-spec fields break claude.ai upload: {', '.join(extra)}")

print(f"{name}: description {len(desc)} chars, {len(extra)} non-spec fields")
for p in problems:
    print(f"  FAIL  {p}")
sys.exit(1 if problems else 0)

On the working skill it prints changelog-from-commits: description 189 chars, 0 non-spec fields and exits 0. On a deliberately broken one, a folder called deploy with name: deploy-helper and an argument-hint key, it prints both failures and exits 1:

deploy-helper: description 919 chars, 1 non-spec fields
  FAIL  name 'deploy-helper' does not match directory 'deploy'
  FAIL  non-spec fields break claude.ai upload: argument-hint

That second line is the one I keep catching in other people's skills. It works perfectly in Claude Code and fails at upload, which is a bad place to find out.

Step 8: Test the trigger, not just the output

Ask three different phrasings that should match the description, then one that should not. If the first three fire and the fourth stays quiet, the description is doing its job. If nothing fires, run claude --debug and search the log; malformed frontmatter shows up there and nowhere else.

Claude Code watches skill directories, so edits to an existing skill apply inside the current session. A brand new top-level skills directory needs /reload-skills first.

When a skill is the wrong tool

  • A fact that has to hold on every turn, like your package manager or your test command, belongs in CLAUDE.md. Skills are for procedures.
  • Live data or an external API belongs behind an MCP server. A skill can describe how to use a tool, it cannot be the tool.
  • A rule that must never be skipped belongs in a hook. Skill bodies are read once on invocation, and a model that has moved on will skip a line it read 20 turns ago.

Wrap up

One test decides the placement: is this a procedure with steps and reference material? Then it is a skill. Write both required fields, put the trigger phrases in the first sentence of the description, keep the body under a screen or two, and move anything mechanical into scripts/.

Next step: take the checklist you pasted into chat most recently this week, turn it into a skill folder, and run the validator on it before you write the body. If the frontmatter is wrong, nothing downstream matters.

Referensi

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation