KaairosGet the kit
Guide · 7 min read

Claude Code hooks cookbook

The lifecycle event map plus five copy-paste settings.json recipes — format on save, block dangerous commands, run tests, notify on stop — and why hooks silently fail to fire.

Claude Code hooks are shell commands that run automatically at fixed points in the agent's lifecycle — before a tool runs, after a file is written, when you submit a prompt, when the agent stops. You configure them in settings.json under a hooks key, matched to the tool or event you care about. This cookbook gives you the lifecycle event map and five copy-paste recipes: auto-formatting edits, blocking dangerous shell and secret writes, running tests on change, and desktop notifications — plus why hooks silently fail to fire and how to fix that.

Every recipe below is a settings.json fragment. Merge the hooks block into .claude/settings.json (project scope, checked into the repo) or ~/.claude/settings.json (user scope); the two are combined. A hook receives a JSON payload on stdin — the tool name, the tool input, the working directory — and talks back through its exit code.

Worth being clear up front: hooks are automatic and deterministic. They fire on the event, every time, whether or not Claude decides they are relevant. If you instead want a check you invoke on demand by typing, that is a slash command with arguments, not a hook. Hooks are for guarantees; commands are for shortcuts.

The lifecycle event map

There are nine events you can hook. The two you will use most are PreToolUse (which can veto a tool call) and PostToolUse (which reacts after a tool succeeds). The rest let you attach behaviour to the edges of a session and a prompt.

EventFiresCommon use
PreToolUseBefore a tool runs — can block itGuard bash, deny secret writes
PostToolUseAfter a tool succeedsFormat, lint, run tests
UserPromptSubmitWhen you send a promptInject context, block on policy
NotificationWhen Claude raises a notificationRoute alerts to Slack or desktop
StopWhen the main agent finishes a turnPing you that it is done
SubagentStopWhen a subagent finishesLog or chain a delegated result
PreCompactBefore the context is compactedSave the transcript first
SessionStartWhen a session starts or resumesLoad env, print reminders
SessionEndWhen a session endsCleanup, usage logging

PreToolUse and PostToolUse take a matcher — a regex against the tool name (Write, Edit, MultiEdit, Bash, Read, and so on). Events that are not tied to a tool omit the matcher. The exit code is the contract: 0 is success, 2 is a blocking error whose stderr is fed back to Claude, and any other non-zero is a non-blocking warning. Only PreToolUse can actually stop an action; a 2 from PostToolUse just surfaces the message.

Where the config livesProject hooks go in .claude/settings.json so your whole team runs the same guards; personal ones go in ~/.claude/settings.json. Keep any scripts a hook calls under .claude/hooks/ in the repo, and commit them — a guard only your machine has is not a guard.

Recipe 1 — auto-format on Write and Edit

Formatting belongs in PostToolUse: the file has already been written, so you run your formatter over the path Claude just touched. The hook pulls file_path out of the tool input with jq and pipes it to the formatter.

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

Swap prettier for gofmt, black, rustfmt, or whatever your stack uses; --ignore-unknown stops it erroring on file types it does not recognise. Because this is PostToolUse, a formatting failure will not block the edit — it just prints a warning and the run continues.

Recipe 2 — block secrets and dangerous bash

This is the highest-value hook and the one to get right. A PreToolUse hook can veto a tool call before it runs by exiting 2. Point a matcher at Bash to catch destructive shell, and mirror the pattern against Write|Edit if you also want to stop Claude committing secrets to disk.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.sh"
          }
        ]
      }
    ]
  }
}

The script reads the command from stdin and blocks on a denylist. Using shell case globs keeps it readable and avoids fighting with regex escaping:

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command')
case "$cmd" in
  *"rm -rf /"* | *"chmod -R 777"* | *"git push"*"--force"* | *"| sh"* | *"| bash"* )
    echo "guard-bash: refused destructive command" >&2
    exit 2 ;;
esac
exit 0

Exit 2 blocks the call and hands your stderr message back to Claude, which then explains the refusal instead of silently retrying. For catching secrets in file writes, apply the same shape to .tool_input.content and match known key prefixes and high-entropy strings. A denylist is a starting point, not a security boundary — for the layered approach (allowlists, path fencing, and why one regex is not enough), see the Claude Code guardrails checklist and the guide on hardening Claude Code against prompt injection.

A denylist is one layer, not the wallThis stops the obvious cases, not a determined adversary or an obfuscated command. Treat it as one layer of defence-in-depth, log everything it blocks, and review the log — do not mistake it for complete protection.

Recipe 3 — run tests or lint on change

The temptation is to run the whole suite in PostToolUse on every Write. Do not — a multi-minute suite on every edit makes the agent crawl. Run a fast check instead: a type-check, a linter, or just the tests for the file that changed.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "npm run -s typecheck 2>&1 | tail -20"
          }
        ]
      }
    ]
  }
}

Because this is PostToolUse, a non-zero exit is a non-blocking signal: the output — a type error, a failing test — is surfaced to Claude, which can fix it on the next turn without the edit being reverted. Keep the command fast and the output short; piping through tail stops a wall of errors flooding the context window.

Recipe 4 — notify when Claude stops

Long runs mean you walk away. A Stop hook fires when the main agent finishes its turn, so you can ping yourself. It takes no matcher. Point it at a small script rather than an inline command so the platform specifics stay out of settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# macOS
osascript -e 'display notification "Claude Code is done" with title "Claude Code"'
# Linux:  notify-send "Claude Code" "done"
# Slack:  curl -s -X POST -d '{"text":"Claude Code is done"}' "$SLACK_WEBHOOK_URL"

Use SubagentStop instead of Stop if you want the ping when a delegated subagent finishes rather than when the whole run does — useful when you have kicked off a long background sweep and want to know the moment it lands.

Hooks not firing — a troubleshooting checklist

The most common hook problem is that nothing happens, with no error. Hooks fail quietly by design, so work this list in order.

  1. Restart or re-review after editing. For safety, Claude Code captures hooks at the start of a session; editing settings.json mid-session does not re-arm them. Run /hooks to review and confirm the new configuration, or restart the session. A widely-referenced bug report traces many 'my hook will not run' cases back to exactly this.
  2. Validate the JSON. A single trailing comma or missing brace disables the entire hooks block silently. Run jq . ~/.claude/settings.json — if jq errors, so does the hook.
  3. Match the exact tool name. Matchers are case-sensitive regex: Write, Edit, MultiEdit, Bash, Read — not write or bash. An empty matcher or * matches everything.
  4. Use absolute paths. A ~ in a command is not expanded, and relative paths resolve against the current working directory, which may not be your project root. Use $CLAUDE_PROJECT_DIR or a full path, and chmod +x the script.
  5. Check the exit code. Only 2 blocks a PreToolUse call. If your guard 'is not blocking', it is probably exiting 1, which is a non-blocking warning.
  6. Run with `--debug`. Start Claude Code as claude --debug to see each hook's command, its stdin, and its exit status as it runs — the fastest way to find out what actually fired.

Where to start

If you add nothing else, add two: the PreToolUse bash guard from Recipe 2, because it is the one that prevents damage, and the PostToolUse formatter from Recipe 1, because it removes a whole class of trivial follow-up edits. Everything else is convenience. Keep each hook fast, keep its output short, and keep the scripts in version control under .claude/hooks/ so your team runs the same guards you do. The curation discipline that keeps a .claude/ directory lean applies to hooks too: a few that earn their place beat a dozen you have forgotten are running.

Sources

Keep reading