ai newsFr., 23. Okt.
EilmeldungKI-Traffic konvertiert besser als organischer Besuch – aber lautstark losjagen lohnt nicht

Claude Code Hooks: So lassen sich Anweisungen zuverlässig statt nur meistens erzwingen

Anweisungen in CLAUDE.md wie das automatische Formatieren nach Änderungen beachtet das Modell nicht immer. Hooks bieten einen deterministischen Weg, solche Aktionen bei bestimmten Events verlässlich auszuführen.

dev.toFreitag 00:17 · 23.10.DE13 Min. Lesezeit
Bild: dev.to

Veröffentlicht am 22. Oktober • Zuerst erschienen auf alexcloudstar.com

Eine Zeile in CLAUDE.md, die sagt, "nach jeder Bearbeitung immer den Formatter auszuführen", funktioniert meistens. Meistens ist das Problem. Das Modell liest die Anweisung, gewichtet sie gegen alles andere im Kontext und entscheidet sich manchmal, dass diese Bearbeitung zu klein ist, um sich die Mühe zu machen.

Hooks lösen das, indem sie dem Modell die Entscheidung abnehmen. Dieser Beitrag behandelt, was Claude Code Hooks sind, jedes Ereignis, das die Dokumentation auflistet, wie man sie konfiguriert, wie Exit-Codes und JSON-Ausgaben Claude steuern, und fünf Beispiele, die man in eine Settings-Datei einfügen kann. Ich habe die Details am 23. Oktober 2026 mit der offiziellen Hooks-Referenz und dem Hooks-Guide abgeglichen. Hooks ändern sich häufig, daher betrachte die Dokumentation als die maßgebliche Quelle, falls hier etwas nicht mehr aktuell ist.

Claude Code Hooks sind benutzerdefinierte Handler, die Claude Code automatisch an bestimmten Punkten seines Lebenszyklus ausführt, etwa vor einem Tool-Aufruf, nach einer Dateibearbeitung oder wenn Claude die Antwort beendet. Die meisten Hooks sind Shell-Befehle. Weil das Harness sie ausführt und nicht das Modell, feuern sie jedes Mal, was sie deterministisch macht.

Dieser letzte Punkt ist der Kern der Sache. Ein Skill oder eine CLAUDE.md-Regel ist ein Vorschlag, dem das Modell folgen kann. Ein Hook ist Code, der ausgeführt wird, egal ob sich das Modell daran erinnert oder nicht. Wenn du eine Regel erzwingen willst, schreibst du einen Hook. Wenn du eine Regel vorschlagen willst, schreibst du einen Skill.

Falls du noch dabei bist zu klären, wie sich Hooks zu Skills, Plugins und MCP-Servern verhalten, hat mein Guide zu Plugin- und Skills-Marketplaces die Kurzfassung, und dieser Beitrag ist die lange Version.

Die Hooks-Referenz listet 33 Ereignisse auf. Jedes davon ist ein Schlüssel, den du innerhalb des hooks-Objekts in einer Settings-Datei platzierst. Dies sind die Ereignisse, wie die Dokumentation sie auflistet, in der Reihenfolge, in der sie dort erscheinen.

Die meisten Leute brauchen PreToolUse, PostToolUse, Stop, SessionStart und Notification. Der Rest deckt engere Aufgaben ab wie das Auditieren von Konfigurationsänderungen oder das Reagieren auf Compaction.

Einige Details aus der Dokumentation, die dir Debugging-Zeit ersparen. PreToolUse und PostToolUse feuern bei jedem Tool-Aufruf in der agentic loop. Stop feuert immer dann, wenn Claude die Antwort beendet, nicht nur, wenn die Aufgabe wirklich fertig ist, und es feuert nicht bei Benutzerabbrüchen. Und ein PostToolUse-Hook kann nichts rückgängig machen, weil das Tool bereits ausgeführt wurde.

Hooks leben in JSON. Du kannst sie an mehreren Orten ablegen, und die Einträge werden über die Ebenen hinweg zusammengeführt, statt einander zu ersetzen.

Ich lege persönliche Annehmlichkeiten wie Benachrichtigungen in ~/.claude/settings.json und Team-Regeln wie einen Formatter in die committete.claude/settings.json.

Du kannst sehen, was aktiv ist, indem du /hooks am Claude-Code-Prompt eingibst. Das öffnet einen schreibgeschützten Browser deiner konfigurierten Hooks. Wenn ein neuer Hook nach ein paar Sekunden nicht erscheint, starte die Sitzung neu.

Drei Ebenen: ein Ereignis, eine Matcher-Gruppe und ein oder mehrere Handler.

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-bash.sh" } ] } ] } } Vollbildmodus öffnen Vollbildmodus beenden Wenn deine Settings-Datei bereits einen hooks-Schlüssel hat, füge das neue Ereignis als Geschwister der bestehenden hinzu. Jeder Ereignisname ist ein Schlüssel innerhalb des einzelnen hooks-Objekts. Das Überschreiben des gesamten Objekts ist der häufigste Weg, versehentlich einen Hook zu löschen, den man vergessen hatte.

Das type-Feld bestimmt, was der Handler tut.

Gemeinsame Felder sind if, timeout, statusMessage und once. Command-Hooks ergänzen args (exec-Form, ohne Shell), async, shell und onFailure. Timeouts betragen standardmäßig 600 Sekunden für command, http und mcp_tool, 30 für prompt und 60 für agent, also setze bei allem Langsamen ein timeout.

Das matcher-Feld filtert, wann eine Gruppe feuert. Wie es ausgewertet wird, hängt davon ab, was es enthält.

Wobei der Matcher verglichen wird, hängt vom Ereignis ab. Bei PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest und PermissionDenied ist es der Tool-Name, etwa Bash, Edit oder mcp__memory__.*. MCP-Tools heißen mcp__<server>__<tool>, und um jedes Tool eines Servers zu treffen, braucht man.*.

Bei SessionStart ist es, wie die Sitzung gestartet wurde: startup, resume, clear, compact oder fork. Bei Notification ist es der Benachrichtigungstyp, etwa permission_prompt oder idle_prompt. UserPromptSubmit, Stop und einige andere nehmen keinen Matcher und feuern immer.

Bei Tool-Ereignissen gibt es außerdem ein if-Feld, das die Syntax von Berechtigungsregeln verwendet, etwa "Bash(git *)" oder "Edit(*.ts)". Es enthält eine Regel und keine booleschen Operatoren. Die Dokumentation nennt es best-effort und empfiehlt, das Berechtigungssystem zu nutzen, wenn man ein hartes Allow oder Deny braucht.

Ein Command-Hook kommuniziert über seinen Exit-Code, stdout und stderr mit Claude Code.

Welche Ereignisse tatsächlich blockiert werden können, ist wichtig. Laut Dokumentation blockiert exit 2 bei PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange (außer policy settings), PostToolBatch, PreCompact, PreModelSwitch, Elicitation, ElicitationResult, WorktreeCreate und WorktreeRemove.

Es kann nicht blockieren bei PostToolUse, PostToolUseFailure, PermissionRequest (stattdessen eine decision verwenden), Notification, SessionStart, SubagentStart, SessionEnd, PostCompact und den anderen beobachtenden Ereignissen. Bei diesen zeigt exit 2 dir den stderr an und die Ausführung läuft weiter.

Ein Stolperstein aus dem Guide: exit 0 von einem PreToolUse-Hook genehmigt den Aufruf nicht. Der Tool-Aufruf durchläuft weiterhin den normalen Berechtigungsfluss.

Exit-Codes sind grob. Wenn du mehr brauchst, gib exit 0 zurück und gib ein einziges JSON-Objekt auf stdout aus. Gib nur das JSON aus. Alles andere auf stdout, etwa die Ausgabe eines Shell-Profils, zerstört das Parsing. Die Dokumentation rät, sich pro Hook auf einen Ansatz zu beschränken: exit 2 mit stderr oder exit 0 mit JSON.

Universelle Felder sind continue (auf false setzen, um Claude komplett zu stoppen), stopReason, systemMessage, suppressOutput und terminalSequence. Ereignisspezifische Entscheidungen liegen an verschiedenen Stellen. PreToolUse nutzt hookSpecificOutput mit permissionDecision auf allow, deny, ask oder defer, plus permissionDecisionReason. PostToolUse und Stop nutzen eine decision auf oberster Ebene: "block" mit einem reason.

{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Database writes are not allowed" } } Vollbildmodus öffnen Vollbildmodus beenden Ein Feld auf der falschen Ebene ist ein häufiger Grund, warum ein Hook stillschweigend nichts tut: permissionDecision gehört innerhalb von hookSpecificOutput, nicht auf die oberste Ebene.

Command-Hooks bekommen JSON auf stdin. Häufige Felder sind session_id, transcript_path, cwd, permission_mode und hook_event_name. Tool-Ereignisse ergänzen tool_name, tool_input und tool_use_id, wodurch ein Skript den Dateipfad oder Befehl findet, den Claude gerade verwenden will. SessionStart-Hooks bekommen zusätzlich ein source, und UserPromptSubmit-Hooks bekommen den Prompt-Text.

Umgebungsvariablen, die die Dokumentation für Hooks auflistet:

Verwende ${CLAUDE_PROJECT_DIR} in Skriptpfaden, damit sie aus jedem Verzeichnis funktionieren.

Dies sind Beispiele, angepasst vom offiziellen Hooks-Guide. Teste jedes in einem Wegwerf-Projekt, bevor du es in ein Repository einbindest, das dein Team nutzt.

Das Notification-Ereignis feuert, wenn Claude auf eine Eingabe oder Berechtigung wartet. Dies ist die macOS-Version aus dem Guide. Sie kommt in ~/.claude/settings.json.

{ "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'" } ] } ] } } Vollbildmodus öffnen Vollbildmodus beenden Der leere Matcher feuert bei jedem Benachrichtigungstyp. Um ihn einzugrenzen, setze den Matcher auf einen Typ wie permission_prompt oder idle_prompt. Der Guide nennt notify-send für Linux und eine PowerShell-Variante für Windows. Unter macOS, falls nichts erscheint: osascript läuft über den Script Editor, und die Dokumentation empfiehlt, in den Systemeinstellungen Benachrichtigungen für den Script Editor zu aktivieren.

Dieser PostToolUse-Hook führt Prettier auf der Datei aus, die Claude gerade geschrieben hat. Er kommt in die.claude/settings.json im Projektstamm.

{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" } ] } ] } } Vollbildmodus öffnen Vollbildmodus beenden jq zieht den Pfad aus dem stdin-JSON, und Prettier erledigt den Rest. Es braucht ein installiertes jq. Eine Grenze, die der Guide hervorhebt: Claude kann Dateien auch über Shell-Befehle ändern, was ein Edit|Write-Matcher nie sieht. Wenn dein Hook jede Änderung sehen muss, füge einen Stop-Hook hinzu, der den Arbeitsbaum einmal pro Runde prüft.

Dieser PreToolUse-Hook beendet sich mit exit 2, wenn ein Befehl einem Muster entspricht, das Claude niemals ausführen soll. Das Skript des Guides blockiert Bearbeitungen geschützter Dateien mit demselben Mechanismus. Ich habe es angepasst, um bei Bash tool_input.command zu lesen.

Speichere dies als.claude/hooks/block-dangerous.sh:

#!/bin/bash # block-dangerous.sh (Beispiel angepasst aus der protect-files.sh des Hooks-Guides) INPUT = $( cat ) COMMAND = $( echo " $INPUT " | jq -r '.tool_input.command // empty' ) BLOCKED_PATTERNS =( "rm -rf" "git push --force" "DROP TABLE" ) for pattern in " ${ BLOCKED_PATTERNS [@] } "; do if [[ " $COMMAND " == * " $pattern " * ]]; then echo "Blocked: command matches ' $pattern '. Ask the user before running it." > &2 exit 2 fi done exit 0 Enter fullscreen mode Exit fullscreen mode Mach die Datei mit chmod +x.claude/hooks/block-dangerous.sh ausführbar und registriere sie dann:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": " \" $CLAUDE_PROJECT_DIR \" /.claude/hooks/block-dangerous.sh" } ] } ] } } Enter fullscreen mode Exit fullscreen mode Claude Code blockiert den Aufruf, bevor er ausgeführt wird, und reicht deine stderr-Meldung an Claude weiter, damit es einen anderen Ansatz wählen kann. Substring-Matching ist grob. Ein Befehl wie rm -rf mit zwei Leerzeichen schlüpft daran vorbei. Behandle das als Stolperdraht für ehrliche Fehler, nicht als Sicherheitsgrenze, und lies den nächsten Abschnitt, bevor du dich darauf verlässt.

Die Dokumentation ergänzt eine nützliche Tatsache: Ein PreToolUse-Hook, der ein Deny zurückgibt, blockiert das Tool selbst im bypassPermissions-Modus oder mit --dangerously-skip-permissions. Deshalb lassen sich Hooks gut mit der Berechtigungskonfiguration aus meinem Least-Privilege-Guide kombinieren.

Compaction fasst ein langes Gespräch zusammen und kann Details verlieren. Ein SessionStart-Hook mit dem compact-Matcher bringt wichtigen Kontext zurück. Dies ist die Version aus dem Guide, in.claude/settings.json.

{ "hooks": { "SessionStart": [ { "matcher": "compact", "hooks": [ { "type": "command", "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'" } ] } ] } } Enter fullscreen mode Exit fullscreen mode Einfacher Text, den ein SessionStart-Befehl nach stdout schreibt, landet im Kontext von Claude. Tausche das echo gegen etwas Dynamisches, etwa git log --oneline -5. Die Dokumentation weist darauf hin, dass CLAUDE.md der bessere Ort ist, wenn du denselben Kontext bei jedem Sitzungsstart willst. Nutze den Hook, wenn der Kontext dynamisch ist oder nur zu einem bestimmten Zeitpunkt gebraucht wird. Ändere den Matcher zu startup, um stattdessen bei einer frischen Sitzung einzuspeisen.

Ein Stop-Hook kann verweigern, dass Claude fertig wird. Dieser führt die Testsuite aus und beendet sich bei einem Fehler mit Exit-Code 2 samt Fehlerausgabe, damit Claude weiterarbeitet. Es ist ein Beispiel, das ich aus zwei Bestandteilen der Doku gebaut habe: dem Exit-2-Verhalten für Stop und der stop_hook_active-Prüfung aus dem Guide.

Speichere dies als.claude/hooks/tests-on-stop.sh:

#!/bin/bash # tests-on-stop.sh (Beispiel) INPUT = $( cat ) # Stop-Hooks können sich selbst erneut auslösen. Brich ab, wenn wir schon einmal blockiert haben. if [ " $( echo " $INPUT " | jq -r '.stop_hook_active' ) " = "true" ]; then exit 0 fi cd " $CLAUDE_PROJECT_DIR " || exit 0 if! OUTPUT = $( bun test 2>&1 ); then echo "Tests are failing. Fix them before finishing:" > &2 echo " $OUTPUT " | tail -n 30 > &2 exit 2 fi exit 0 Enter fullscreen mode Exit fullscreen mode Ersetze bun test durch deinen eigenen Testbefehl. Registriere ihn dann:

{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": " \" $CLAUDE_PROJECT_DIR \" /.claude/hooks/tests-on-stop.sh", "timeout": 300 } ] } ] } } Enter fullscreen mode Exit fullscreen mode Die stop_hook_active-Sperre ist wichtig. Die Doku sagt, dass Claude Code einen Stop-Hook außer Kraft setzt, nachdem er achtmal hintereinander ohne dazwischenliegenden Tool-Aufruf blockiert hat, und das Limit lässt sich mit CLAUDE_CODE_STOP_HOOK_BLOCK_CAP anheben. Da Stop nach jeder Antwort feuert, wird eine langsame Testsuite öfter laufen, als du erwartest. Für einen leichteren Ansatz zeigt der Guide einen Prompt-Hook auf Stop, der ein Modell fragt, ob alle angeforderten Aufgaben erledigt sind.

Ein Command-Hook ist ein Shell-Befehl, der als dein Benutzer läuft, mit deiner Umgebung und Zugriff auf deine Dateien. Es gibt keine Sandbox darum herum, es sei denn, du baust eine. Das hat einige Konsequenzen.

Lies jeden Hook, den du nicht selbst geschrieben hast. Ein Hook in einer gemeinsamen.claude/settings.json oder in der hooks/hooks.json eines Plugins läuft auf deiner Maschine, sobald du dem Projekt vertraust oder das Plugin aktivierst. Ein Repo zu klonen und seine Einstellungen zu akzeptieren bedeutet, seine Befehle auszuführen. Die Doku sagt, dass Projekt-Hooks in Settings-Dateien und Frontmatter der Workspace-Trust unterliegen und dass Frontmatter-Hooks in Projekt-Subagents erst nach Bestätigung des Trust-Dialogs laufen. Behandle diesen Dialog so wie das Ausführen eines Installationsskripts.

Zitiere deine Variablen. Die Hook-Eingabe enthält Dateipfade und Befehle, die das Modell gewählt hat. Ein Pfad mit einem Leerzeichen oder Shell-Metazeichen bricht ein nicht zitiertes Skript oder führt schlimmstenfalls etwas aus, das du nicht beabsichtigt hast. Die Doku empfiehlt die exec-Form mit args für Pfade und weist darauf hin, dass die Shell-Form sorgfältiges Quoting erfordert.

Verwechsele if oder Substring-Matching nicht mit Durchsetzung. Das if-Feld ist nach Aussage der Doku selbst ein Best-Effort-Mechanismus. Für harte Regeln nutze Permission-Regeln und Managed Settings, und setze Hooks als eine zusätzliche Schicht ein.

Für Teams bieten Managed Settings zusätzliche Kontrollen. allowManagedHooksOnly blockiert Benutzer-, Projekt-, lokale und Plugin-Hooks, und allowedHttpHookUrls begrenzt, welche URLs HTTP-Hooks erreichen können. Ich würde außerdem die Plugin-Sicherheitsseite der Doku lesen, bevor ich einen Marketplace von Drittanbietern aktiviere, da Plugin-Hooks mit derselben Autorität laufen wie die, die du selbst schreibst.

Diese vier werden verwechselt, weil sie alle Claude sagen, was zu tun ist. Sie unterscheiden sich darin, wer die Regel durchsetzt.

Die praktische Aufteilung: CLAUDE.md für Konventionen, Skills für Workflows, Permissions für die erlaubte Oberfläche und Hooks für alles, was unabhängig vom Modell passieren muss oder nicht passieren darf. Wenn ich mich dabei ertappe, dass ich in einer CLAUDE.md-Regel always oder never schreibe und sie trotzdem ignoriert wird, will diese Regel wahrscheinlich ein Hook sein. Über das Bauen von Skills habe ich im Skill-Developer-Guide geschrieben.

Ein Notification-Hook ist ein Verwandter der Statuszeile, die den Zustand kontinuierlich anzeigt statt dich einmal zu alarmieren. Und SubagentStart und SubagentStop lassen dich sich in delegierte Arbeit einklinken, was im Subagents-Guide behandelt wird.

Wenn ein Hook nichts tut, führe /hooks aus, um zu bestätigen, dass er gelistet ist, validiere das JSON (keine abschließenden Kommas), prüfe, ob das Skript ausführbar ist, und stelle sicher, dass nichts anderes in dein JSON vorab nach stdout schreibt. Leite Beispiel-JSON in ein Skript, um es außerhalb einer Sitzung zu testen, etwa echo '{"tool_input":{"command":"rm -rf /tmp/x"}}' |.claude/hooks/block-dangerous.sh, und prüfe dann echo $?.

Was sind Claude Code Hooks? Das sind benutzerdefinierte Handler, die Claude Code bei bestimmten Lebenszyklus-Ereignissen ausführt, etwa vor einem Tool-Aufruf, nach einer Bearbeitung oder wenn Claude seine Antwort beendet. Die meisten sind Shell-Befehle, und weil das Harness sie ausführt, feuern sie jedes Mal, statt vom Modell abzuhängen.

Wo lege ich Claude Code Hooks ab? In einer Settings-Datei: ~/.claude/settings.json für alle Projekte,.claude/settings.json für ein Projekt (committed) oder.claude/settings.local.json für ein Projekt (gitignored). Plugins können sie in hooks/hooks.json mitliefern, und Skills und Subagents können sie im Frontmatter definieren.

Wie blockiere ich einen Tool-Aufruf mit einem Hook? Nutze einen PreToolUse-Hook. Beende ihn mit Exit-Code 2 und schreibe den Grund nach stderr, oder beende ihn mit 0 und gib JSON aus, in dem permissionDecision innerhalb von hookSpecificOutput auf deny gesetzt ist. In beiden Fällen stoppt Claude Code den Aufruf, bevor er läuft.

Kann ein Hook laufen, wenn Claude eine Aufgabe abschließt? Ja, mit dem Stop-Event. Es feuert, wann immer Claude eine Antwort beendet, nicht nur beim Abschluss einer Aufgabe, daher prüfe stop_hook_active in der Eingabe, um Schleifen zu vermeiden.

Funktionieren Hooks mit --dangerously-skip-permissions? Ein PreToolUse-Hook, der einen Aufruf verweigert, blockiert ihn weiterhin im bypassPermissions-Modus oder mit diesem Flag, so der Hooks-Guide. Das macht Hooks nützlich für Richtlinien, die niemand durch Ändern des Permission-Modus abschalten kann.

Quellen: Claude Code hooks reference und Automate actions with hooks (Hooks-Guide), geprüft am 23. Oktober 2026.

Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.

Original (Englisch) anzeigen

Posted on Oct 22 • Originally published at alexcloudstar.com

A line in CLAUDE.md that says "always run the formatter after editing" works most of the time. Most of the time is the problem. The model reads the instruction, weighs it against everything else in the context, and sometimes decides this edit is too small to bother.

Hooks fix that by taking the decision away from the model. This post covers what Claude Code hooks are, every event the docs list, how to configure them, how exit codes and JSON output control Claude, and five examples you can paste into a settings file. I checked the details against the official hooks reference and hooks guide on October 23, 2026. Hooks change often, so treat the docs as the source of truth if anything here has drifted.

Claude Code hooks are user-defined handlers that Claude Code runs automatically at specific points in its lifecycle, such as before a tool call, after a file edit, or when Claude finishes responding. Most hooks are shell commands. Because the harness runs them, not the model, they fire every time, which makes them deterministic.

That last part is the whole point. A skill or a CLAUDE.md rule is a suggestion the model may follow. A hook is code that runs whether the model remembers it or not. If you want a rule enforced, you write a hook. If you want a rule suggested, you write a skill.

If you are still sorting out how hooks sit next to skills, plugins and MCP servers, my plugin and skills marketplace guide has the short version, and this post is the long one.

The hooks reference lists 33 events. Each one is a key you put inside the hooks object in a settings file. These are the events as the docs list them, in the order they appear there.

Most people need PreToolUse, PostToolUse, Stop, SessionStart and Notification. The rest cover narrower jobs like auditing config changes or reacting to compaction.

A few details from the docs that save you debugging time. PreToolUse and PostToolUse fire on every tool call in the agentic loop. Stop fires whenever Claude finishes responding, not only when the task is truly done, and it does not fire on user interrupts. And a PostToolUse hook cannot undo anything, because the tool has already run.

Hooks live in JSON. You can put them in several places, and the entries merge across levels instead of replacing each other.

I put personal conveniences like notifications in ~/.claude/settings.json, and team rules like a formatter in the committed.claude/settings.json.

You can browse what is active by typing /hooks at the Claude Code prompt. It opens a read-only browser of your configured hooks. If a new hook does not appear after a few seconds, restart the session.

Three levels: an event, a matcher group, and one or more handlers.

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-bash.sh" } ] } ] } } Enter fullscreen mode Exit fullscreen mode If your settings file already has a hooks key, add the new event as a sibling of the existing ones. Each event name is a key inside the single hooks object. Overwriting the whole object is the most common way to delete a hook you forgot you had.

The type field decides what the handler does.

Shared fields include if, timeout, statusMessage and once. Command hooks add args (exec form, no shell), async, shell and onFailure. Timeouts default to 600 seconds for command, http and mcp_tool, 30 for prompt and 60 for agent, so set timeout on anything slow.

The matcher field filters when a group fires. How it is evaluated depends on what it contains.

What the matcher is compared against depends on the event. For PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied, it is the tool name, like Bash, Edit or mcp__memory__.*. MCP tools are named mcp__<server>__<tool>, and matching every tool from one server needs the.*.

For SessionStart it is how the session started: startup, resume, clear, compact or fork. For Notification it is the notification type, such as permission_prompt or idle_prompt. UserPromptSubmit, Stop and several others take no matcher and always fire.

On tool events there is also an if field that uses permission-rule syntax, like "Bash(git *)" or "Edit(*.ts)". It holds one rule and no boolean operators. The docs call it best-effort and say to use the permission system when you need a hard allow or deny.

A command hook talks to Claude Code through its exit code, stdout and stderr.

Which events can actually be blocked matters. By the docs, exit 2 blocks on PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange (except policy settings), PostToolBatch, PreCompact, PreModelSwitch, Elicitation, ElicitationResult, WorktreeCreate and WorktreeRemove.

It cannot block PostToolUse, PostToolUseFailure, PermissionRequest (use a decision instead), Notification, SessionStart, SubagentStart, SessionEnd, PostCompact and the other observational events. On those, exit 2 shows stderr to you and execution continues.

One gotcha from the guide: exit 0 from a PreToolUse hook does not approve the call. The tool call still goes through the normal permission flow.

Exit codes are blunt. If you need more, exit 0 and print one JSON object to stdout. Print only the JSON. Anything else on stdout, like output from a shell profile, breaks parsing. The docs say to pick one approach per hook: exit 2 with stderr, or exit 0 with JSON.

Universal fields are continue (set false to stop Claude entirely), stopReason, systemMessage, suppressOutput and terminalSequence. Event-specific decisions go in different places. PreToolUse uses hookSpecificOutput with permissionDecision set to allow, deny, ask or defer, plus permissionDecisionReason. PostToolUse and Stop use a top-level decision: "block" with a reason.

{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Database writes are not allowed" } } Enter fullscreen mode Exit fullscreen mode A field at the wrong level is a common reason a hook silently does nothing: permissionDecision belongs inside hookSpecificOutput, not at the top.

Command hooks get JSON on stdin. Common fields include session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name, tool_input and tool_use_id, which is how a script finds the file path or command Claude is about to use. SessionStart hooks also get a source, and UserPromptSubmit hooks get the prompt text.

Environment variables the docs list for hooks:

Use ${CLAUDE_PROJECT_DIR} in script paths so they work from any directory.

These are examples, adapted from the official hooks guide. Test each in a throwaway project before you commit it to a repo your team uses.

The Notification event fires when Claude is waiting for input or permission. This is the guide's macOS version. Put it in ~/.claude/settings.json.

{ "hooks": { "Notification": [ { "matcher": "", "hooks": [ { "type": "command", "command": "osascript -e 'display notification \" Claude Code needs your attention \" with title \" Claude Code \" '" } ] } ] } } Enter fullscreen mode Exit fullscreen mode The empty matcher fires on every notification type. To narrow it, set the matcher to a type like permission_prompt or idle_prompt. The guide gives notify-send for Linux and a PowerShell variant for Windows. On macOS, if nothing appears, osascript routes through Script Editor, and the docs say to enable notifications for Script Editor in System Settings.

This PostToolUse hook runs Prettier on the file Claude just wrote. It goes in.claude/settings.json in the project root.

{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" } ] } ] } } Enter fullscreen mode Exit fullscreen mode jq pulls the path out of the stdin JSON and Prettier does the rest. It needs jq installed. One limit the guide calls out: Claude can also change files through shell commands, which an Edit|Write matcher never sees. If your hook must see every change, add a Stop hook that checks the working tree once per turn.

This PreToolUse hook exits 2 when a command matches a pattern you never want Claude to run. The guide's own script blocks edits to protected files with the same mechanism. I adapted it to read tool_input.command for the Bash tool.

Save this as.claude/hooks/block-dangerous.sh:

#!/bin/bash # block-dangerous.sh (example adapted from the hooks guide's protect-files.sh) INPUT = $( cat ) COMMAND = $( echo " $INPUT " | jq -r '.tool_input.command // empty' ) BLOCKED_PATTERNS =( "rm -rf" "git push --force" "DROP TABLE" ) for pattern in " ${ BLOCKED_PATTERNS [@] } "; do if [[ " $COMMAND " == * " $pattern " * ]]; then echo "Blocked: command matches ' $pattern '. Ask the user before running it." > &2 exit 2 fi done exit 0 Enter fullscreen mode Exit fullscreen mode Make it executable with chmod +x.claude/hooks/block-dangerous.sh, then register it:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": " \" $CLAUDE_PROJECT_DIR \" /.claude/hooks/block-dangerous.sh" } ] } ] } } Enter fullscreen mode Exit fullscreen mode Claude Code blocks the call before it runs and passes your stderr message to Claude, so it can pick another approach. Substring matching is crude. A command like rm -rf with two spaces slips past it. Treat this as a tripwire for honest mistakes, not as a security boundary, and read the next section before you rely on it.

The docs add one useful fact: a PreToolUse hook that returns a deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. That is why hooks pair well with the permission setup in my least-privilege guide.

Compaction summarizes a long conversation and can lose details. A SessionStart hook with the compact matcher puts critical context back. This is the guide's version, in.claude/settings.json.

{ "hooks": { "SessionStart": [ { "matcher": "compact", "hooks": [ { "type": "command", "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'" } ] } ] } } Enter fullscreen mode Exit fullscreen mode Plain text a SessionStart command writes to stdout goes into Claude's context. Swap the echo for something dynamic, like git log --oneline -5. The docs point out that if you want the same context on every session start, CLAUDE.md is the better home. Use the hook when the context is dynamic or only needed at a specific moment. Change the matcher to startup to inject on a fresh session instead.

A Stop hook can refuse to let Claude finish. This one runs the test suite and, if it fails, exits 2 with the failure output so Claude keeps working. It is an example I built from two pieces of the docs: the exit-2 behavior for Stop, and the guide's stop_hook_active check.

Save this as.claude/hooks/tests-on-stop.sh:

#!/bin/bash # tests-on-stop.sh (example) INPUT = $( cat ) # Stop hooks can re-trigger themselves. Bail out if we already blocked once. if [ " $( echo " $INPUT " | jq -r '.stop_hook_active' ) " = "true" ]; then exit 0 fi cd " $CLAUDE_PROJECT_DIR " || exit 0 if! OUTPUT = $( bun test 2>&1 ); then echo "Tests are failing. Fix them before finishing:" > &2 echo " $OUTPUT " | tail -n 30 > &2 exit 2 fi exit 0 Enter fullscreen mode Exit fullscreen mode Swap bun test for your own test command. Then register it:

{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": " \" $CLAUDE_PROJECT_DIR \" /.claude/hooks/tests-on-stop.sh", "timeout": 300 } ] } ] } } Enter fullscreen mode Exit fullscreen mode The stop_hook_active guard matters. The docs say Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call in between, and the cap can be raised with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. Because Stop fires after every response, a slow test suite will run more often than you expect. For a lighter touch, the guide shows a prompt hook on Stop that asks a model whether all requested tasks are done.

A command hook is a shell command that runs as your user, with your environment and access to your files. There is no sandbox around it unless you build one. That has a few consequences.

Read every hook you did not write. A hook in a shared.claude/settings.json or a plugin's hooks/hooks.json runs on your machine once you trust the project or enable the plugin. Cloning a repo and accepting its settings means running its commands. The docs say project hooks in settings files and frontmatter are subject to workspace trust, and frontmatter hooks in project subagents run only after you accept the trust dialog. Treat that dialog the way you treat running an install script.

Quote your variables. The hook input contains file paths and commands the model chose. A path with a space or shell metacharacter in it will break an unquoted script or, worse, run something you did not intend. The docs recommend exec form with args for paths, and they note shell form needs careful quoting.

Do not mistake if or substring matching for enforcement. The if field is best-effort by the docs' own words. For hard rules, use permission rules and managed settings, and use hooks as one more layer.

For teams, managed settings add controls. allowManagedHooksOnly blocks user, project, local and plugin hooks, and allowedHttpHookUrls limits which URLs HTTP hooks can reach. I would also read the plugin security page in the docs before enabling any third-party marketplace, since plugin hooks run with the same authority as the ones you write yourself.

These four get confused because they all "tell Claude what to do". They differ in who enforces the rule.

The practical split: use CLAUDE.md for conventions, skills for workflows, permissions for the allowed surface, and hooks for anything that must happen or must not happen regardless of the model. If I find myself writing "always" or "never" in a CLAUDE.md rule and it keeps getting ignored, that rule probably wants to be a hook. I wrote about building skills in the skill developer guide.

A Notification hook is a cousin of the status line, which shows state continuously instead of alerting you once. And SubagentStart and SubagentStop let you hook into delegated work, which is covered in the subagents guide.

If a hook does nothing, run /hooks to confirm it is listed, validate the JSON (no trailing commas), check the script is executable, and make sure nothing else prints to stdout before your JSON. Pipe sample JSON into a script to test it outside a session, for example echo '{"tool_input":{"command":"rm -rf /tmp/x"}}' |.claude/hooks/block-dangerous.sh, then check echo $?.

What are Claude Code hooks? They are user-defined handlers that Claude Code runs at specific lifecycle events, such as before a tool call, after an edit, or when Claude finishes responding. Most are shell commands, and because the harness runs them, they fire every time instead of depending on the model.

Where do I put Claude Code hooks? In a settings file: ~/.claude/settings.json for all projects,.claude/settings.json for one project (committed), or.claude/settings.local.json for one project (gitignored). Plugins can ship them in hooks/hooks.json, and skills and subagents can define them in frontmatter.

How do I block a tool call with a hook? Use a PreToolUse hook. Exit with code 2 and write the reason to stderr, or exit 0 and print JSON with permissionDecision set to deny inside hookSpecificOutput. Either way, Claude Code stops the call before it runs.

Can a hook run when Claude finishes a task? Yes, with the Stop event. It fires whenever Claude finishes responding, not only at task completion, so check stop_hook_active in the input to avoid loops.

Do hooks work with --dangerously-skip-permissions? A PreToolUse hook that denies a call still blocks it in bypassPermissions mode or with that flag, according to the hooks guide. That makes hooks useful for policy people cannot switch off by changing permission mode.

Sources: Claude Code hooks reference and Automate actions with hooks (hooks guide), checked October 23, 2026.

Originaltitel: Claude Code Hooks: Every Event, the Config Format and Five Working ExamplesQuelle: dev.to (dev.to). Original öffnen