Claude · 18 · Part 9 · Hands-On Skills

Your First Skill

A skill is a folder with one Markdown file that teaches Claude a repeatable job. This chapter builds three of them on the bookshelf project, step by step, and runs each one for real: one that explains an endpoint in a read-only subagent, one that turns a commit range into release notes using live git output, and one that Claude picks up on its own. It ends with a troubleshooting section that includes a result I did not expect.

3
Skills built
5
Real runs
2
Broken skills tested
Information current as of: 2026-10-31. Field names and behaviour are from Anthropic's skills page read on 2026-10-31 (the last part of that page was not read). Runs used Claude Code 2.1.314 and model claude-sonnet-5-5.
01
Concept

What a Skill Is, and What It Is Not

A skill is a directory containing SKILL.md: optional YAML frontmatter, then instructions in Markdown. Claude always sees the short description of every skill; the full body loads only when the skill is invoked, by you typing /name or by Claude deciding it fits (Source 1). That makes skills cheap to have many of.

Three levels of loading
1 Descriptionalways in contextdecides if it is used 2 SKILL.md bodyloads on invocationthe instructions 3 Other filesread when body says sochecklists, scripts the later the level, the later it costs context
Use…when you want…
CLAUDE.mdFacts Claude should know in every session (build command, conventions)
A skillA procedure you repeat, loaded only when needed
A subagentA separate context with its own tools (a skill can run in one with context: fork)
A hookA rule that must run every time, enforced by the harness, not by Claude's judgement

Older custom commands in .claude/commands/ still work; skills are the current form and add supporting files and the frontmatter below (Source 1).

02
Reference

Anatomy, Locations and Frontmatter

# project skill, shared through git .claude/skills/release-notes/SKILL.md # personal skill, all your projects ~/.claude/skills/release-notes/SKILL.md # a skill may carry other files, referenced from SKILL.md .claude/skills/review-diff/checklist.md

Where a skill lives decides who gets it: enterprise (managed settings), personal, project, nested subdirectories, extra directories, and plugins. If names clash, enterprise beats personal and personal beats project (Source 1). The frontmatter fields, all optional:

FieldWhat it does
nameThe /name. Defaults to the directory name
descriptionWhat Claude reads to decide whether to load it. If omitted, the first body line is used. Combined with when_to_use it is cut at 1,536 characters, so put the main use first
argument-hintAutocomplete hint such as [commit-range]
argumentsNamed positional arguments for $name substitution
disable-model-invocationtrue stops Claude loading it automatically; only /name runs it
user-invocablefalse hides it from the / menu; only Claude can use it
allowed-toolsPre-approves tools for the turn the skill runs in. It does not restrict other tools
disallowed-toolsRemoves tools while the skill is active
model, effortOverride for the current turn only
context: fork and agentRun in a subagent; agent picks its type (default general-purpose)
pathsGlobs: auto-load only when matching files are in play
hooks, shellHooks registered while the skill is used; shell for injected commands

In the body, $ARGUMENTS is everything typed after the name, and $0, $1 are positional. A line starting with ! followed by a backticked command runs that command first and replaces the line with its output before Claude sees the skill. Edits to existing skill files are picked up live; a brand-new top-level skills directory needs /reload-skills (Source 1).

Outside Claude Code. The same page says claude.ai uploads and the Skills API accept only some fields (name, description, license, compatibility, metadata, allowed-tools) and reject others. A skill written for Claude Code may not upload unchanged.
03
Build Along · Skill 1

explain-endpoint: a Read-Only Explainer

The job: "How does this endpoint work, from controller to database?" You ask it often, and you want it to never edit anything. Two decisions shape the file: context: fork with agent: Explore runs it in a separate read-only subagent so its file reading does not clutter your conversation, and the body says what to report.

Steps: create .claude/skills/explain-endpoint/, write SKILL.md, then type /explain-endpoint GET /api/books/{id}.

.claude/skills/explain-endpoint/SKILL.md
--- name: explain-endpoint description: Explains how one REST endpoint of bookshelf works, from controller to database, without changing any file. Use when asked how a given endpoint behaves. argument-hint: "[METHOD /path]" context: fork agent: Explore --- Explain how `$ARGUMENTS` is handled in this project. 1. Find the controller method that maps it. 2. Follow the call into the service and repository layers. 3. Name every class and the line where validation, mapping and persistence happen. 4. Report the SQL statement count if a test already measures it. Read only. Do not edit files.

The run below is the real thing. The command was sent through claude -p with the project's skills loaded and tools limited to Read, Grep, Glob and Skill.

Real sample: what Claude answered (Checked)
Prompt as sent
/explain-endpoint GET /api/books/{id}
Actual output · executed 2026-10-31 · claude-sonnet-5-5 · Claude Code 2.1.314 · 0 turns · 38.8s · est. $0.099 · project commit f23d2f5
`GET /api/books/{id}` is handled by `BookController.get`, which calls `BookService.findById`, which calls `BookRepository.findById`. No existing test measures its SQL statement count. Bash was denied in this session, so I used Read and Glob only. I didn't read `BookNotFoundException.java`, `Author.java` or `BookControllerTest.java`. Their line numbers are not given below, and the Author lookup is inferred from `Book.java`. All files are under `<project>/src/main/java/com/example/bookshelf/`. **Request flow** 1. **`BookController.java`** - Line 16 sets the class prefix `@RequestMapping("/api/books")`. - Line 35 is `@GetMapping("/{id}")`, and line 36 is `public BookDto get(@PathVariable long id)`. - Line 37 calls `service.findById(id)`. 2. **`BookService.java`** - Line 28 is `@Transactional(readOnly = true)`, and line 29 is `findById(long id)`. - Line 30 does the persistence call, the mapping and the not-found handling in one expression: `books.findById(id).map(BookDto::from).orElseThrow(() -> new BookNotFoundException(id))`. 3. **`BookRepository.java`** - Line 7 declares `BookRepository extends JpaRepository<Book, Long>`. - `findById` is the inherited Spring Data method, so the repository has no custom code for it. - The `@EntityGraph(attributePaths = "author")` annotations on lines 9 and 12 apply only to `findAllByOrderByTitle` and `findByTitleContainingIgnoreCaseOrderByTitle`. They do not apply to `findById`. **Where each step happens** - **Validation:** There is none in the code beyond Spring's conversion of `{id}` to `long`. A non-numeric id fails in Spring MVC and returns 400. `@Valid` is used only on `create`, at `BookController.java:42`. - **Persistence:** `BookRepository.findById` loads the `Book` entity from `Book.java`. `Book.java:22-23` maps `author` as `@ManyToOne(fetch = FetchType.LAZY)`. - **Mapping:** `BookDto.java:5-7` has `static BookDto from(Book)`, which builds the record `BookDto(id, title, author, year)`. It calls `book.getAuthor().getName()`. - **Error path:** `BookNotFoundException` is thrown at `BookService.java:30`. `ApiExceptionHandler.java:11-14` is a `@RestControllerAdvice` that turns it into a `ProblemDetail` with status 404. **SQL statement count** - `BookQueryCountTest.java` is the only test that counts statements. It uses Hibernate `getPrepareStatementCount()` and covers `service.findAll()` only, asserting exactly 1. - Nothing measures `findById`. - Reading the code, I would expect 2 statements. `findById` has no entity graph, so the author is a lazy proxy. `BookDto.from` then calls `getAuthor().getName()` inside the read-only transaction, which should trigger a second SELECT on the author. - That count is inferred, not measured. A hit in Hibernate's first-level cache would avoid the second statement.
Author's check: Checked. All file and line references were checked (BookController 16/35/36/37/42, BookService 28-30, BookRepository 7/9/12, Book 22-23, BookDto 5-7) and are correct. It said Bash was denied, listed three files it had not read, and marked the Author lookup as inferred.
Notice the denial. The answer opens by saying Bash was denied. The run allowed no shell, the subagent tried one anyway, and it told you so rather than hiding it. That honesty is the reason to read the first lines of a skill's output.
04
Build Along · Skill 2

release-notes: Arguments and Live Command Output

The job: turn git log for a range into release notes without inventing anything. Three things are new: $ARGUMENTS carries the commit range, the ! line injects real log output before Claude reads the file, and disable-model-invocation: true means it runs only when you ask, because writing notes is a deliberate act.

.claude/skills/release-notes/SKILL.md
--- name: release-notes description: Drafts user-facing release notes from a git commit range. Use only when the user asks for release notes or a changelog entry for a range such as HEAD~5..HEAD. argument-hint: "[commit-range]" disable-model-invocation: true allowed-tools: Bash(git log *) --- ## Commits in the range !`git log --no-merges --pretty='%h %s' $ARGUMENTS` ## Task Write release notes for the commits above. - Group into Added, Changed, Fixed. Leave a group out if it is empty. - One line per user-visible change, in plain words. Skip commits that only touch tests, CI or build files. - Only describe what the commit subjects support. If a subject is too vague to describe, list it under "Needs a human" instead of guessing. - Do not claim performance or behaviour changes that the subjects do not state.

allowed-tools: Bash(git log *) matters. Injected commands are checked against permission rules, and one that is not allowed aborts the whole invocation (Source 1). The pre-approval is narrow on purpose: git log only, for this turn only.

Real sample: what Claude answered (Checked)
Prompt as sent
/release-notes 7672f6d..23b68e9
Actual output · executed 2026-10-31 · claude-sonnet-5-5 · Claude Code 2.1.314 · 1 turns · 21.0s · est. $0.044 · project commit f23d2f5
## Release notes (7672f6d..23b68e9) ### Added - Title search endpoint for finding books by title. - Stats endpoint. - Database persistence for books using JPA. - Validation of book input. - Requests for a book that doesn't exist now return a 404. ### Fixed - Fixed the N+1 query problem when listing books. ### Needs a human - 23b68e9 "Merge search and stats; add verification scripts, feature list, CI workflow": the subject doesn't say whether "feature list" is user-facing or just a doc. I left it out. - 27c78fd "Add stats endpoint": the subject doesn't say what the stats cover, so the Added line is generic. Please fill in the details. I skipped a01a832 (Claude Code project setup) because it only touches tooling. I also skipped the verification scripts and CI workflow in the merge commit.
Author's check: Checked. Every entry traces to a commit subject in the range. It put the vague 'Add stats endpoint' under 'Needs a human' instead of inventing detail, as the skill instructs.
The guard clause worked. The skill says "if a subject is too vague, list it under Needs a human". The vague subject "Add stats endpoint" ended up there, instead of an invented description. A skill's value is often in the sentence that tells Claude what not to do.
05
Triggering

Will Claude Pick It Up by Itself?

With disable-model-invocation left off, Claude may load a skill whenever the description fits the request. So the description is the trigger. State what the skill does first, then "Use when…" with the situations. To test it, ask in plain words without naming the skill, and look at the tool calls. This is the real log of that test, using explain-endpoint's description from above:

Prompt as sent
How does the title search endpoint work in this project? Read only.
Actual events · executed 2026-10-31 · session init event removed · long values shortened
#EventContent (shortened)
1Skill call{"skill": "explain-endpoint", "args": "title search endpoint"}
2Answer shown to youBase directory for this skill: <project>/.claude/skills/explain-endpoint Explain how `title search endpoint` is handled in this project. 1. Find the controller method that maps it. 2. Follow the call into the service and repository layers. 3. Name every clas…
3Grep call{"pattern": "title|search", "path": "<project>", "-i": true, "output_mode": "content", "glob": "**/*.java"}
4Bash call{"command": "cd <project>/src; cat -n main/java/com/example/bookshelf/BookController.java main/java/com/example/bookshelf/BookService.java main/java/com/example/bookshelf/BookRepository.java main/java/com/example/bookshelf/BookDto
5Read call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookController.java"}
6Read call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookService.java"}
7Read call{"file_path": "<project>/src/main/java/com/example/bookshelf/BookRepository.java"}
8Read call{"file_path": "<project>/src/test/java/com/example/bookshelf/BookSearchTest.java"}
9Grep call{"pattern": "statement|StatementCount|generate_statistics|getPrepareStatementCount", "path": "<project>", "-i": true, "output_mode": "content"}
10Answer shown to you`GET /api/books/search?title=...` does a case-insensitive "contains" match on title and returns the matches sorted by title. I didn't edit anything. **Call path** 1. **Controller:** `BookController.search(@RequestParam String title)` is mapped at lines 30-33 …

From the same log: 2 turns, 38.0 s, estimated $0.146, model claude-sonnet-5-5.

It triggered, and the first call proves it. Event 1 is the Skill tool with explain-endpoint; the rest is the forked subagent doing the work. The review was good (see below), but one run is one run: with a vaguer description or a differently worded question it may not trigger. The negative case (a question that should not trigger it) was not run for this chapter.
Author's check of this run's answer
Checked. Line references (BookController 30-33, BookService 23-26) are correct, and it marks "search also runs one statement" as inferred because BookQueryCountTest measures findAll only.
06
When It Does Not Work

Troubleshooting, With a Real Experiment

The most useful first check is whether Claude Code sees the skill at all. A session's start-up event lists the skills it loaded. I created two deliberately broken skills and started a one-turn session:

blank-line-demo: frontmatter does not start on line 1 (the file begins with an empty line)
↤ (empty first line) --- name: blank-line-demo description: Demo skill whose frontmatter does not start on line 1. --- Say BLANK.
typo-demo: the key is spelled "descripton"
--- name: typo-demo descripton: Demo skill with a misspelled description key. --- Say TYPO.
Actual result · executed 2026-10-31 · from the session's start-up event
blank-line-demo listed: false typo-demo listed: false release-notes listed: true (the working skill, as a control)

What I expected versus what happened. From the documentation I expected the blank-line skill to lose its frontmatter and the typo skill to load with the unknown key ignored. In this run neither appeared in the list at all. I did not establish why; one possibility is that a skill without a usable description is not listed, but that is a guess. What the experiment shows is the diagnostic: if a skill is not in the list, fix the file before blaming the description.

SymptomCheck
Not in the /skills listIs --- the very first line? Is the YAML valid? Is the key spelled exactly? Is the name free of odd characters? claude --debug and search the log for [names] (Source 1)
Listed but Claude never uses itIs disable-model-invocation set? Rewrite the description: main use first, concrete situations
You renamed a skill and nothing changedRenames in unwatched locations need a restart; a new top-level skills folder needs /reload-skills
An injected command aborts the skillAdd the narrowest allowed-tools pattern; a failing command aborts the invocation, so append || true to commands that may exit non-zero
Claude stops following it mid-sessionThe page has a section on this that I did not read; re-invoke the skill, and keep the body short and imperative
Trust and hooks. These runs printed "Ignoring 3 permissions.allow entries from .claude/settings.json: this workspace has not been trusted". In non-interactive mode a project's own permission allow-list is ignored until you have accepted the trust dialog once. This is also why the skills here carry their own narrow allowed-tools.
07
Summary

Key Takeaways, Exercises and Sources

∑ Your first skill: what to remember
  • A skill is a folder with SKILL.md. The description is always loaded; the body loads when used.
  • The description is the trigger. Main use first, then "Use when…".
  • Use context: fork with a read-only agent for explorers that should not touch files or clutter your chat.
  • Inject facts with ! lines, and give them the narrowest allowed-tools.
  • Tell the skill what not to do. The best line in release-notes is the "Needs a human" rule.
  • If it is not listed, fix the file first.
Exercises

Exercise 1 Beginner

Write a skill called explain-class that explains one class in a repository you know. Use context: fork and a read-only agent. Run it with /explain-class Name.

Learning outcome: create, place and invoke a skill.

Exercise 2 Intermediate

Make a skill that injects git status --short with a ! line. First leave out allowed-tools and see what happens, then add the narrowest pattern that works.

Learning outcome: understand permissions on injected commands.

Exercise 3 Advanced

Write three prompts that should trigger one of your skills and three that should not. Run all six with --output-format stream-json --verbose and count the Skill tool calls. Rewrite the description until all six behave.

Learning outcome: test a skill's trigger like code.

Sources (read on 2026-10-31; paraphrased)
  1. Anthropic, Extend Claude with skills — locations, frontmatter, arguments, dynamic injection, supporting files, listing limits, troubleshooting. The last roughly 19,000 characters of the page were not read.
  2. Anthropic, Subagents — the Explore agent used with context: fork.

The runs SK-01, SK-02, SK-04 and SK-05 were executed on 2026-10-31 against the bookshelf project (tools/capture.sh and tools/stream_once.sh); project skills were loaded with --setting-sources user,project, used only on a repository I wrote. The skill files are in the examples repository under skills/. The cause of the unlisted broken skills was not established.