nanda mochammad
Applied AI

The quiet power of Claude Code hooks

11 min read
Tagged AI

You ask Claude Code to make a change, it does, and you notice the file is unformatted again, so you type “run the formatter” for the third time this session. Or it proposes rm -rf on a path that is one directory off from what you meant, and you catch it a beat too late. Or you kick off a long task, walk away to make coffee, and come back ten minutes after it actually finished. Three small annoyances, all the same shape: a rule you keep having to enforce by hand, or by reminding the model.

Hooks fix all three because they fire on their own. A hook is a shell command Claude Code runs automatically at a fixed point in its loop. You wire it up once and it happens every time, whether or not the model “remembers” to do it. They are one of the more useful parts of Claude Code and one of the least turned on, mostly because the docs present them as a reference rather than something to reach for. So here is what they are, where they live, and three I keep switched on, with the actual config for each.

What a hook is

Deterministic, not remembered

The difference that makes hooks worth the setup is determinism. If you put “always format after editing” in your instructions, the model usually does it, until a long session, a distracting bug, or a full context window crowds it out. A hook is not a suggestion to the model; it is code Claude Code runs itself. It always fires.

It fires at fixed points in the agent’s loop. The two you will use most often wrap each tool call: PreToolUse runs before a tool executes and can block it, and PostToolUse runs after a tool succeeds. Stop runs when the model finishes responding.

Diagram: Claude Code's agent loop with hook events firing at fixed points: SessionStart, then UserPromptSubmit, then a repeating tool loop where PreToolUse fires before a tool runs and can block it and PostToolUse fires after it succeeds, then Stop when the model finishes, then SessionEnd. SessionStart session begins UserPromptSubmit before the model sees it model works tool loop: repeats per tool call PreToolUse before the tool runs, can block tool runs (Bash · Edit · Write…) PostToolUse after the tool succeeds next tool call model done Stop the model finishes responding SessionEnd session closes
Where hooks fire in the loop. After the session starts and you submit a prompt, every tool call is wrapped by PreToolUse (which can block) and PostToolUse; the tool loop repeats until the model is done, at which point Stop fires.
Where hooks live

The settings file

Hooks are configured in a settings.json file. Claude Code reads three of them, from broadest to narrowest scope:

  • ~/.claude/settings.json: applies to all your projects.
  • .claude/settings.json: this project, checked into git so your team shares it.
  • .claude/settings.local.json: this project, not shared (git-ignored).

For this guide we use the project file, .claude/settings.json. The shape is the same in all three: a top-level hooks object keyed by event name, where each event holds an array of matcher groups. A matcher group has a matcher (which tools it applies to) and a list of hooks to run.

// .claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "echo hook fired" }
        ]
      }
    ]
  }
}

The matcher filters by tool name: "*" or an omitted matcher means every tool; "Bash" matches exactly; "Edit|Write" matches either (it is a regex, so other patterns work too). MCP tools match by their full name, like mcp__server__tool. Get the matcher wrong and the hook never runs. That is the most common mistake, so it is worth double-checking against the tool you actually want to catch.

Hook 1: auto-format on every edit

PostToolUse

Start with the formatter annoyance. What you want: every time Claude edits or writes a file, run the formatter on that file. That is a PostToolUse hook matching Edit|Write.

The hook receives a JSON object on standard input describing what just happened. For an Edit or Write, the field you want is tool_input.file_path, the file that was touched. Pull it out with jq (the examples here use it to read that JSON, so it needs to be on your path) and pass it to your formatter:

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

What this does, left to right: jq -r '.tool_input.file_path' reads the JSON on stdin and prints the path; xargs -r runs npx prettier --write on it (and the -r means “do nothing if the path is empty,” so an edit with no path can’t misfire). Swap npx prettier --write for gofmt -w, black, or whatever your project uses.

Once this is on, edits come back already formatted. Claude changes a file, the hook reformats it to your Prettier style (re-indented, quotes normalized), and a git diff shows the edit plus the cleanup, even though nobody asked for the cleanup. Formatting just stops being a thing you chase.

Hook 2: block a dangerous command

PreToolUse

Now the scary one. You want Claude Code to refuse to run a recursive force-delete, no matter how it got there. That is a PreToolUse hook on Bash, because PreToolUse is the only event that can stop a tool before it runs.

The decision is yours to make in code. A PreToolUse hook can end three ways:

Diagram: a PreToolUse hook decides what happens to a tool call. The tool call reaches the hook, which runs and returns one of three outcomes: allow (the tool proceeds), deny (the tool is blocked and the reason is sent back to Claude), or ask (the user decides). Tool call Claude wants to run Bash PreToolUse hook runs, inspects the input allow the tool proceeds, no prompt ask you decide: confirm or reject deny tool is blocked; your reason is sent back to Claude exit 0 + JSON decision, or exit 2 to block outright
A PreToolUse hook inspects the tool input and returns one of three outcomes: allow (the tool proceeds), ask (you confirm), or deny (the tool is blocked and your reason is sent back to Claude).

There are two ways to signal deny. The blunt one: exit with code 2, and whatever you wrote to stderr is fed back to Claude as the reason the action was blocked. The precise one: exit 0 and print a JSON object choosing "allow", "deny", or "ask". Here is the exit-2 version, kept in its own script so the settings file stays readable:

# .claude/hooks/block-rm.sh
#!/usr/bin/env bash
# Block recursive force-deletes before Bash runs them.
# Reads the PreToolUse JSON from stdin; the command is at .tool_input.command.

command=$(jq -r '.tool_input.command // empty')

if printf '%s' "$command" | grep -Eq 'rm[[:space:]]+(-[a-zA-Z]*\b[[:space:]]*)*-?r[a-zA-Z]*f|rm[[:space:]]+-rf'; then
  echo "Blocked: 'rm -rf' is not allowed by a project hook. Delete specific files explicitly instead." >&2
  exit 2
fi

exit 0

The script needs to be executable (chmod +x .claude/hooks/block-rm.sh), and then it gets wired up like the others. The one wrinkle is the path: ${CLAUDE_PROJECT_DIR} resolves to your project root no matter which subdirectory Claude is working in, which is what you want here.

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

With this in place, a rm -rf never reaches the shell: the command is blocked and your message (“Blocked: ‘rm -rf’ is not allowed…”) goes back to Claude as the reason, so it adjusts instead of deleting. A plain rm ./one-file.txt still goes through, since the matcher only catches the recursive-force pattern.

One honest limit: this pattern catches the short flags (-rf, -fr, -r -f) but not the long forms rm --recursive --force. Treat it as a guardrail against the common slip, not an airtight fence. Widen the regex if you want the long flags too.

Hook 3: tell me when it’s done

Stop

The third annoyance is walking away. A Stop hook fires when the model finishes responding, which is exactly the moment you want a nudge. The Stop event’s JSON has no tool_input, so the hook is just a notification command.

On macOS, osascript posts a native notification:

// .claude/settings.json  (macOS)
{
  "hooks": {
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code finished responding\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

On Linux, notify-send does the same job:

// .claude/settings.json  (Linux)
{
  "hooks": {
    "Stop": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Claude Code finished responding'"
          }
        ]
      }
    ]
  }
}

Now the end of every response posts a desktop notification reading “Claude Code finished responding,” so a long task pulls you back the moment it’s done instead of you checking. If nothing shows on Linux, notify-send ships in the libnotify-bin package; on macOS, the terminal app has to be allowed to post notifications in System Settings → Notifications.

Read this before you trust a hook

The security cost

The mitigation is not complicated: read the command, keep hooks short and legible (which is part of why Hook 2 lives in its own script you can audit), and run /hooks after cloning a repo so you know what will fire before you give it a task.

When it doesn’t fire

What trips people up

If you do wire these up, almost every hook problem is one of a handful of things. In rough order of how often they happen:

SymptomLikely causeFix
Hook never runsSettings changed mid-sessionFully quit and reopen Claude Code
Hook never runsWrong matcher or wrong eventCheck the tool name; Edit|Write for files, Bash for commands
”No such file” / nothing happensRelative script pathUse ${CLAUDE_PROJECT_DIR}/… and chmod +x the script
jq: command not foundjq not installedbrew install jq / sudo apt install jq
Block doesn’t blockExit code confusionOnly exit 2 blocks; other non-zero codes just show an error

The exit-code rule is the one that trips people up most, so to be explicit: 0 means success (and stdout is parsed as optional JSON), 2 is the blocking error (stderr becomes the reason fed back to Claude), and any other non-zero code is a non-blocking error that shows in the transcript but lets the action proceed. If your “block” hook isn’t blocking, it is almost certainly exiting 1 instead of 2. Run /hooks to confirm what Claude Code thinks is configured, and check the transcript output for your hook’s stderr.

Hooks move a rule out of the model’s memory and into the machine. The formatter runs whether or not the model thought to run it; the dangerous command is stopped whether or not anyone was watching; the notification arrives whether or not you were still at the desk. That is a small amount of setup for a payoff you stop having to think about.

If you liked wiring this up, the other half of shaping Claude Code is connecting it to your tools through MCP. See setting up the Linear MCP to drive your tracker from the terminal, and setting up the Zotero MCP to give it your reference library. Hooks decide what happens around the loop; MCP decides what the loop can reach.

Cited sources