Ein Hook ist ein Stück Automatik, das Claude Code an einem fest definierten Punkt seines Lebenszyklus ausführt — ohne dass das Modell entscheidet, ob es passiert. Genau darin liegt der Unterschied zu einer Regel in CLAUDE.md: Ein Hook läuft, weil das Ereignis eingetreten ist, nicht weil Claude sich daran erinnert. Command-Hooks bekommen die Ereignisdaten als JSON auf stdin und antworten über Exit-Code und stdout; Prompt- und Agent-Hooks legen die Entscheidung dagegen einem Claude-Modell vor und sind damit nicht deterministisch. Dieses Modul zeigt, wie die Konfiguration aufgebaut ist, welche der 30 Ereignisse du im Alltag wirklich brauchst, wie ein Hook blockiert, Kontext nachschiebt oder eine Tool-Ausgabe ersetzt — und wo seine Grenzen liegen.
Wie Hooks konfiguriert werden und wann sie feuern
Die Konfiguration steht in einer Einstellungsdatei unter dem Schlüssel hooks und ist drei Ebenen tief: zuerst das Ereignis, darin eine Matcher-Gruppe, die einschränkt, wann es zählt, und darin die Handler, die tatsächlich laufen. Wie der matcher gelesen wird, hängt von seinen Zeichen ab. Enthält er nur Buchstaben, Ziffern, Unterstrich, Bindestrich, Leerzeichen, Komma oder senkrechten Strich, vergleicht Claude Code exakt — Bash trifft genau dieses Tool, Edit|Write genau diese beiden. Kommt ein anderes Zeichen vor, wird der Wert als nicht verankerter regulärer Ausdruck ausgewertet: mcp__github__.* trifft damit alle Tools des GitHub-MCP-Servers, Edit.* aber eben auch NotebookEdit. Ein leerer Matcher, ein weggelassener oder * trifft jedes Vorkommen des Ereignisses:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/validate-bash.py\"",
"timeout": 10
}
]
}
]
}
}
Feiner filtert das Feld if, das seit v2.1.85 an einem einzelnen Handler steht und die Syntax der Berechtigungsregeln verwendet. Der matcher sieht nur den Tool-Namen; if prüft Namen und Argumente zusammen, sodass der Hook-Prozess gar nicht erst startet, wenn der konkrete Aufruf nicht passt. Das lohnt sich überall dort, wo dich nur ein Ausschnitt der Aufrufe eines Tools interessiert — etwa jedes git push, aber nicht jeder Bash-Befehl:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git push*)",
"hooks": [
{
"type": "command",
"command": "/path/to/check-push.sh"
}
]
}
]
}
}
Ein Handler trägt dabei genau eine Regel; && oder Listen kennt das Feld nicht, mehrere Bedingungen bedeuten also mehrere Handler. Bei Bash-Mustern zerlegt Claude Code die Befehlszeile: Führende Variablenzuweisungen fallen weg, jede Teilkette hinter && und jeder Befehl in $() oder Backticks wird einzeln geprüft, und ein Muster, das mehr als den Befehlsnamen nennt, lässt den Hook bei solchen Ersetzungen vorsichtshalber laufen. Lässt sich die Zeile nicht parsen, läuft er ebenfalls — der Filter ist absichtlich fail-open und deshalb kein Ersatz für eine Berechtigungsregel. Außerhalb der Tool-Ereignisse PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest und PermissionDenied bewirkt ein gesetztes if das Gegenteil von dem, was man erwartet: Der Hook läuft dort nie.
Insgesamt kennt Claude Code 30 Hook-Ereignisse, doch der Alltag kommt mit vieren aus. PreToolUse prüft, bevor ein Tool läuft, und kann es verhindern. PostToolUse reagiert danach, kann Kontext nachschieben oder die Ausgabe ersetzen. UserPromptSubmit greift eine Eingabe ab, bevor Claude sie verarbeitet. Stop läuft, wenn Claude mit dem Antworten fertig ist. Die übrigen gruppieren sich um Berechtigungen (PermissionRequest, PermissionDenied), den Sitzungs- und Subagent-Lebenszyklus (SessionStart, SessionEnd, SubagentStart, SubagentStop), Fehlerfälle (PostToolUseFailure, StopFailure), Kompaktierung, Worktrees, Konfigurations- und Dateiänderungen sowie Anzeige und Benachrichtigung.
Einige dieser Ereignisse sind noch jung und lohnen einen zweiten Blick. CwdChanged und FileChanged kamen mit v2.1.83 und machen die Umgebung reaktiv: Das eine feuert bei jedem Verzeichniswechsel, das andere bei Änderungen an den Dateien, die sein matcher als Namensliste aufzählt. Beide schreiben typischerweise nach CLAUDE_ENV_FILE, das Claude Code vor jedem Bash-Befehl als Präambel ausführt — so ziehen Werkzeuge wie direnv auch in Claudes Shell nach. TaskCreated (v2.1.84) feuert, wenn ein Task über TaskCreate entsteht, und kann seine Anlage mit Exit-Code 2 zurückrollen. Elicitation und ElicitationResult (v2.1.76) sitzen um den Dialog herum, mit dem ein MCP-Server mitten in einem Tool-Aufruf strukturierte Eingaben erfragt: Das erste kann ihn beantworten oder ablehnen, bevor er dir überhaupt angezeigt wird, das zweite die Antwort überschreiben, bevor sie zurückgeht. WorktreeCreate gibt es dagegen schon seit v2.1.50, und es ist kein Benachrichtigungs-Ereignis: Es ersetzt die eingebaute git-Logik, legt den Arbeitsbereich selbst an und gibt dessen Pfad zurück — genau das macht Worktree-Isolation auch unter SVN, Perforce oder Mercurial möglich. Seit v2.1.84 geht das auch mit einem HTTP-Handler, der den Pfad als hookSpecificOutput.worktreePath liefert.
PreCompact läuft unmittelbar bevor Claude Code die Konversation zusammenfasst, um Kontext freizugeben — und gehört zu den Ereignissen, die man tatsächlich verhindern kann. Sein matcher unterscheidet die Herkunft: manual steht für ein von dir ausgelöstes /compact, auto für die automatische Kompaktierung, wenn der Kontext voll läuft. Wer den Zustand vorher sichern oder eine Zusammenfassung mitten in einem Umbau abwenden will, hängt sich genau hier ein:
{
"hooks": {
"PreCompact": [
{
"matcher": "auto",
"hooks": [
{ "type": "command", "command": "./scripts/snapshot-context.sh" }
]
}
]
}
}
Blockiert wird auf zwei Wegen: Exit-Code 2 mit einer Begründung auf stderr, oder Exit 0 und ein JSON-Objekt mit "decision": "block" samt reason auf stdout. Beides zusammen geht nicht — bei Exit 2 ignoriert Claude Code jedes JSON und nimmt stderr als Grund. Das Gegenstück PostCompact feuert, nachdem die Zusammenfassung steht; es kann nichts mehr verhindern und ist der Ort, um verlorene Notizen erneut anzuhängen, einen Skill neu aufzurufen oder festzuhalten, was übrig geblieben ist.
Neben den JSON-Daten auf stdin erbt ein Hook-Prozess die Umgebung von Claude Code und bekommt einige Variablen zusätzlich gesetzt. CLAUDE_CODE_SESSION_ID trägt die Kennung der laufenden Session — dieselbe, die im JSON als session_id steht, und sie wechselt mit /clear; damit ordnest du Hook-Protokolle und externe Telemetrie derselben Session zu. ${CLAUDE_PROJECT_DIR} zeigt auf die Projektwurzel, ${CLAUDE_PLUGIN_ROOT} auf das Installationsverzeichnis eines Plugins, sodass ein Skript unabhängig vom Arbeitsverzeichnis gefunden wird. Ein Modellname steht dagegen nicht bereit: Eine Variable $CLAUDE_MODEL gibt es nicht, und nur SessionStart kann überhaupt ein model-Feld in der Eingabe sehen.
In Python sind Ereignisdaten und Session-Kennung damit in wenigen Zeilen beisammen:
import json, sys, os
data = json.load(sys.stdin)
tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
Die Antwort geht über den Exit-Code. 0 heißt „kein Einwand“ — Claude Code liest dann stdout als JSON, und bei UserPromptSubmit, UserPromptExpansion und SessionStart landet die Ausgabe direkt in Claudes Kontext. 2 ist der blockierende Fall: JSON wird ignoriert, stderr geht als Begründung an Claude, und was genau blockiert wird, hängt vom Ereignis ab — PreToolUse verhindert den Tool-Aufruf, Stop hält Claude am Arbeiten, SessionStart kann nichts verhindern und zeigt die Meldung nur an. Jeder andere Code ist ein nicht blockierender Fehler: Die Aktion läuft weiter, im Transkript erscheint eine Hinweiszeile mit der ersten stderr-Zeile. Für Skripte aus anderen Welten ist das die wichtigste Fußangel — die übliche Unix-Konvention gilt hier nicht, 1 blockiert nichts.
Wie gründlich ein Hook prüfen soll, kann er von der Effort-Stufe des Turns abhängig machen. Die Eingabe trägt dafür ein effort-Objekt mit dem Feld level, und derselbe Wert steht Hook-Befehlen und den von ihnen gestarteten Bash-Aufrufen als $CLAUDE_EFFORT zur Verfügung. Die Stufen sind low, medium, high, xhigh und max — eine Stufe auto gibt es nicht. Unterstützt das aktive Modell die angeforderte Stufe nicht, steht dort die tatsächlich verwendete, herabgestufte:
import json, os, sys
data = json.load(sys.stdin)
effort_level = data.get("effort", {}).get("level", "medium") # from JSON
effort_env = os.environ.get("CLAUDE_EFFORT", "medium") # from env var
Die fünf Handler-Typen und was sie zurückgeben
Command-Hooks gibt es in zwei Formen, und die Wahl entscheidet, ob eine Shell mitspielt. Ohne args gilt die Shell-Form: Der String aus command geht an sh -c, unter Windows an die Git-Bash oder ersatzweise an PowerShell, samt Pipes, &&, Umleitungen und Variablenexpansion. Sobald args gesetzt ist, gilt die Exec-Form: command ist dann nur noch die ausführbare Datei, jedes Element von args wird als genau ein Argument übergeben, und Anführungszeichen, Dollarzeichen oder Backticks reisen unverändert durch. Für jeden Hook, der einen Pfad-Platzhalter enthält, ist die Exec-Form die richtige Wahl — sie erspart das Quoten von Pfaden mit Leerzeichen:
{
"type": "command",
"command": "node",
"args": ["./scripts/validate.js", "--strict"]
}
Neben command kennt Claude Code vier weitere Handler-Typen. http schickt dieselbe JSON-Eingabe per POST an eine URL und liest die Antwort im gleichen Ausgabeformat; Header dürfen Umgebungsvariablen einsetzen, aber nur die, die in allowedEnvVars ausdrücklich aufgezählt sind. mcp_tool ruft ein Tool eines bereits verbundenen MCP-Servers auf — angegeben werden server, tool und ein input-Objekt, dessen String-Werte Platzhalter wie ${tool_input.file_path} aus der Hook-Eingabe einsetzen. prompt und agent legen die Entscheidung einem Modell vor; sie sind das Thema des letzten Abschnitts. Nicht jedes Ereignis unterstützt jeden Typ — SessionStart und Setup etwa nur command und mcp_tool. Der Konfigurationsassistent weiter unten auf dieser Seite erzeugt bislang ausschließlich command-Hooks; für die übrigen Typen ist dieses JSON die Vorlage:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "mcp_tool",
"server": "slack",
"tool": "send_message",
"input": { "channel": "#deploys", "text": "Claude finished the task" }
}
]
}
]
}
}
Wer wissen will, wo die Zeit hingeht, findet in den Eingaben von PostToolUse und PostToolUseFailure seit v2.1.119 das Feld duration_ms. Es misst die reine Ausführungszeit des Tools in Millisekunden und lässt Berechtigungsabfragen und die Laufzeit der PreToolUse-Hooks bewusst außen vor — es beschreibt also das Tool, nicht die Wartezeit davor.
Ob eine Session wirklich fertig ist, lässt sich am Ende eines Turns nicht am Antworttext ablesen. Deshalb tragen die Eingaben von Stop und SubagentStop seit v2.1.145 zwei Listen. background_tasks beschreibt jede noch laufende Hintergrundarbeit mit id, status und einem type wie shell, subagent, monitor oder workflow. session_crons führt die geplanten Aufwachpunkte der Session auf, die aus CronCreate, ScheduleWakeup und /loop stammen, jeweils mit schedule und der Angabe, ob sie wiederkehren. Ein Abschluss-Gate blockiert damit so lange, wie eine der beiden Listen nicht leer ist:
import json, sys
data = json.load(sys.stdin)
pending_bg = [t for t in data.get("background_tasks", []) if t.get("status") in ("running", "starting")]
pending_cron = data.get("session_crons", [])
if pending_bg or pending_cron:
print(json.dumps({
"decision": "block",
"reason": f"{len(pending_bg)} background task(s) and {len(pending_cron)} scheduled task(s) still active"
}))
sys.exit(0)
Zwei Feinheiten gehören dazu. Beide Listen beziehen sich auf die übergeordnete Session, auch wenn der Hook in einem Subagent feuert — ein SubagentStop-Gate sieht also die Hintergrundarbeit des ganzen Laufs, nicht nur die eigene. Und ein blockierender Stop-Hook braucht eine Abbruchbedingung: Die Eingabe trägt dafür stop_hook_active, und nach acht aufeinanderfolgenden Blockaden setzt Claude Code den Hook ohnehin außer Kraft.
Vier Muster aus dem Alltag
Das erste Muster ist zugleich das häufigste: formatieren, sobald Claude eine Datei angefasst hat. Ein PostToolUse-Hook mit dem Matcher Edit|Write holt sich den Pfad aus tool_input.file_path und schickt ihn an den Formatter, der zur Dateiendung passt. Claudes Ausgabe entspricht damit ohne Zutun dem Projektstil — und niemand muss daran denken:
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))")
case "$FILE" in
*.ts|*.tsx|*.js) prettier --write "$FILE" 2>/dev/null ;;
*.py) black "$FILE" 2>/dev/null ;;
*.go) gofmt -w "$FILE" 2>/dev/null ;;
esac
exit 0
Das zweite Muster meldet, statt zu verhindern. Ein PostToolUse-Hook durchsucht den frisch geschriebenen Inhalt nach Mustern, die nach Zugangsdaten aussehen, und gibt seinen Befund als additionalContext im hookSpecificOutput zurück. Claude Code hängt diesen Text als System-Erinnerung neben das Tool-Ergebnis; Claude liest ihn beim nächsten Modellaufruf und kann den Fund selbst ausräumen. Formuliere solche Texte als Feststellung, nicht als Befehl — als Systemanweisung getarnte Zeilen lösen Claudes Schutz gegen Prompt-Injection aus und werden dir dann vorgelegt, statt als Kontext zu wirken:
SECRET_PATTERNS = [
(r"api[_-]?key\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded API key"),
(r"password\s*=\s*['\"][^'\"]+['\"]", "Potential hardcoded password"),
]
# ... check content, then:
output = {"hookSpecificOutput": {"hookEventName": "PostToolUse",
"additionalContext": f"Security warnings: {'; '.join(warnings)}"}}
print(json.dumps(output))
Das dritte Muster geht einen Schritt weiter und ersetzt, was Claude überhaupt zu sehen bekommt: updatedToolOutput im hookSpecificOutput tauscht das Tool-Ergebnis aus, seit v2.1.121 für alle Tools statt nur für MCP-Tools. Zwei Dinge sind dabei entscheidend. Der Ersatzwert muss zur Ausgabeform des Tools passen — Bash etwa liefert ein Objekt mit stdout, stderr, interrupted und isImage, und ein Wert, der nicht dazu passt, wird bei eingebauten Tools stillschweigend verworfen. Und ersetzt wird ausschließlich die Wahrnehmung: Das Tool ist längst gelaufen, geschriebene Dateien und abgesetzte Befehle bleiben, wie sie sind. Wer eingreifen will, bevor etwas passiert, braucht PreToolUse. Das folgende Beispiel zeigt nur den Aufbau — für ein eingebautes Tool müsste der Ersatz die Felder dieses Tools mitbringen statt eines einfachen Textes, und das Eingabefeld heißt tool_response:
import json, sys
data = json.load(sys.stdin)
original = data.get("tool_response", "")
sanitized = original.replace("/home/user", "~")
output = {"hookSpecificOutput": {"updatedToolOutput": sanitized}}
print(json.dumps(output))
Das vierte Muster ist die Notbremse. Ein PreToolUse-Hook prüft den Befehl gegen eine Liste von Mustern und beendet sich mit Exit-Code 2, sobald eines greift; die Meldung auf stderr erklärt Claude, warum. Solche Hooks laufen vor jeder Prüfung des Berechtigungsmodus und damit in jedem Modus — auch unter bypassPermissions. Umgekehrt gilt das nicht: Ein allow aus einem Hook hebt keine deny-Regel aus den Einstellungen auf. Hooks können verschärfen, aber nicht lockern:
BLOCKED = [(r"\brm\s+-rf\s+/", "Blocking dangerous rm -rf /")]
for pattern, message in BLOCKED:
if re.search(pattern, command):
print(message, file=sys.stderr)
sys.exit(2)
Wenn ein Urteil gefragt ist: Prompt- und Agent-Hooks
Nicht jede Bedingung lässt sich als regulärer Ausdruck schreiben. Für Fälle, in denen ein Urteil gefragt ist, gibt es type: "prompt": Claude Code schickt deinen Prompt zusammen mit der Hook-Eingabe an ein schnelles Modell — standardmäßig Haiku, über das Feld model änderbar — und erwartet als Antwort nur ein ok und, wenn es false ist, ein reason. Der Platzhalter $ARGUMENTS setzt die Eingabe an die gewünschte Stelle des Prompts. Bei Stop und SubagentStop wird die Begründung zu Claudes nächster Anweisung, die Arbeit geht also weiter:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check: 1) Were all files modified? 2) Do tests pass? 3) Is the PR description updated? If anything is missing, explain what.",
"timeout": 30
}
]
}
]
}
}
Reicht ein Blick auf die Eingabedaten nicht, sondern muss jemand nachsehen, kommt type: "agent" ins Spiel. Statt eines einzelnen Modellaufrufs startet Claude Code einen Subagent, der Dateien lesen, suchen und Befehle ausführen darf, bevor er dasselbe Paar aus ok und reason zurückgibt — mit 60 statt 30 Sekunden Vorgabe und bis zu 50 Tool-Zügen. Dazu gehört ein Warnhinweis der Dokumentation: Agent-Hooks sind experimentell, Verhalten und Konfiguration können sich ändern. Für Produktives bleiben Command-Hooks die verlässlichere Wahl.
Hooks müssen nicht global gelten. Skills und Subagents dürfen im eigenen Frontmatter ein hooks-Feld führen; solche Hooks sind registriert, solange die Komponente aktiv ist, und werden danach wieder aufgeräumt. Das Format ist dasselbe wie in den Einstellungen, mit einer Umschreibung: In einem Subagent wird aus Stop automatisch SubagentStop, weil das dort das Ereignis ist, das feuert. Frontmatter-Hooks eines Projekt-Subagents laufen außerdem erst, nachdem du den Ordner als vertrauenswürdig bestätigt hast:
---
name: production-deploy
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/production-safety-check.sh"
once: true
---
Im Beispiel steht once: true: Der Hook läuft dann einmal pro Session und wird danach entfernt, was für einmalige Einrichtungsprüfungen genau richtig ist. Beachtet wird das Feld allerdings nur im Frontmatter eines Skills — in Einstellungsdateien und im Frontmatter eines Subagents wird es ignoriert, ohne Fehlermeldung.
Wenn ein Prompt- oder Agent-Hook ok: false zurückgibt, endet der Turn an vielen Ereignissen: Bei PreToolUse und PostToolUse erscheint die Begründung als Warnzeile, und Claude hört auf. Das Feld continueOnBlock dreht das um — die Begründung geht als Tool-Fehler an Claude zurück und der Turn läuft weiter, sodass Claude nachbessern kann, statt zu stoppen. Für Lint- und Stilprüfungen ist meist genau das gewünscht. Einige Ereignisse hören dabei gar nicht auf das Feld — für dieses Modul sind zwei Gruppen wichtig: Bei PostToolUseFailure und TaskCreated geht die Begründung ohnehin immer an Claude zurück, und bei PostToolBatch, UserPromptSubmit und UserPromptExpansion endet der Turn unabhängig davon.
Zum Schluss zwei Dinge, die im Betrieb zählen. Hook-Prozesse laufen ohne steuerndes Terminal und können deshalb nichts nach /dev/tty schreiben; für Desktop-Meldungen, Fenstertitel oder einen Signalton gibt es stattdessen das Feld terminalSequence in der JSON-Ausgabe, das Claude Code über seinen eigenen Ausgabeweg emittiert — beschränkt auf eine Allowlist harmloser Sequenzen, damit kein Hook die laufende Anzeige verstellt. Und wenn du wissen willst, was gerade registriert ist, öffnet /hooks eine Übersicht nach Ereignissen samt Herkunft jeder Definition. Sie ist nur zum Lesen: Geändert wird in der Einstellungsdatei, vorübergehend abgeschaltet mit disableAllHooks.