Unmarkdown
AI Tools

Building Custom Skills for Claude Code: A Practical Guide

Updated Mar 18, 2026 · 13 min read

I noticed I was typing the same prompt variations across dozens of sessions. "Review this PR, check for security issues, summarize the changes." "Write Vitest tests for this file, cover edge cases, use the existing test patterns." "Read the last 10 commits and write a changelog entry."

Each time, I'd tweak the wording slightly. Each time, Claude would interpret the prompt a little differently. The output quality varied because the input quality varied. I was doing context engineering by hand, over and over, and getting inconsistent results.

Claude Code custom skills fixed that. A skill is a reusable prompt template stored as a markdown file. You write it once, invoke it with a slash command, and get consistent results every time. The prompt engineering happens once; the execution happens forever.

Here's how to build them, with four real skills I use in my workflow.

What Claude Code skills are

Skills are markdown files that live in your project's .claude/ directory. Each skill has a SKILL.md file containing YAML frontmatter (name, description, trigger rules) and a markdown body with instructions for Claude.

There are two locations for skill files:

  • .claude/commands/: The original location. Still works. Each markdown file in this directory becomes a slash command, where the filename (minus the extension) is the command name.
  • .claude/skills/: The newer, recommended location. Each skill gets its own subdirectory with a SKILL.md file. This structure supports more complex skills with additional files (examples, templates, reference data).

The practical difference: commands/ is simpler for single-file skills. skills/ is better when a skill needs supporting files or when you want to organize skills into categories.

For the skills in this post, I'll use the skills/ directory structure since it's the recommended approach and scales better.

Skill 1: /review-pr

This was the first skill I built. I review pull requests constantly, and my review process follows the same pattern every time: read the diff, check for common issues, summarize the changes, flag anything concerning. That's a perfect candidate for a skill.

Create the file at .claude/skills/review-pr/SKILL.md:

---
name: review-pr
description: Review a pull request for code quality, security, and correctness
---

# Review Pull Request

Review pull request #$1 thoroughly.

## Steps

1. Run `gh pr view $1 --json title,body,additions,deletions,changedFiles` to get PR metadata.
2. Run `gh pr diff $1` to get the full diff.
3. Analyze the changes for:
   - **Correctness**: Logic errors, off-by-one mistakes, missing null checks, unhandled edge cases.
   - **Security**: Hardcoded secrets, SQL injection, XSS vectors, missing auth checks, exposed API keys.
   - **Performance**: N+1 queries, unnecessary re-renders, missing indexes, unbounded loops.
   - **Style**: Naming conventions, dead code, overly complex abstractions, inconsistent patterns.
4. Check if tests are included. If the PR changes logic but adds no tests, flag it.
5. Check if the PR description explains the "why," not just the "what."

## Output Format

Structure your review as:

### Summary
One paragraph describing what this PR does.

### Changes
Bulleted list of significant changes.

### Issues Found
For each issue:
- **File**: path/to/file.ts (line X)
- **Severity**: Critical / Warning / Nit
- **Description**: What's wrong and why it matters
- **Suggestion**: How to fix it

### Verdict
APPROVE, REQUEST_CHANGES, or COMMENT, with a one-sentence justification.

If no issues are found, say so. Do not invent problems.

How to invoke it:

/review-pr 42

The $1 in the skill body gets replaced with the first argument you pass. So this becomes a review of PR #42.

Example output (abbreviated):

### Summary
Adds rate limiting to the AI editing endpoints using a sliding window
counter in Redis.

### Changes
- New middleware in src/lib/ai/rate-limit.ts
- Applied to /api/ai/edit and /api/ai/suggest routes
- Window: 10 requests per minute per user
- Returns 429 with Retry-After header

### Issues Found
- **File**: src/lib/ai/rate-limit.ts (line 23)
- **Severity**: Warning
- **Description**: The Redis key doesn't include the API version.
  If you version the API later, rate limits will be shared across versions.
- **Suggestion**: Include the route path in the key:
  `ratelimit:${userId}:${route}:${window}`

### Verdict
APPROVE. The rate limiting logic is correct and the implementation
is clean. The key naming issue is minor and can be addressed later.

Tips for improvement. Add a step that checks if CI passed: gh pr checks $1. You can also add project-specific review criteria. In my case, I added "Check that all in-app buttons follow the standard Tailwind classes from CLAUDE.md" because button consistency is something I care about.

Skill 2: /write-tests

Writing tests is the task where I got the most inconsistency from freeform prompting. Sometimes Claude would write Jest tests when my project uses Vitest. Sometimes it would mock everything; other times it would mock nothing. The skill standardizes all of that.

Create .claude/skills/write-tests/SKILL.md:

---
name: write-tests
description: Generate Vitest tests for a given source file
---

# Write Tests

Generate comprehensive tests for the file at `$1`.

## Steps

1. Read the file at `$1` completely.
2. Identify all exported functions, classes, and components.
3. For each export, identify:
   - Normal/happy path behavior
   - Edge cases (empty input, null, undefined, boundary values)
   - Error conditions (invalid input, network failures, missing data)
4. Read existing tests in the `tests/` directory that test similar modules, to match the project's testing patterns and conventions.
5. Write tests using Vitest (`describe`, `it`, `expect`).
6. Run `npm test -- --run [test-file-path]` to verify all tests pass.
7. If any test fails, read the error, fix the test, and re-run.

## Rules

- Use Vitest, not Jest. Import from `vitest`: `import { describe, it, expect, vi } from 'vitest'`
- Place the test file in `tests/` mirroring the source path. Example: `src/lib/engine/convert.ts` becomes `tests/engine/convert.test.ts`.
- Prefer real implementations over mocks. Only mock external services (APIs, databases, file system).
- Each `describe` block groups tests for one function or method.
- Each `it` block tests one behavior. Name it as a sentence: `it('returns empty array when input is null')`.
- Include at least one test per exported function, one edge case test, and one error case test.
- Do not test private/unexported functions directly.

## Output

After writing the tests, report:
- Number of test cases written
- Coverage summary (which functions/methods are covered)
- Any functions that were difficult to test and why

How to invoke it:

/write-tests src/lib/engine/plugins/rehype/rehype-slack.ts

Why the rules section matters. Without explicit rules, Claude makes reasonable but inconsistent choices. One session it uses jest.fn(), the next it uses vi.fn(). One session it creates mocks for everything, the next it calls real functions. The rules section eliminates that variance. Every test file generated by this skill uses the same framework, follows the same naming conventions, and applies the same mocking philosophy.

Tips for improvement. Add a step that checks code coverage: npx vitest run --coverage $1. You can also include a snippet from an existing test file as a "reference style" example. Claude is excellent at matching existing patterns when you show it one.

Skill 3: /generate-docs

This skill reads all files in a directory and generates structured documentation. It's useful for documenting internal libraries, API modules, or complex subsystems.

Create .claude/skills/generate-docs/SKILL.md:

---
name: generate-docs
description: Generate markdown documentation for a module or directory
---

# Generate Documentation

Generate comprehensive documentation for the module at `$1`.

## Steps

1. List all files in the directory at `$1` (and subdirectories, if any).
2. Read each file. For each file, extract:
   - Exported functions, classes, types, and interfaces
   - Function signatures with parameter types and return types
   - JSDoc comments or inline documentation
   - Dependencies (imports from other internal modules)
3. Identify the module's entry point (index.ts, or the main exported file).
4. Trace the public API: what does a consumer of this module actually use?

## Output Format

Write the documentation as a single markdown file with this structure:

### [Module Name]

**Purpose**: One sentence describing what this module does.

**Entry point**: `path/to/index.ts`

### Installation / Setup

How to import and configure the module.

### API Reference

For each exported function/class/type:

#### `functionName(param: Type): ReturnType`

Description of what it does.

**Parameters:**
| Name | Type | Description |
|------|------|-------------|
| param | Type | What it does |

**Returns:** Description of return value.

**Example:**
```typescript
// Usage example

Architecture

How the internal files relate to each other. Which file does what.

Dependencies

What this module depends on (both internal and external).

Common Patterns

Typical usage patterns, with code examples.

Save the documentation to docs/[module-name].md.


**How to invoke it:**

/generate-docs src/lib/engine/plugins/rehype


This reads every rehype plugin file, understands the relationships between them, and outputs structured documentation with API references and usage examples.

**Tips for improvement.** Add a step that checks if documentation already exists and updates it rather than overwriting. You can also add a "Changelog" section that notes what changed since the last documentation generation by comparing against git history.

## Skill 4: /publish-changelog

This is the skill that ties everything together. It reads recent git commits, generates a formatted changelog in markdown, and optionally publishes it through [Unmarkdown's](https://unmarkdown.com) MCP integration.

Create `.claude/skills/publish-changelog/SKILL.md`:

```markdown
---
name: publish-changelog
description: Generate and optionally publish a formatted changelog from recent commits
---

# Publish Changelog

Generate a changelog from recent git history and optionally publish it.

## Steps

1. Run `git log --oneline --since="$1"` to get recent commits. If $1 is not provided, default to "1 week ago".
2. Group commits by category based on their prefixes or content:
   - **Features**: commits with "feat:", "add:", or that introduce new files
   - **Fixes**: commits with "fix:", "bug:", or "patch:"
   - **Improvements**: commits with "improve:", "update:", "enhance:", "refactor:"
   - **Documentation**: commits with "docs:", "doc:", "blog:"
   - **Infrastructure**: commits with "chore:", "ci:", "build:", "deps:"
3. For each commit, write a human-readable description. Transform "fix: template preview overflow on mobile viewports" into "Fixed template preview overflow on mobile viewports."
4. Add a date header and optional version number.

## Output Format

```markdown
# Changelog - [Date Range]

## Features
- Description of feature 1
- Description of feature 2

## Fixes
- Description of fix 1

## Improvements
- Description of improvement 1

## Documentation
- Description of docs change 1

Omit any category that has no entries.

Publishing (Optional)

If the Unmarkdown MCP server is connected, after generating the changelog:

  1. Use the create_document tool to create a new Unmarkdown document with the changelog markdown.
  2. Use the publish_document tool to publish it with a clean template.
  3. Report the published URL.

If the MCP server is not connected, save the changelog to CHANGELOG.md in the project root.


**How to invoke it:**

/publish-changelog 2 weeks ago


Or without arguments for the default one-week window:

/publish-changelog


**The Unmarkdown connection.** The changelog this skill generates is markdown. When you publish it through [Unmarkdown's](https://unmarkdown.com) MCP integration, it goes from raw text to a beautifully formatted, shareable page in seconds. No copy-pasting into Google Docs, no fighting with Confluence formatting, no Notion import quirks. The markdown is the source of truth, and Unmarkdown makes it presentable.

**Tips for improvement.** Add a step that reads the previous changelog and appends rather than overwrites. You can also add commit links: for each entry, include a link to the commit on GitHub using `git log --format="[%h](https://github.com/your-org/your-repo/commit/%H)"`.

## Arguments: $ARGUMENTS, $1, $2

Skills support positional arguments. When you type `/review-pr 42`, the `42` is available as:

- **`$ARGUMENTS`**: The entire argument string ("42")
- **`$1`**: The first space-separated argument ("42")
- **`$2`**: The second argument (empty in this case)

For a skill invoked as `/publish-changelog 2 weeks ago`, the values are:

- `$ARGUMENTS`: "2 weeks ago"
- `$1`: "2"
- `$2`: "weeks"

This is why the `/publish-changelog` skill uses `$1` only when it expects a structured argument, but uses the full argument string in the git log command via `$ARGUMENTS` when you need the complete phrase.

Design your argument interface carefully. If a skill needs a file path, `$1` works perfectly. If it needs a freeform phrase (like a time range), document in the skill description how to pass it.

## Common mistakes

After building a dozen Claude Code skills, I've learned what goes wrong.

**Skills that are too vague.** A skill that says "Review this code and give feedback" will produce different output every time. Claude interprets "feedback" differently based on context. Be specific: what should it check? What format should the output be? What should it ignore?

**Skills that don't specify output format.** Without a format section, Claude might give you a bulleted list, a table, a narrative paragraph, or all three. If you want a specific structure, define it. The `/review-pr` skill's output format section ensures every review has the same sections in the same order.

**Skills without verification steps.** The `/write-tests` skill doesn't just generate tests. It runs them and fixes failures. Without that verification step, you'll get tests that look correct but fail when executed. Always include a "check your work" step.

**Skills that do too much.** A skill that reviews code, writes tests, generates docs, and deploys should be four separate skills. Each skill should do one thing well. Compose them when needed, but keep each one focused.

**Not reading existing patterns.** The `/write-tests` skill includes a step to read existing tests first. This is critical. Without it, Claude generates tests that work but don't match your project's conventions. The tests feel foreign. Reading existing patterns first produces tests that feel like a natural extension of your test suite.

## Composing skills with hooks

Claude Code skills become more powerful when combined with [hooks](/blog/claude-code-hooks-automate-everything). Here's a practical example: the `/write-tests` skill paired with a pre-commit hook.

The hook (in `.claude/settings.json`):

```json
{
  "hooks": [
    {
      "type": "PreToolUse",
      "matcher": "Bash(git commit)",
      "command": "npm test -- --run 2>&1 | tail -5; exit ${PIPESTATUS[0]}"
    }
  ]
}

The workflow:

  1. You invoke /write-tests src/lib/engine/convert.ts
  2. The skill generates tests and verifies they pass
  3. You ask Claude to commit the changes
  4. The pre-commit hook runs the full test suite, catching any regressions
  5. If tests fail, Claude sees the output and fixes the issue before committing

The skill handles creation. The hook handles validation. Neither knows about the other, but together they form a pipeline where tests are always written and always passing before code is committed.

Every skill is context engineering

There's a deeper pattern here. Every skill file is a markdown document that structures the context Claude receives. The frontmatter sets metadata. The steps section defines a reasoning path. The output format section constrains the response. The rules section encodes project-specific knowledge.

This is context engineering in action. You're not just telling Claude what to do. You're shaping the information environment so that Claude consistently produces the output you want. A well-written skill is a reusable piece of context engineering that compounds over time.

Skills also pair naturally with a hub-and-spoke memory system. Your memory files give Claude knowledge about your project. Your skills give Claude structured workflows for acting on that knowledge. The memory is the "what." The skills are the "how."

Getting started with Claude Code skills

You don't need four skills on day one. Start with one. Pick the task you repeat most often, the one where you find yourself typing a paragraph of instructions before Claude does anything useful.

Write that paragraph once, put it in a SKILL.md, and invoke it with a slash command. If the output isn't right, refine the skill file. After a few iterations, you'll have a skill that produces exactly the output you want, every time, in less time than it took you to type the original prompt.

Then build the second one. And the third. Before long, your .claude/skills/ directory becomes a library of your team's best practices, encoded as executable documents that any developer on the team can invoke.

Claude Code custom skills are just markdown. The slash commands are just shortcuts. But the consistency they produce is what makes the difference between using Claude Code as a toy and using it as infrastructure.

Your markdown deserves a beautiful home.

Start publishing for free. Upgrade when you need more.

View pricing