Claude Code Hooks – A Practical Guide with Copy-Ready Recipes

The official documentation on Claude Code hooks explains the system thoroughly. And yet I kept running into the same question: which hooks do I actually need for my day-to-day work? That is the problem I am solving here. Instead of another reference manual, you get finished, immediately usable recipes that you can copy straight into your settings.json. Each recipe solves a concrete problem that I, or other developers I know, have run into.

If you are not yet familiar with Claude Code, have a look at the Claude Code cheat sheet first. For CLAUDE.md, there is a dedicated deep dive.

Hooks explained in 30 seconds

Hooks are shell commands that run automatically at specific points in the Claude Code lifecycle. Unlike instructions in your CLAUDE.md, which Claude is supposed to follow, hooks are deterministic: they happen guaranteed, every single time. You configure them in your settings.json, and you need neither a plugin nor an extension to use them.

Three things define a hook: the event name decides when it runs, an optional matcher narrows it down to specific tools or event subtypes, and the hook definition says what happens. Where the file lives decides the scope: ~/.claude/settings.json applies to every project on your machine, .claude/settings.json applies to the current project and is tracked in Git, and .claude/settings.local.json applies to the current project without ending up in Git.

One note before the recipes: the comments inside the code blocks, along with a few user-facing strings such as notification texts, are German, because I wrote these snippets for the German edition of this article and keep the code identical in both editions. I explain everything relevant in the surrounding text, and you can replace any visible string with your own wording without changing the mechanics.

# Hooks werden in JSON konfiguriert.
# Die Grundstruktur sieht immer so aus:

{
  "hooks": {
    "EventName": [           # Wann soll der Hook laufen?
      {
        "matcher": "Filter", # Optional: Nur bei bestimmten Tools/Events
        "hooks": [
          {
            "type": "command",  # Was soll passieren?
            "command": "dein-befehl"
          }
        ]
      }
    ]
  }
}

# Wo die Datei liegt, bestimmt den Geltungsbereich:
# ~/.claude/settings.json           → gilt für alle Projekte
# .claude/settings.json             → gilt für dieses Projekt (Git-tracked)
# .claude/settings.local.json       → gilt für dieses Projekt (nicht Git-tracked)

Recipe 1: Auto-format after every edit

Problem: Claude writes code that does not match your formatting standard. You end up running Prettier or Biome by hand, over and over.

Solution: A PostToolUse hook that automatically runs the formatter on the edited file after every file change.

# Für Prettier (in .claude/settings.json)
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null"
          }
        ]
      }
    ]
  }
}

# So funktioniert es:
# 1. Claude bearbeitet eine Datei (Edit oder Write Tool)
# 2. Der Hook bekommt die Event-Daten als JSON auf stdin
# 3. jq extrahiert den Dateipfad aus den Tool-Eingaben
# 4. Der Formatter wird auf genau diese Datei angewendet
# 5. 2>/dev/null unterdrückt Fehlermeldungen bei Nicht-JS-Dateien

Here is what happens: Claude edits a file through the Edit or Write tool, and the hook receives the event data as JSON on stdin. jq extracts the file path from the tool input, xargs hands it to Prettier, and the formatter runs on exactly that one file. The 2>/dev/null at the end suppresses error messages for files Prettier cannot handle.

Recipe 2: ESLint feedback after file changes

Problem: Claude writes code that works but violates your lint rules. You only notice at the next commit.

Solution: Claude receives ESLint feedback immediately after every edit and can fix the issues on the spot.

# ESLint nach jeder Dateibearbeitung ausführen
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(jq -r '.tool_input.file_path'); if [[ \"$FILE\" =~ \\.(js|ts|tsx|jsx)$ ]]; then npx eslint \"$FILE\" --format compact >&2; fi"
          }
        ]
      }
    ]
  }
}

# Was passiert:
# 1. Der Dateipfad wird aus dem JSON extrahiert
# 2. Nur JS/TS-Dateien werden geprüft (if-Bedingung)
# 3. ESLint-Output geht auf stderr, damit Claude die Fehler sieht
# 4. Bei Nicht-JS-Dateien passiert nichts

The command extracts the file path from the JSON, the if condition restricts the check to .js, .ts, .tsx and .jsx files, and the ESLint output goes to stderr (>&2) so that Claude sees the findings and can address them immediately. For every other file type, nothing happens.

Recipe 3: Protecting sensitive files

Problem: Claude accidentally modifies .env files, lock files or files inside the .git/ directory.

Solution: A PreToolUse hook checks the target path before every edit. On a match, the action is blocked and Claude receives feedback explaining why.

# Speichern Sie als .claude/hooks/protect-files.sh

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

# Liste der geschützten Muster – nach Bedarf anpassen
PROTECTED_PATTERNS=(
  ".env"
  "package-lock.json"
  "pnpm-lock.yaml"
  "yarn.lock"
  ".git/"
  "dist/"
  "node_modules/"
)

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blockiert: $FILE_PATH ist geschützt ('$pattern')" >&2
    exit 2   # Exit 2 = Aktion blockieren
  fi
done

exit 0       # Exit 0 = alles okay, weitermachen

# Danach ausführbar machen: chmod +x .claude/hooks/protect-files.sh

Save the script as .claude/hooks/protect-files.sh, adjust the PROTECTED_PATTERNS array to your project, and make it executable with chmod +x .claude/hooks/protect-files.sh. The German echo line is the message Claude sees on stderr; it says that the file is protected, and you can reword it however you like. Then register the hook:

# Hook registrieren (in .claude/settings.json)
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

# Exit-Codes erklärt:
# 0 = Aktion erlauben
# 2 = Aktion blockieren (Claude bekommt stderr als Feedback)
# Alles andere = Aktion erlauben, stderr wird nur geloggt

The exit codes carry the meaning: exit 0 allows the action, exit 2 blocks it and feeds stderr back to Claude as feedback, and any other exit code allows the action while stderr is merely logged.

Recipe 4: Desktop notifications when Claude needs you

Problem: You kick off a Claude task, switch to another window and never notice that Claude finished long ago or is waiting for your input.

# macOS (in ~/.claude/settings.json)
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude wartet auf Sie\" with title \"Claude Code\" sound name \"Ping\"'"
          }
        ]
      }
    ]
  }
}

# Linux (benötigt libnotify):
# "command": "notify-send -u normal 'Claude Code' 'Claude wartet auf Sie'"

# Matcher-Optionen:
# ""                    → bei allen Benachrichtigungen
# "permission_prompt"   → nur bei Berechtigungsanfragen
# "idle_prompt"         → nur wenn Claude fertig ist und wartet

On macOS, osascript displays a native notification with a sound. The German text “Claude wartet auf Sie” simply means “Claude is waiting for you”; put any message you like there. On Linux, use notify-send from libnotify instead, as shown in the comment. The matcher controls when the notification fires: an empty string matches all notifications, permission_prompt fires only on permission requests, and idle_prompt fires only when Claude has finished and is waiting for you.

Recipe 5: Intercepting dangerous shell commands

Problem: Claude could accidentally run destructive commands such as rm -rf, DROP TABLE or git push --force.

Solution: A PreToolUse hook with the Bash matcher inspects every shell command before it runs and blocks anything that matches a denylist.

# Speichere als .claude/hooks/block-dangerous.sh

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

DANGEROUS_PATTERNS=(
  "rm -rf /"
  "rm -rf ~"
  "drop table"
  "drop database"
  "git push.*--force"
  "git reset --hard"
  "chmod -R 777"
  "curl.*| bash"
)

COMMAND_LOWER=$(echo "$COMMAND" | tr '[:upper:]' '[:lower:]')

for pattern in "${DANGEROUS_PATTERNS[@]}"; do
  if echo "$COMMAND_LOWER" | grep -qE "$pattern"; then
    echo "BLOCKIERT: '$pattern' erkannt in: $COMMAND" >&2
    exit 2
  fi
done
exit 0

The script lowercases the command and greps it against the pattern list; extend DANGEROUS_PATTERNS with whatever keeps you up at night. “BLOCKIERT” is simply German for “BLOCKED”. Register the script like this:

# Hook registrieren
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous.sh"
          }
        ]
      }
    ]
  }
}

# Matcher "Bash" fängt nur Shell-Befehle ab.
# "Edit|Write" würde nur Dateiänderungen abfangen.
# Ohne Matcher läuft der Hook bei JEDEM Tool.

The Bash matcher intercepts only shell commands. Edit|Write would intercept only file changes, and without any matcher the hook runs for every single tool.

Recipe 6: Restoring context after compaction

Problem: In long sessions the context window fills up, Claude compacts the conversation, and important details get lost.

# Dynamischen Kontext nach Compaction injizieren
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo '--- Kontext nach Compaction ---' && echo 'Branch:' && git branch --show-current 2>/dev/null && echo 'Letzte 5 Commits:' && git log --oneline -5 2>/dev/null && echo 'Geänderte Dateien:' && git diff --name-only HEAD~3 2>/dev/null && echo 'Erinnerung: pnpm nutzen, Tests vor Commits.'"
          }
        ]
      }
    ]
  }
}

# Alles was auf stdout geschrieben wird, landet in Claudes Kontext.
# Der Matcher "compact" sorgt dafür, dass der Hook nur
# nach einer Komprimierung läuft, nicht bei jedem Session-Start.
# Für Kontext bei JEDEM Start: nutzen Sie CLAUDE.md stattdessen.

Everything the command writes to stdout lands in Claude’s context. The compact matcher makes sure the hook runs only after a compaction, not at every session start; if you want context injected at every start, that is a job for CLAUDE.md instead. The echo strings in the sample are German: a header, the current branch, the last five commits, the recently changed files and a reminder to use pnpm and to run tests before commits. Adapt them to whatever your project needs to remember after a compaction.

Recipe 7: Logging every Bash command

Problem: You want to trace which shell commands Claude executed during a session, for documentation, for debugging or for compliance.

# Jeden Bash-Befehl mit Zeitstempel loggen
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '\"[\" + (now | todate) + \"] \" + .tool_input.command' >> ~/.claude/command-log.txt"
          }
        ]
      }
    ]
  }
}

# Ergebnis in ~/.claude/command-log.txt:
# [2026-03-31T10:15:23Z] npm test
# [2026-03-31T10:15:45Z] git diff --name-only
# [2026-03-31T10:16:02Z] npx tsc --noEmit

Each command is appended to ~/.claude/command-log.txt with a UTC timestamp; the sample lines at the end of the block show what the result looks like. Because the hook runs on PostToolUse, only commands that actually executed end up in the log.

Recipe 8: Type checking after TypeScript changes

Problem: Claude produces TypeScript code that looks functional but contains type errors. You only find out at build time.

# Type-Check nur bei TypeScript-Dateien
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "FILE=$(jq -r '.tool_input.file_path'); if [[ \"$FILE\" =~ \\.(ts|tsx)$ ]]; then npx tsc --noEmit --pretty 2>&1 | head -20 >&2; fi"
          }
        ]
      }
    ]
  }
}

# head -20 begrenzt die Ausgabe (bei vielen Fehlern sonst riesig)
# >&2 schickt das Ergebnis auf stderr, damit Claude es sieht

Only .ts and .tsx files trigger the check. head -20 caps the output, because a single broken type can cascade into dozens of errors, and >&2 routes the result to stderr so Claude sees the errors and can fix them right away.

Recipe 9: A prompt hook that checks whether the task is done

Problem: Claude says it is finished but has not completed all subtasks.

Solution: A Stop hook of type prompt. Instead of a shell command, a separate Claude model evaluates whether the task is really complete.

# Prompt-basierter Stop-Hook
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "prompt",
            "prompt": "Prüfe ob alle Aufgaben erledigt sind. Falls nicht: {\\\"ok\\\": false, \\\"reason\\\": \\\"Was noch fehlt...\\\"}"
          }
        ]
      }
    ]
  }
}

# Hook-Typen erklärt:
# "command" → führt Shell-Befehl aus (deterministisch)
# "prompt"  → fragt ein LLM (Standard: Haiku) für Ja/Nein-Entscheidung
# "agent"   → spawnt einen Subagent mit Tool-Zugriff
# "http"    → POSTet Event-Daten an eine URL

# WICHTIG: Stop-Hooks können Endlosschleifen verursachen!
# Das Modell muss stop_hook_active prüfen.

The sample prompt is German: it instructs the model to check whether all tasks are complete and, if not, to answer with {"ok": false, "reason": "what is still missing"}. Write your own prompt in English; the shape of the answer is what matters. There are four hook types in total: command runs a shell command and is fully deterministic, prompt makes a single LLM call (a small, fast model, Haiku by default) for a yes or no decision, agent spawns a subagent with tool access, and http POSTs the event data to a URL. One warning: Stop hooks can cause endless loops, so the hook must respect stop_hook_active. You will find the pattern for that in the troubleshooting section below.

Recipe 10: An agent hook that runs your tests before stopping

Problem: Claude claims the implementation is finished, but the tests do not pass.

# Agent-basierter Stop-Hook: Tests müssen grün sein
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Führe die Test-Suite aus und verifiziere, dass alle Tests bestehen. $ARGUMENTS",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

# Unterschied prompt vs. agent:
# prompt: Ein einzelner LLM-Aufruf, bewertet nur die Hook-Daten
# agent:  Spawnt Subagent mit echtem Tool-Zugriff
#         (Dateien lesen, Befehle ausführen, Code durchsuchen)
#
# Nutzen Sie "prompt" wenn die Hook-Eingabedaten reichen.
# Nutzen Sie "agent" wenn Sie gegen den echten Code-Zustand prüfen müssen.

The German prompt means “Run the test suite and verify that all tests pass.” The timeout caps the check at 120 seconds. The difference between prompt and agent matters here: a prompt hook is a single LLM call that only sees the hook data, while an agent hook spawns a subagent with real tool access that can read files, run commands and search the code. Use prompt when the hook input is enough to decide, and agent when you need to verify against the actual state of the code.

Troubleshooting

Hooks can be frustrating when they refuse to work. These are the pitfalls I see most often:

  • The hook does not fire: check with /hooks whether it is registered, remember that matchers are case-sensitive (bash does not match Bash), and keep in mind that PreToolUse runs before the tool while PostToolUse runs after it.
  • jq: command not found: install jq through your package manager.
  • JSON parsing errors: your shell profile prints text that gets prepended to the JSON payload; guard echo statements so they only run in interactive shells.
  • A Stop hook runs forever: check stop_hook_active in the hook input and exit with 0 when it is true.
  • The script does not execute: make it executable with chmod +x.
  • Debugging: press Ctrl+O for verbose mode or start Claude Code with claude --debug.
# Problem: Hook wird nicht ausgelöst
# 1. Prüfen Sie mit /hooks ob der Hook sichtbar ist
# 2. Matcher sind case-sensitive: "bash" ≠ "Bash"
# 3. PreToolUse läuft VOR dem Tool, PostToolUse DANACH

# Problem: "jq: command not found"
brew install jq        # macOS
apt-get install jq     # Debian/Ubuntu

# Problem: JSON-Parsing-Fehler
# Shell-Profil gibt Text aus, der dem JSON vorangestellt wird.
# Lösung: Echo nur in interaktiven Shells:
if [[ $- == *i* ]]; then
  echo "Shell ready"
fi

# Problem: Stop-Hook läuft endlos
# stop_hook_active prüfen:
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Claude darf stoppen
fi

# Problem: Script wird nicht ausgeführt
chmod +x ./dein-script.sh

# Debugging: Ctrl+O für Verbose-Modus oder:
claude --debug

Conclusion

Hooks turn Claude Code from a reactive tool into a proactive workflow machine. The three hooks I recommend to everyone: auto-formatting after edits (because doing it by hand gets old fast), file protection for sensitive files (because accidents happen) and desktop notifications (because multitasking does not work without them).

Start with a single hook and build from there. You will quickly notice which additional automations make sense in your project. And keep in mind that hooks and CLAUDE.md complement each other: hooks guarantee that something happens, while CLAUDE.md gives Claude the context to make the right decisions.

Further reading