AniUI Academy

Hooks

Shell commands that run automatically at fixed points in Claude's workflow, for the things that must happen every time rather than usually.

9 min read

The last two lessons gave you two ways to tell Claude things. Both share a property that is easy to overlook: neither one is binding.

CLAUDE.md is context. A skill is context. Claude reads them, and mostly follows them, and occasionally does not — because the file was long, or the instruction was ambiguous, or it simply judged differently this time. For most guidance that is fine. Usually is good enough for a naming convention.

Sometimes usually is not good enough.

The distinction that matters

A hook is a shell command that Claude Code runs at a fixed point in its own lifecycle: before a tool call, after a file edit, when a session starts, when Claude finishes responding. It is not a request. Claude does not decide whether to honour it.

That gives you a clean way to sort your own rules. "Prefer named exports" is advice, and belongs in CLAUDE.md. "Never write to the migrations directory" is a rule, and belongs in a hook. "Format edited files with Prettier" is not really an instruction to a model at all — it is a mechanical step, and asking a language model to remember it every time is the wrong shape of solution.

There is a useful signal here too. If you find yourself adding IMPORTANT and YOU MUST to a line in CLAUDE.md, you are trying to make an advisory mechanism behave like a deterministic one. That is the moment to write a hook instead.

What one looks like

Hooks are configured in a settings file under a hooks key. Where you put it decides the scope, exactly as with everything else in this section: .claude/settings.json for the project and your team, ~/.claude/settings.json for every project on your machine, .claude/settings.local.json for personal overrides that stay out of git.

Here is the one most people write first — run Prettier on every file Claude edits:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

Three things are going on. PostToolUse is the event — the point in the lifecycle where this fires. "Edit|Write" is the matcher, which narrows it to the file-editing tools rather than every tool call. And the command is what runs.

The command gets the event's data as JSON on stdin, which is what jq is reading here: it pulls the edited file's path out and passes it to Prettier. Every event provides a slightly different shape of data, along with common fields like the session ID and the working directory.

If the JSON nesting looks redundant — hooks containing an array containing hooks — it is because the outer level groups by matcher and the inner level lists the commands to run for that group. You can attach several commands to one matcher.

The events

There are a lot of events, and you will use four or five. The ones worth knowing by name:

EventFires
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
NotificationWhen Claude Code sends a notification, including when it is waiting on you
StopWhen Claude finishes responding
SessionEndWhen a session terminates

Beyond those there are events for compaction, subagents, permission decisions, configuration file changes, working-directory changes and more. The full list is in the reference, and /hooks shows every event name in one screen, which is a faster way to browse than reading documentation.

Most events also accept a matcher, and what the matcher filters depends on the event. For tool events it is the tool name. For SessionStart it is how the session began — startup, resume, clear, compact or fork. For Notification it is the notification type, so you can fire a desktop alert only when Claude is waiting for a permission decision.

Blocking, and how a hook talks back

A hook communicates through its exit code and what it writes.

Exit 0 means no objection. For a PreToolUse hook this is not an approval — the normal permission flow still applies afterwards. For UserPromptSubmit and SessionStart, anything the command prints to stdout is added to Claude's context, which is how you inject information at a fixed moment.

Exit 2 blocks the action. Whatever the script wrote to stderr becomes the explanation, and for most events that text is fed back to Claude so it can adjust rather than just failing.

That second behaviour is what makes a blocking hook cooperative rather than merely obstructive. Consider a script that guards protected files:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
 
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
 
for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done
 
exit 0

Wire that to PreToolUse with an Edit|Write matcher and Claude cannot touch those files. It also learns why, in that sentence, and can route around the restriction instead of retrying.

Two notes. The script has to be executable — chmod +x — which is a common first failure. And for more than block-or-allow, a hook can exit 0 and print a JSON object to stdout instead, which gives you structured control over the decision. That is worth knowing exists; you will rarely need it.

Getting one written

You do not need to memorise any of the JSON. Describing what you want is faster and gets the event and matcher right more often than guessing does.

claude
  1. WhyBackground: this project has a generated file, src/api/types.ts, produced by a codegen step. Claude has edited it directly twice this month, each time producing a change that vanished on the next build. There is already a line in CLAUDE.md telling it not to.

  2. You typed: Write a hook that blocks any edit to src/api/types.ts and tells you to change the schema and re-run codegen instead.

  3. Claude replied: PreToolUse on Edit|Write, with a script that exits 2 when the path matches. Writing the script first.

  4. Claude ran: Write .claude/hooks/protect-generated.sh

  5. Claude ran: Run: chmod +x .claude/hooks/protect-generated.sh

  6. Claude ran: Edit .claude/settings.json

  7. WhyTwo files, not one. The script does the work and settings.json registers it. Both are in .claude/, so both are committed and the rule applies to everyone on the team, not just whoever wrote it.

  8. You typed: /hooks

  9. Output: PreToolUse (1)
      matcher   Edit|Write
      type      command
      source    .claude/settings.json
      command   "$CLAUDE_PROJECT_DIR"/.claude/hooks/protect-generated.sh
  10. WhyConfirming registration before testing behaviour. The menu is read-only — it will not let you edit a hook — but it answers the only question that matters when something does not fire.

  11. You typed: add an optional avatarUrl field to the User type

  12. Claude ran: Edit src/api/types.ts

  13. Output: Blocked: src/api/types.ts is generated. Edit schema/user.graphql and run npm run codegen.
  14. Claude replied: Right — that file is generated. I'll edit the schema instead.

  15. Claude ran: Edit schema/user.graphql

  16. Claude ran: Run: npm run codegen

  17. Output: Generated 4 files in 1.2s
  18. WhyThis is the whole argument for hooks in one exchange. The CLAUDE.md line had been ignored twice. The hook was not ignored, because there was nothing to ignore — and because the block explained itself, Claude recovered in one step rather than stopping.

An instruction that was being skipped, converted into one that cannot be.

Rough edges worth knowing

Hooks are shell commands running on your machine with your credentials, triggered automatically. Treat one you copied from the internet the way you would treat any script you were about to run: read it first. A hook in a project's .claude/settings.json came from whoever committed it.

A few practical limits. PostToolUse hooks cannot undo anything, because the tool has already run — if you need to prevent something, it has to be PreToolUse. Matching on Edit|Write will not catch a file Claude changes by running a shell command, since that is the Bash tool. Stop hooks fire every time Claude finishes responding, not only when a task is genuinely complete, and Claude Code overrides a Stop hook that has blocked eight times in a row so a badly written one cannot trap you in a loop.

And when a hook does not appear to work at all, the order of checks is: run /hooks and confirm it is registered, check the matcher, and check the script is executable. That covers almost every case. There is a whole lesson on diagnosing configuration later in the course.

The matcher is worth one more sentence, because it has a trap in it. A plain name like Bash, or several separated by |, is matched as an exact string. Add any other character and the whole thing becomes a regular expression that is tested anywhere in the tool name rather than against the whole of it — so Edit.* fires on NotebookEdit too. If you find yourself reaching for regular expression syntax, anchor it: ^Edit$ means what you meant.

What to take away

A hook is a shell command Claude Code runs at a fixed point in its lifecycle, configured under a hooks key in a settings file and scoped by where that file lives. The reason to reach for one is determinism: CLAUDE.md and skills are advice that Claude usually follows, while a hook happens regardless, so anything that must occur every time without exception belongs here. An event says when it fires and a matcher narrows which occurrences count; the command receives the event data as JSON on stdin, and exits 0 to stay out of the way or 2 to block, with its stderr passed back to Claude as an explanation it can act on. Use /hooks to see what is registered, and describe the hook you want to Claude rather than writing the configuration by hand.

Next: connecting Claude to systems outside your codebase — issue trackers, databases, design tools — through the Model Context Protocol.

Check yourself

5 questions · pass 4/5 to unlock Connecting Tools With MCP

up to 50
  1. 1.What is the defining difference between a CLAUDE.md rule and a hook?

  2. 2.Where do you configure a hook?

  3. 3.A PreToolUse hook script exits with code 2 and writes a message to stderr. What happens?

  4. 4.You want Prettier to run on every file Claude edits. Which event fits?

  5. 5.What does the /hooks command do?

5 left to answer