nanda mochammad
Applied AI

The quiet power of Claude Code hooks

11 min read
Tagged AI

Hello, Claude Code users. Ever found yourself reminding Claude Code about the same rule for the third time in one session? This article is about hooks, a small feature that makes those rules run on their own.

By the end of this article, you will know what a hook is, where the config lives, and how to set up three useful ones: auto-format every edit, block a dangerous command, and get a notification when a task finishes. You will also know the security cost and the common reasons a hook does not fire.

You ask Claude Code to make a change. It does, but 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 start a long task, walk away to make coffee, and come back ten minutes after it finished. Three small annoyances with the same shape, a rule you keep enforcing 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 set it up once and it happens every time, whether or not the model “remembers” to do it. Few people turn hooks on, partly because the Claude Code 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 thing that makes hooks worth the setup is that they always run. If you put “always format after editing” in your instructions, the model usually does it. Then a long session, a distracting bug, or a full context window pushes it out. A hook is code Claude Code runs itself, so it fires every time.

It fires at fixed points in the agent’s loop. The two you will use most wrap each tool call. PreToolUse runs before a tool executes and can block it. 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 widest 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. There is a top-level hooks object keyed by event name. 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 a missing matcher means every tool. "Bash" matches exactly. "Edit|Write" matches either one (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 double-check it 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. It describes 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 and pass it to your formatter. The examples here use jq to read that JSON, so it needs to be on your path.

// .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. 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). A git diff shows the edit plus the cleanup, even though nobody asked for the cleanup. Formatting 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. 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 simple way is to exit with code 2; whatever you wrote to stderr is sent back to Claude as the reason the action was blocked. The precise way is to exit 0 and print a JSON object choosing "allow", "deny", or "ask". Here is the exit-2 version. It lives 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). Then it gets wired up like the others. The one catch 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.

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 a perfect 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. That 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.” 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

Read the command. Keep hooks short and easy to read (this is part of why Hook 2 lives in its own script you can check). 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 set 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 trips people up most, so to be clear: 0 means success (and stdout is parsed as optional JSON). 2 is the blocking error (stderr becomes the reason sent back to Claude). Any other non-zero code is a non-blocking error. It 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.

Recap

The goal of this article was to help you understand what hooks are and set up three of them. Here are the main points:

  • A hook is a shell command Claude Code runs on its own at a fixed point in its loop. It always fires, so the rule no longer depends on the model’s memory.
  • Hooks live in settings.json (user, project, or local). Each event holds matcher groups, and the matcher decides which tools trigger the hook.
  • A PostToolUse hook on Edit|Write can run your formatter on every changed file.
  • A PreToolUse hook on Bash can block a dangerous command. Only exit 2 blocks.
  • A Stop hook can post a desktop notification when the model finishes.
  • Hooks run with your full credentials, so only use configs you have read. Run /hooks to see what is active.

If you liked setting 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.

I hope this writing helps you. Thanks for reading.

Cited sources