Claude Code Hooks: Five Recipes That Actually Work

Claude Code Hooks: Five Recipes That Actually Work

I used Claude Code for four months without touching hooks. They looked like an advanced feature, something to circle back to when the basics felt comfortable. Then I needed to enforce one rule: every Python file the agent edited should be formatted with Black before I reviewed it. I was pasting that reminder into every session and still forgetting it half the time.

Thirty lines of JSON later, I stopped thinking about formatting. That is what hooks are: the place where you encode behavior you keep asking for manually.

This is a deep-dive into how Claude Code hooks work, the three lifecycle events worth knowing, and five recipes I run in real sessions. There is also a debugging trick at the end that makes the whole system much less opaque.

Note: the configuration format and exit-code behavior described here apply to Claude Code as of October 2026.

What hooks are

Hooks in Claude Code are shell commands that run at defined points in the agent loop. They are not plugins. They do not change the model's reasoning. They run on your machine, via your shell, when a specific event fires.

You configure them in settings.json. Two scopes exist:

  • User scope: ~/.claude/settings.json. Applies to all projects.
  • Project scope: .claude/settings.json inside a repo. Takes precedence over user scope for overlapping matchers.

The three lifecycle events that carry the most weight:

PreToolUse fires before a tool executes. You can inspect the call and block it.

PostToolUse fires after the tool returns. Useful for side effects, logging, or formatting.

Stop fires when the agent finishes its turn. The right place for notifications or cleanup.

There is also Notification, which fires when the agent emits a status message mid-run, for example when it is waiting on a slow external call. I use it occasionally to forward those updates to an external log or monitoring system.

If you are new to the Claude Code CLI and want context on how the tool loop works, the CLI command surface overview covers that ground before hooks become relevant.

How the configuration works

The minimal structure in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/check-bash.sh"
          }
        ]
      }
    ]
  }
}

The matcher string is compared against the tool name. An empty string matches all tools. A pipe-separated list like "Write|Edit" matches either.

When your hook runs, Claude Code pipes a JSON payload to its stdin. That payload contains the tool name and its arguments. For PostToolUse it includes the tool's response as well.

Return codes control what happens:

Exit 0: allow the operation. For most hook types, anything your hook prints to stdout is passed back into the agent's context as additional information. For PreToolUse hooks specifically, plain text stdout is routed to the debug log and does not reach the model; to inject context from a PreToolUse hook, output JSON with hookSpecificOutput.additionalContext (Recipe 5 shows the format).

Exit 2: block the operation. Write the blocking reason to stderr, not stdout, or output structured JSON with permissionDecision: "deny" and permissionDecisionReason. The agent sees the reason and can propose an alternative. It does not retry silently.

Any other non-zero exit: treated as a hook error. The operation proceeds anyway and Claude Code logs the failure.

The context injection on exit 0 is the feature most people skip past. You can feed real-time data, file contents, or environment state into the agent without writing it into the prompt.

Five recipes

The scripts below all require jq for JSON parsing. On macOS: brew install jq. On Linux: apt install jq or dnf install jq.

Recipe 1: Auto-format Python on every write

The agent writes clean logic but not always PEP-8-clean formatting. I want Black to run automatically after every file write or edit.

~/.claude/hooks/format-on-write.sh:

#!/bin/bash
set -euo pipefail

INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

if [[ -z "$FILE" ]]; then
  exit 0
fi

if [[ "$FILE" == *.py ]]; then
  black --quiet "$FILE" 2>/dev/null || true
fi

exit 0

Hook config in settings.json (PostToolUse, Write|Edit matcher):

"PostToolUse": [
  {
    "matcher": "Write|Edit",
    "hooks": [{"type": "command", "command": "~/.claude/hooks/format-on-write.sh"}]
  }
]

The || true on the Black call is intentional. If Black fails on a file, the hook exits 0 and the session continues. A formatting failure is not worth killing the agent's flow mid-task.

Recipe 2: Block dangerous shell patterns

I want a gate on a few specific shell patterns before they run, not after.

~/.claude/hooks/guard-bash.sh:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$CMD" | grep -qE '(rm -rf |curl .* \| sh|wget .* \| bash|chmod -R 777)'; then
  echo "Blocked: command matches a restricted pattern. Review it and run manually if intended." >&2
  exit 2
fi

exit 0

Hook config (PreToolUse, Bash matcher):

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

Exit 2 causes Claude Code to surface your message. The agent sees it and can propose an alternative.

Keep the pattern list short. A long deny-list starts blocking legitimate commands and you end up removing the whole hook in frustration. Start with the four patterns above and add only what you have actually seen cause damage in your own sessions. The list is deliberately short and conservative. This is a starter guard, not a security boundary: it catches common accidental cases, not crafted adversarial prompts.

Recipe 3: Log all tool calls to a file

Something goes wrong in a two-hour session and I want to reconstruct what happened, in order, without re-reading the full transcript.

~/.claude/hooks/log-tools.sh:

#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "$TS $TOOL" >> ~/.claude/tool-log.txt
exit 0

Hook config (PostToolUse, empty matcher to match all tools):

"PostToolUse": [
  {
    "matcher": "",
    "hooks": [{"type": "command", "command": "~/.claude/hooks/log-tools.sh"}]
  }
]

Run tail -f ~/.claude/tool-log.txt in a side terminal during long sessions. The file accumulates across sessions. Once a week I remove entries older than seven days.

Recipe 4: Desktop notification when the agent stops

I start an agentic task, switch to another window, and miss when it finishes. The agent then waits twenty minutes for my next message.

~/.claude/hooks/notify-stop.sh:

#!/bin/bash
# macOS
if command -v osascript &>/dev/null; then
  osascript -e 'display notification "Claude Code finished its turn" with title "Claude Code"'
fi

# Linux with notify-send
if command -v notify-send &>/dev/null; then
  notify-send "Claude Code" "Finished its turn"
fi

exit 0

Hook config (Stop event):

"Stop": [
  {
    "hooks": [{"type": "command", "command": "~/.claude/hooks/notify-stop.sh"}]
  }
]

Stop hooks receive no tool-specific payload. They fire when the agent ends its turn, not per tool call. The Stop event does not match on tool names, so no matcher field is needed.

Recipe 5: Inject git status before a commit

The agent sometimes proposes a commit message without accounting for what is actually staged. I want it to see the current git status automatically before any commit command.

~/.claude/hooks/inject-git-status.sh:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command')

if echo "$CMD" | grep -q 'git commit'; then
  STATUS=$(git status --short 2>/dev/null)
  jq -n --arg status "$STATUS" \
    '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:("Current git status:\n"+$status)}}'
fi

exit 0

Hook config (PreToolUse, Bash matcher):

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [{"type": "command", "command": "~/.claude/hooks/inject-git-status.sh"}]
  }
]

On PreToolUse, plain text stdout is routed to the debug log and does not reach the model. To inject context, the hook must output JSON with hookSpecificOutput.additionalContext. The script uses jq to build that JSON safely and includes the status output under that key. The agent reads it before reasoning about the commit message. I caught a "feat:" commit that should have been "fix:" because a bug-fix file was staged that the agent had not explicitly mentioned during its work.

The debugging trick

The most common problem: you add a hook, run a session, and are not sure whether it fired at all.

Add a log line at the very top of your script, before any other logic:

#!/bin/bash
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) hook fired: $0" >> ~/.claude/hook-debug.log
# rest of script below

Watch it in a terminal:

tail -f ~/.claude/hook-debug.log

If the line does not appear when Claude Code runs the relevant tool, the hook is not loading. Most likely causes:

  • Wrong path in the command field. The path must be absolute or valid from your shell's context. Tilde expansion (~) works, relative paths often do not.
  • Script missing execute permission. Run chmod +x ~/.claude/hooks/your-hook.sh.
  • settings.json is in the wrong scope. Project scope takes precedence over user scope for overlapping matchers. If you placed the hook in the user file and a project file defines a competing matcher, the project file wins. Check both.

I spent forty minutes debugging a hook once before noticing a trailing space in the command path.

Two other tools belong in this kit. The /hooks command, run inside a Claude Code session, lists every registered hook across user and project scopes. If your hook does not appear there, the settings.json entry is missing or malformed. Running claude --debug logs hook invocations, their arguments, and exit codes as the session runs. Between the debug file above, /hooks, and --debug, most hook problems surface quickly.

Bottom line

Hooks are the right place for behavior you want applied uniformly, without prompting, in every session. Formatting, safety gates, notifications, and context injection all fit. They are not the right place for complex logic that needs full tool history or belongs in a script the agent calls directly.

Write the log line first. Understand what fires. Build from there.


If you have pushed hooks as far as they go and need a production-grade automation system built around Claude Code, including multi-agent pipelines, custom tooling, or integration into an existing stack, that is the work I do at klim.expert.

enjoyed this? follow me!

X / Twitter LinkedIn GitHub

share this!

← Back to blog