New playbooks in your inbox
Hands-on tutorials for people who want to build with AI.
tutorial · Claude Code Skills

Claude Code Hooks Tutorial: Automate Code Review

A Claude Code hooks tutorial using the real settings.json schema: wire a PostToolUse hook to a review skill. No fictional git-event or blocking fields.

From the youcanbuildthings catalog ▸ Build-tested 9 min read

Summary:

  1. The real hooks schema lives in settings.json, not .claude/hooks/*.json files.
  2. A 13-line PreToolUse hook that blocks rm -rf before Claude can run it.
  3. A PostToolUse hook that auto-runs a code-review skill on edits.
  4. The real event names, from the official docs, and the fictional ones to delete.

Most every Claude Code hooks tutorial you’ll find online uses a schema that doesn’t exist: standalone .claude/hooks/review.json files, an options.blocking: true field, a git:pre-commit event. None of that is real. Hooks live in settings.json, the events are a fixed case-sensitive list, and a hook runs a shell command, not a skill. Get the schema right and you can make Claude review its own code automatically.

What is a Claude Code hook?

A Claude Code hook is a shell command that fires on a Claude Code event. Three layers stack here: skills are the brain (.claude/skills/<name>/SKILL.md, defines behavior), hooks are the reflexes (settings.json, fire commands on events), MCP servers are the hands (.mcp.json, external data). This tutorial wires the first two so a review runs without you asking.

Hook configuration goes in a hooks block in one of:

  • ~/.claude/settings.json (all projects)
  • .claude/settings.json (this project, commit it)
  • .claude/settings.local.json (this project, gitignored)

Not .claude/hooks/review.json. The .claude/hooks/ directory is a convention for the shell scripts a hook shells out to, never for the hook config.

Hook 1: block rm -rf before Claude runs it

The most useful safety hook, and the smallest. In .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm -rf *)",
            "command": "echo 'blocked: rm -rf is not allowed via Claude. Run it yourself if you really mean it.' >&2 && exit 1"
          }
        ]
      }
    ]
  }
}

Thirteen lines that stop Claude from nuking your home directory. When Claude is about to run a Bash tool whose command matches rm -rf *, the if predicate fires, the command exits non-zero, and the tool call is blocked. Claude sees the stderr message and stops. Note the schema: type is always "command", there is an optional if predicate, and a command. There is no action: block, no options.silent, no options.blocking. If a tutorial shows those, it’s wrong.

The real event list (from the docs)

Hooks fire on a fixed, case-sensitive set of events. From the official Claude Code hooks reference:

EventWhen it fires
SessionStartWhen a session begins or resumes
UserPromptSubmitWhen you submit a prompt, before Claude processes it
PreToolUseBefore a tool call executes. Can block it
PostToolUseAfter a tool call succeeds
PostToolUseFailureAfter a tool call fails
StopWhen Claude finishes responding
PreCompactBefore context compaction
SessionEndWhen a session ends

For “react when Claude edits a file,” the event is PostToolUse matched against Edit|Write. file:save, git:pre-commit, and command:fail are not on the list. They never were. Claude Code hooks are not git hooks: if you want a literal git pre-commit, that lives in .git/hooks/pre-commit and is not a Claude Code feature.

Hook 2: auto-run a code-review skill on edits

Here’s the automation. When Claude edits a file in a sensitive directory, fire a review using a skill. First the skill at .claude/skills/code-review/SKILL.md:

---
name: code-review
description: Use this skill when reviewing code or diffs. Prioritizes bugs and security over style. One sentence per issue with a specific fix. Top-5 limit.
---

# Code Review
## Instructions
- Prioritize: bugs, security, performance, then API design
- State each issue in one sentence with a specific code fix
- Limit to 5 highest-priority items; no praise

Then the hook in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "if": "FilePath(src/security/**)",
            "command": "claude -p \"Review the staged changes to $CLAUDE_TOOL_FILE_PATH for security issues. Use the code-review skill. If clean, output: SECURITY-OK.\""
          }
        ]
      }
    ]
  }
}

This is how you “wire a hook to a skill.” Not via a fictional action.type: "skill". The hook command shells out to claude -p with a prompt that names the skill; the skill auto-invokes because its description: matches the prompt. The review prints to stdout and flows back into your active session.

Three-layer architecture diagram: Skills as the brain in .claude/skills/SKILL.md, Hooks as the reflexes in settings.json, MCP servers as the hands in .mcp.json, with a real PreToolUse rm-rf-blocking hook and a real-versus-fictional hook event reference

What broke: hooks that silently never fire

Three failure points, in order of how often they bite:

  1. Wrong event name. Events are case-sensitive. PreToolUse, not pretooluse or preToolUse. The most common mistake is using one of the fictional events (file:save, git:pre-commit) copied from an old blog post. Confirm yours is in the docs list above.
  2. Wrong location. Config goes in the hooks block of settings.json. A .claude/hooks/review.json file is never read as hook config.
  3. Predicate too broad or too narrow. if: FilePath(**/*.ts) fires on every TypeScript edit and burns tokens fast when the command calls claude -p. if: FilePath(src/security/**) fires only where it matters. Test predicates against real tool calls.

To debug a silent hook, redirect output while testing:

# Append to the hook command temporarily
... 2>&1 | tee /tmp/hook.log
# Then trigger the hook and read what actually ran
cat /tmp/hook.log

Cost note: hooks that don’t call claude -p are free shell commands. Hooks that do cost tokens like any Claude turn (a 50–100 line review is a few hundred tokens, fractions of a cent). The lever is the if predicate. Scope it tight.

What should you actually do?

  • If you want one safety win today: add the rm -rf PreToolUse hook. Thirteen lines, immediate, free.
  • If you want automated review: use the PostToolUse + claude -p + skill pattern, scoped with if: FilePath(src/security/**) so it only fires on sensitive code.
  • If a hook isn’t firing: check the event name against the docs list, confirm config is in settings.json, then narrow the predicate. Don’t reach for a blocking field; it doesn’t exist.

bottom_line

  • Hooks live in settings.json with real case-sensitive event names. .claude/hooks/*.json, options.blocking, and git:pre-commit are tutorial folklore.
  • A hook runs a shell command. To involve a skill, that command calls claude -p and names the skill. There is no schema field that “runs a skill.”
  • Start with the rm -rf block, then scope a PostToolUse review hook tightly. Broad predicates that call claude -p are how hook costs sneak up on you.
Why trust this? Every youcanbuildthings guide is pulled from a build-tested book — code that ran in production before it was written down.

Frequently Asked Questions

Where do Claude Code hooks go?+

In a hooks block inside settings.json (~/.claude/settings.json, .claude/settings.json, or .claude/settings.local.json). Not in standalone .claude/hooks/*.json files.

Is there a Claude Code git pre-commit hook event?+

No. Claude Code hooks fire on Claude Code events like PreToolUse and PostToolUse, not git events. file:save, git:pre-commit, and command:fail do not exist.

Can a hook invoke a skill directly?+

Not via a schema field. The hook runs a shell command; to involve a skill, the command calls claude -p with a prompt that names the skill. There is no action.type skill field.