Published on

Claude Code Skills: A Practical Guide to Writing SKILL.md Files

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Most people who use Claude Code discover CLAUDE.md early — it's obvious, it's documented, and the effect is immediate. Skills take longer to find. They live in a subdirectory, they have their own file format, and the value isn't clear until you've written one that saves you ten minutes on a task you do every day.

This guide covers the whole picture: what a skill is, how the file is structured, how Claude decides when to use one, and the patterns that make skills actually useful versus ones that just add noise.

Contents

  1. What is a skill?
  2. Where skill files live
  3. The anatomy of a SKILL.md file
  4. Frontmatter field reference
  5. How invocation works
  6. Skills vs CLAUDE.md: when to use which
  7. Passing arguments
  8. Writing descriptions that work
  9. allowed-tools and permissions
  10. Practical examples
  11. Supporting files and directory layout
  12. Common problems and how to debug them
  13. Before you ship a skill

A complete example

Before the details, here is a full working skill you can drop into your project today:

---
name: review
description: Review a pull request or recent code changes for security issues, performance problems, and style violations
argument-hint: "[file-or-pr]"
allowed-tools: Read, Grep, Glob
---

Review the code in $ARGUMENTS (or the most recently changed files if no argument is given).

## Security
- SQL injection, command injection, path traversal
- Secrets or credentials committed to code
- Missing input validation at system boundaries
- Unsafe deserialization or eval usage

## Performance
- N+1 queries or loops that trigger nested DB calls
- Missing indexes on frequently queried columns
- Allocations or work in tight loops
- Blocking I/O on the main thread

## Correctness
- Error paths that silently swallow exceptions
- Race conditions in concurrent code
- Off-by-one errors in slice or index operations
- Unchecked nil/null dereferences

## Style
- Does it follow the patterns in the surrounding code?
- Is there unnecessary complexity?
- Are variable and function names clear?

---

Give specific file:line references for each issue.
Separate **must fix** (bugs, security) from **suggestions** (style, performance).
Keep the summary short — one sentence per issue is enough.

Save this as .claude/skills/review/SKILL.md. From that point on, /review is a slash command and Claude will also load it automatically when you ask something like "can you review this?"

A few things to notice in this example:

  • allowed-tools: Read, Grep, Glob pre-authorizes read-only access so Claude doesn't prompt per tool use. No Bash needed since this skill only reads files.
  • No disable-model-invocation — a review skill is safe to auto-load. If you asked "can you review this PR?" Claude picks it up without you typing /review.
  • $ARGUMENTS is substituted at invocation time. /review src/auth/ passes the path. If you type /review with no argument, the fallback in the instruction body kicks in.
  • The body is instructions, not documentation — imperative, specific, structured. Claude follows it step by step.

1. What is a skill?

A skill is a reusable unit of instructions that Claude loads on demand. You package a workflow, a checklist, a style guide, or a background reference into a directory with a SKILL.md file, and Claude makes it available as a slash command: /your-skill-name.

There are two ways a skill gets invoked:

  • You invoke it with /skill-name, optionally with arguments: /migrate-component SearchBar React Vue
  • Claude invokes it automatically when your request matches the skill's description

The second mode is where skills become interesting. Claude reads each skill's description at session start and decides, mid-conversation, whether to load the full skill when you ask something related. A skill described as "Deploy the application to staging or production" will load when you type "deploy this" — you don't have to remember the command name.

Claude Code ships with two built-in skills:

  • /simplify — reviews recent changes and auto-fixes code quality, reuse, and efficiency issues
  • /batch <instruction> — orchestrates large-scale codebase changes in parallel across isolated git worktrees

Everything else you write yourself.


2. Where skill files live

Skills are discovered from four scope levels. Higher-priority scopes override lower ones.

ScopeLocationWho it applies to
Organization (managed policy)Configured by your org adminEveryone in the org
Personal~/.claude/skills/<skill-name>/SKILL.mdYou, across all projects
Project.claude/skills/<skill-name>/SKILL.mdEveryone working in this repo
Plugin<plugin-dir>/skills/<skill-name>/SKILL.mdWhere the plugin is installed

The directory name becomes the default skill name. So .claude/skills/deploy/SKILL.md creates a /deploy command unless you override the name in frontmatter.

Monorepo support

Claude automatically discovers .claude/skills/ directories in subdirectories when you work with files there:

monorepo/
├── .claude/skills/
│   └── shared-deploy/
│       └── SKILL.md
└── packages/
    └── api/
        └── .claude/skills/
            └── api-tests/
                └── SKILL.md

When you edit files under packages/api/, Claude discovers both shared-deploy and api-tests. This is useful for giving different packages their own workflows without cluttering the root.

Naming conflicts

When the same skill name appears at multiple scopes, the highest-priority scope wins. For plugin skills, the namespace is plugin-name:skill-name to avoid conflicts with your own skills.


3. The anatomy of a SKILL.md file

Every skill lives in its own directory and needs exactly one file: SKILL.md. YAML frontmatter at the top, markdown instructions below — same format you already know from CLAUDE.md:

---
name: fix-issue
description: Fix a specific GitHub issue by number
argument-hint: "[issue-number]"
disable-model-invocation: true
allowed-tools: Read, Grep, Bash(gh *)
---

Fix GitHub issue $ARGUMENTS.

Steps:
1. Run `gh issue view $ARGUMENTS` to read the issue
2. Find the relevant code
3. Implement the fix with tests
4. Run `gh issue comment $ARGUMENTS -b "Fixed in..."` when done

The frontmatter controls behavior. The body is what Claude actually reads and follows when the skill runs.

Anything in the body that looks like $ARGUMENTS, $0, $1 gets substituted with what you type at invocation time — covered in section 7.


4. Frontmatter field reference

name

name: my-skill-name

The slash command name. Must be lowercase, letters/numbers/hyphens only, max 64 characters. Defaults to the directory name if omitted. Avoid generic names like run or check — be specific enough that someone reading the command name knows what it does.

description

description: Deploy the application to staging or production after running tests

The most important field in the file. Claude reads it at session start to decide whether to load the full skill mid-conversation. A weak description means missed invocations or false triggers. How to write it well is covered in section 8.

If you omit it, Claude falls back to the first paragraph of the body — which is almost never specific enough to be a good trigger.

argument-hint

argument-hint: "[issue-number]"
argument-hint: "[branch-name] [target-env]"
argument-hint: "[filename] [output-format]"

Shown in the autocomplete menu when you type /skill-name. Purely informational — it doesn't affect how arguments are parsed. Still worth setting because it makes the skill discoverable.

disable-model-invocation

disable-model-invocation: true

When true, Claude never loads this skill automatically. Only you can trigger it with /skill-name. Use this for skills with side effects — deployments, commits, messages sent to external services. You don't want Claude deploying your app because you typed "ship this."

Defaults to false.

user-invocable

user-invocable: false

When false, the skill is hidden from the / command menu. Claude can still load it automatically based on the description, but you can't invoke it directly. Use this for background knowledge that Claude should consult silently — legacy system context, deprecated API notes, internal coding standards for a specific module.

Defaults to true.

allowed-tools

allowed-tools: Read, Grep, Glob
allowed-tools: Read, Bash(npm *), Bash(gh issue *)

Tools Claude can use without a per-use permission prompt while the skill is active. Accepts tool names and optionally scoped patterns for Bash. This is how you pre-authorize the specific shell commands a skill needs without opening up all of Bash.

model

model: claude-haiku-4-5-20251001

Which Claude model to use when this skill is active. Useful for lightweight informational skills where you want faster responses and lower cost. Heavier skills that need careful reasoning should omit this or use a more capable model.

context

context: fork

When set to fork, the skill runs in an isolated subagent instead of inline in your current session. Useful for long-running or destructive operations where you don't want the skill's actions mixed into your current context. The subagent runs, returns a result, and your session continues unaffected.

agent

context: fork
agent: Plan

Which subagent type to use when context: fork is set. Options include Explore, Plan, general-purpose. Only meaningful alongside context: fork.


5. How invocation works

Manual invocation

/deploy
/fix-issue 1234
/migrate-component SearchBar React Vue

You type the command, Claude loads the full SKILL.md, substitutes any $ARGUMENTS placeholders, and follows the instructions. This always works regardless of any frontmatter settings.

Automatic invocation

At session start, Claude loads all skill descriptions (not the full skill content — just the description field). When you send a message, Claude scans those descriptions and loads the full content of any skill that's relevant to your request.

This means:

  • Asking "can you deploy this?" loads a skill described as "Deploy the application to staging or production"
  • Asking "review this PR" loads a skill described as "Review pull requests for quality and security issues"
  • The match is semantic, not keyword-based

Control this behavior:

GoalSetting
Prevent auto-load for side-effect operationsdisable-model-invocation: true
Background knowledge Claude uses but you never invokeuser-invocable: false
A helper Claude should auto-load AND you can invokeDefault (both omitted)

Context budget

Skill descriptions are loaded at session start within a character budget (default ~16,000 characters across all skill descriptions). If you have many skills, some may be excluded when the budget is exceeded. Run /context to check for warnings. You can raise the budget with the SLASH_COMMAND_TOOL_CHAR_BUDGET environment variable.


6. Skills vs CLAUDE.md: when to use which

Both give Claude instructions. The difference is cost and timing.

CLAUDE.mdSkills
LoadsEvery session, in full, alwaysDescriptions only at start; full content on invocation
Context costConstant — always occupying contextProportional to use
Best forCoding standards, project conventions, build commands, testing patternsTask-specific workflows, one-off operations, on-demand reference
Side-effect operationsNot applicableUse disable-model-invocation: true
Supporting filesNo — one file onlyYes — templates, examples, scripts in same directory

If it applies to almost every task in the project, put it in CLAUDE.md. Things like "we use tabs not spaces", "all API responses follow this shape", "run npm test before committing" — these belong in CLAUDE.md because Claude needs them in scope constantly. They're not special workflows, they're just how this project works.

If it's a specific workflow you run occasionally, make it a skill. A deploy process, a PR description generator, a migration script runner — these are tasks you do sometimes, not rules that apply everywhere. As a skill, the full instructions only load when you actually invoke it. The rest of the time it costs nothing.

If it's reference material Claude should consult silently, make it a skill with user-invocable: false. Say you have documentation about a legacy payment module that's tricky to work with. You don't want to manually invoke it every time you touch that code, but you also don't want its 200 lines of context loaded every session. With user-invocable: false, Claude reads the description at startup (a few words), recognises when it's relevant mid-conversation, and loads the full content only then — without you doing anything. You can't type /legacy-payments to invoke it; it just appears in the background when Claude decides it's useful.

If it has side effects, make it a skill with disable-model-invocation: true. This is the opposite: Claude will never load it on its own. The only way it runs is if you explicitly type /deploy or /send-report. This exists because you don't want Claude triggering a production deploy because you typed "can you ship this?" in passing.

Don't put things in CLAUDE.md to make them feel "more official." Every line in CLAUDE.md occupies context on every single session, whether it's relevant or not. Skills are free until you actually use them.


7. Passing arguments

$ARGUMENTS — all arguments as a string

---
name: fix-issue
argument-hint: "[issue-number]"
---

Fix GitHub issue $ARGUMENTS.

/fix-issue 1234 → Claude sees "Fix GitHub issue 1234."

$0, $1, $2 — positional arguments

---
name: migrate-component
argument-hint: "[component-name] [from-framework] [to-framework]"
---

Migrate the $0 component from $1 to $2.

/migrate-component SearchBar React Vue → Claude sees "Migrate the SearchBar component from React to Vue."

$ARGUMENTS[0], $ARGUMENTS[1] are equivalent — pick whichever reads more clearly in context.

Fallback behavior

If your skill body doesn't include any $ARGUMENTS reference, Claude Code appends it automatically:

ARGUMENTS: <whatever you typed>

Claude still sees it — just at the end of the instructions. This is fine for simple cases but explicit substitution is clearer.

Optional arguments

There's no built-in optional argument syntax. The usual approach is to document the default in the skill body:

Generate a changelog for $ARGUMENTS (default: last 10 commits if no range given).

8. Writing descriptions that work

The description is the only thing Claude reads when deciding whether to load your skill. Get it wrong and you end up in one of two bad places: Claude never loads the skill automatically and you always have to type the command, or it loads too broadly and clutters conversations where it doesn't belong.

What makes a good description

Be specific about the action and context:

# Too vague
description: Deployment helper

# Good
description: Deploy the application to staging or production. Runs the test suite, builds, and pushes to the configured cloud environment.

Match the language your requests actually use:

If you say "ship this" or "push to prod," include those phrases or synonyms in the description. If you're formal, write formally. Claude matches semantically but you're helping it calibrate.

State what it does, not what it is:

# What it is (weak)
description: A code review tool

# What it does (better)
description: Review a pull request or code change for security issues, performance problems, and style violations

Include domain-specific terms for niche skills:

description: Run the payment reconciliation pipeline for a given date range. Use when investigating billing discrepancies or running end-of-month reports.

The "use when" clause is useful for skills that might otherwise be confused with similar ones.

Descriptions for user-invocable: false skills

When a skill is background knowledge only, write the description from the perspective of what triggers Claude to consult it:

---
user-invocable: false
description: Context about the legacy payment system — Stripe API v2010, custom crypto in src/payment/crypto.js. Load when questions involve payment processing or billing code.
---

The "load when" framing tells Claude exactly when this reference is useful.


9. allowed-tools and permissions

Every tool use prompts for permission by default. For a skill you run once, that's fine. For one you trigger ten times a day, it's noise. allowed-tools pre-authorizes specific tools so Claude doesn't ask mid-task.

Basic usage

allowed-tools: Read, Grep, Glob

Grant read-only access. Claude can read files and search without prompts.

Scoped Bash access

allowed-tools: Read, Bash(npm test), Bash(npm run build)

Only the specific commands listed. Claude cannot run arbitrary shell commands just because Bash is partially allowed.

allowed-tools: Read, Bash(gh issue *), Bash(gh pr *)

Wildcard patterns work. This allows any gh issue or gh pr subcommand.

What to grant

A good default: start with the minimum the skill needs and add more only when you hit permission prompts. Read-heavy skills rarely need Bash at all. Deployment skills need specific deploy commands, not Bash(*).

Permission rules in settings

You can control which skills Claude can invoke from your settings:

{
  "permissions": {
    "allow": ["Skill(review-code)", "Skill(deploy *)"],
    "deny": ["Skill(send-slack-*)"]
  }
}

This is useful in shared environments where you want to restrict which automated operations are allowed.


10. Practical examples

Code review

---
name: review
description: Review a pull request or recent code changes for security issues, performance problems, and style violations
allowed-tools: Read, Grep, Glob
---

Review the code in $ARGUMENTS (or the most recent changes if no argument given).

Check for:

**Security**
- SQL injection, command injection, path traversal
- Secrets or credentials in code
- Missing input validation at system boundaries

**Performance**
- N+1 queries or loops with nested database calls
- Missing indexes on frequently queried columns
- Allocations in hot paths

**Correctness**
- Error paths that silently swallow exceptions
- Race conditions in concurrent code
- Off-by-one errors in slice/index operations

**Style**
- Does it follow the patterns in the surrounding code?
- Is it more complex than it needs to be?

Give specific line references. Separate must-fix from suggestions.

Commit with conventional format

---
name: commit
description: Stage changes and create a conventional commit with a well-formed message
disable-model-invocation: true
allowed-tools: Bash(git *)
---

Create a conventional commit for the current changes.

1. Run `git diff --staged` and `git status` to understand what's changing
2. Write a commit message in the format: `type(scope): description`
   - Types: feat, fix, docs, refactor, test, chore
   - Scope: the module or area affected (optional but helpful)
   - Description: imperative mood, under 72 characters
3. If there are unstaged changes that belong with this commit, stage them
4. Run `git commit -m "..."` with the message

Do not use `--no-verify`. Do not amend previous commits.

GitHub issue workflow

---
name: gh-issue
description: Work on a specific GitHub issue — read it, understand the context, implement a fix
argument-hint: "[issue-number]"
disable-model-invocation: true
allowed-tools: Read, Grep, Glob, Bash(gh issue *), Bash(gh pr *)
---

Work on GitHub issue #$ARGUMENTS.

1. Run `gh issue view $ARGUMENTS` to read the full issue and comments
2. Understand what's being asked before touching any code
3. Find the relevant code — search for related function names, file paths mentioned in the issue
4. Implement the fix; keep the change minimal and focused
5. Add a test that would have caught the bug if the issue is a bug report
6. When done, run `gh issue comment $ARGUMENTS -b "Implemented in [branch/commit]"`

Background context: legacy system

---
name: legacy-payments
description: Context about the legacy payment integration — load when questions involve billing, invoicing, or the payment module
user-invocable: false
---

The payment module (`src/payments/`) uses a deprecated Stripe API from 2019.

Key facts:
- Uses `stripe.charges.create()` not `stripe.paymentIntents.create()`
- Webhook verification is in `src/payments/webhooks.js` — custom HMAC, not Stripe's SDK method
- The `PaymentRecord` model has a `legacy_charge_id` field that maps to old Stripe charge IDs
- Do not refactor this module without reading `docs/payment-migration-plan.md` first

For new payment flows, use the `src/payments/v2/` module instead.

API documentation generator

---
name: gen-docs
description: Generate or update API documentation from code
argument-hint: "[path-to-handlers] [output-file]"
allowed-tools: Read, Glob, Grep, Bash(npx *)
---

Generate API documentation for the handlers in $0.

Output file: $1 (default: docs/api.md if not given)

Steps:
1. Find all route handlers in $0
2. For each route, extract: method, path, request body shape, response shape, error codes
3. Look for JSDoc comments and include them verbatim
4. Write a markdown file with one section per endpoint
5. Group endpoints by resource (users, orders, etc.)
6. Include example request/response pairs where the code makes the shape clear

Keep the descriptions concise. Don't guess at behavior — only document what the code actually does.

Changelog from git log

---
name: changelog
description: Generate a changelog from git history for a given version range or time period
argument-hint: "[from-ref]..[to-ref]  or  [number-of-commits]"
disable-model-invocation: true
allowed-tools: Bash(git log *), Bash(git show *)
---

Generate a changelog for: $ARGUMENTS (default: last 20 commits if no range given)

1. Run `git log --oneline $ARGUMENTS` to get the commit list
2. Group commits by type: Features, Bug fixes, Performance, Internal/chore
3. For each user-facing change, write one line: what changed and why it matters to users
4. Skip pure internal commits (dependency bumps, CI config, code formatting)
5. Format as markdown with a `## [version] - [date]` header

Keep each entry to one line. Use plain language — no jargon.

11. Supporting files and directory layout

SKILL.md doesn't have to carry everything. The skill directory can hold supporting files — checklists, templates, reference docs, scripts — and Claude reads them on demand as it works through the instructions:

.claude/skills/
└── deploy/
    ├── SKILL.md          # Main instructions
    ├── pre-flight.md     # Pre-deployment checklist (linked from SKILL.md)
    ├── rollback.md       # Rollback steps (linked from SKILL.md)
    └── scripts/
        └── health-check.sh

Reference them from SKILL.md:

Before deploying, follow the checklist in [pre-flight.md](pre-flight.md).
If the deploy fails, see [rollback.md](rollback.md).

Claude reads them on demand as it works through the steps. SKILL.md stays short — just the overview and the structure — while the detail lives where it belongs.

When to use supporting files

  • Pre-flight checklists — long enough that embedding them in SKILL.md would make it hard to read
  • Reference material — API docs, schema definitions, architecture notes
  • Templates — output formats, commit message templates, PR description formats
  • Scripts — shell scripts the skill calls via Bash

Keep SKILL.md under 500 lines. If it's longer, it's doing too many things or the detail belongs in a supporting file.


12. Common problems and how to debug them

Skill never loads automatically

  1. Check the description — is it specific enough? Does it match what you actually say?
  2. Run /context — verify the skill is loaded and its description isn't truncated
  3. Check whether disable-model-invocation: true is set by accident
  4. Try invoking directly with /skill-name to confirm the skill itself works

Skill loads too often / loads on unrelated requests

  1. Make the description more specific — add "use when..." or narrow the action described
  2. Add disable-model-invocation: true and invoke manually

Skill not found at all

  1. Confirm the directory has exactly SKILL.md (not skill.md or skills.md)
  2. Check that the directory is at the right path: .claude/skills/<name>/SKILL.md
  3. Restart the session — Claude discovers skills at session start

Arguments not substituting

  1. Check the placeholder is exactly $ARGUMENTS, $0, $1, etc. — case-sensitive
  2. Check there's no extra space or character around the $
  3. If your text contains a literal $, escape it: \$

Permission prompts despite allowed-tools

  1. Check the exact tool name spelling — Read not ReadFile, Bash not bash
  2. For scoped Bash: check the pattern matches — Bash(npm *) allows npm test but not npx jest
  3. The global deny list overrides allowed-tools — check your settings

13. Before you ship a skill

Before committing a skill to your project repo (.claude/skills/) for teammates to use, run through these.

Description — Is it specific enough that a teammate reading it immediately knows what the skill does and when to reach for it? Does it match the language people actually use — "ship this", "review this PR" — not just a formal label? If it could be confused with another skill, does it have a "use when..." clause?

Invocation control — Anything with side effects (deploys, commits, messages sent outside the repo) should have disable-model-invocation: true. You don't want Claude triggering a deploy because someone said "push this." Background reference that should never be a manual command gets user-invocable: false.

Arguments — If the skill takes arguments, argument-hint should be set so the autocomplete is useful. The placeholders ($0, $1, $ARGUMENTS) should appear in the body where the value actually matters. If arguments are optional, the fallback behavior should be documented in plain text.

Permissions — allowed-tools should list the minimum the skill actually needs. Scope Bash to specific commands rather than opening up Bash(*). A read-only skill usually doesn't need Bash at all.

Content — Keep SKILL.md under 500 lines. Instructions should be imperative and direct: "run npm test", not "you might want to run the tests". No hardcoded paths, tokens, or environment-specific values — those belong in environment variables or config files, not in the skill body.

Testing — Invoke it manually once with /skill-name to confirm it follows the instructions as written. Then test automatic invocation by describing the use case in plain English and seeing if Claude picks it up without being prompted.


Skills become more valuable the more precisely they're written. A vague skill that sort-of works on most requests is less useful than a specific skill that works exactly right on the tasks you actually repeat. Start with one workflow you do every day, write it once, and build from there.

The format is simple. The leverage is in the description and the instructions.

I build and scale reliable production systems. Open to full-time and freelance work with U.S.-based teams that value ownership and execution.

Got something in mind?

Book a Discovery Call