Module 9 — Hooks : automatiser et verrouiller le comportement de Claude
Le module 7 a montré comment refuser une action avec une règle de permission, et le module 8 comment revenir sur un pas de trop avec /rewind. Reste un manque : le comportement que l'on veut à coup sûr, pas selon l'humeur du modèle. Formater chaque fichier édité, refuser toute écriture dans .env, relancer les tests avant de rendre la main : ce ne sont pas des consignes, ce sont des lois. Un hook est le mécanisme qui les rend déterministes.
Un hook n'est pas une consigne
Une ligne dans CLAUDE.md du genre « lance toujours ruff après une modification » suggère un comportement au modèle ; il l'appliquera souvent. Un hook, lui, est une commande shell, un point d'API HTTP, un outil MCP ou un prompt que Claude Code exécute lui-même à un moment précis du cycle de vie. Quand l'événement tire, le hook part : le modèle n'a pas le choix. Cette bascule du probabiliste au déterministe est ce qui fait des hooks l'outil de prédilection des équipes qui tiennent des règles internes sans se battre à chaque prompt.
Le catalogue exact des événements
La documentation officielle liste les événements que Claude Code fait connaître aux hooks.
| Événement | Quand il tire |
|---|---|
SessionStart / SessionEnd | Session qui démarre, reprend ou se termine |
Setup | --init-only ou -p --init en CI |
UserPromptSubmit / UserPromptExpansion | Prompt envoyé, avant traitement ou expansion |
PreToolUse / PostToolUse / PostToolUseFailure | Autour d'un appel d'outil |
PostToolBatch | Après un lot d'appels parallèles |
PermissionRequest / PermissionDenied | Permission demandée ou refusée |
Notification / MessageDisplay | Notification, affichage de texte |
SubagentStart / SubagentStop | Naissance et fin d'un sous-agent |
TaskCreated / TaskCompleted | Cycle d'une tâche |
Stop / StopFailure | Fin de tour, éventuellement en erreur |
TeammateIdle | Coéquipier d'équipe d'agents inactif |
InstructionsLoaded / ConfigChange | Un CLAUDE.md, une règle ou une config change |
CwdChanged / DirectoryAdded / FileChanged | Répertoire courant, /add-dir ou fichier surveillé |
WorktreeCreate / WorktreeRemove | Création ou suppression d'un worktree |
PreCompact / PostCompact | Compaction du contexte |
PreModelSwitch / PostModelSwitch | Changement de modèle |
Elicitation / ElicitationResult | Un serveur MCP demande une saisie |
Un événement d'outil s'accompagne d'un matcher qui filtre : "Bash", "Edit|Write", "mcp__github__.*". Un matcher composé uniquement de lettres, chiffres, _, -, espaces, , et | est traité comme une correspondance exacte ; tout autre caractère bascule sur une expression régulière JavaScript non ancrée (Edit.* capture Edit et NotebookEdit ; écrire ^Edit$ pour épingler).
Un second filtre plus précis existe : le champ if de chaque handler, qui accepte la même syntaxe que les règles de permissions. if: "Bash(rm *)" ne déclenche que si la sous-commande correspond ; if: "Edit(*.ts)" ne cible que TypeScript.
Le format d'entrée et de sortie
Un hook de type command reçoit un objet JSON sur stdin et communique son résultat par le code de retour, éventuellement enrichi par du JSON sur stdout. Les champs communs sont session_id, prompt_id, transcript_path, cwd, permission_mode, hook_event_name. Un PreToolUse reçoit aussi tool_name, tool_input et tool_use_id. Les chemins arrivent en séparateurs natifs (antislashs sous Windows).
Codes de retour :
0sans stdout : succès, aucune décision. Le flux normal continue.0avec un objet JSON en stdout : Claude lit les champs de décision. Voie recommandée.2: erreur bloquante sur les événements qui peuvent bloquer (PreToolUse,UserPromptSubmit,Stop,SubagentStop,TaskCreated,TaskCompleted,ConfigChange,PreCompact,PreModelSwitch,PostToolBatch,Elicitation,WorktreeCreate). Le contenu de stderr sert de message.- Autre code : erreur non bloquante ; l'action continue, une notice apparaît au transcript.
Le JSON en stdout accepte deux couches. Les champs universels sont continue (mettre false arrête complètement Claude), stopReason, systemMessage et terminalSequence. Le champ hookSpecificOutput transporte les décisions propres à chaque événement.
| Événements | Champs de décision |
|---|---|
UserPromptSubmit, PostToolUse, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact | decision: "block" en racine, reason |
PreToolUse | hookSpecificOutput.permissionDecision (allow / deny / ask / defer), permissionDecisionReason, updatedInput, additionalContext |
PermissionRequest | hookSpecificOutput.decision.behavior (allow / deny), updatedInput, message |
PermissionDenied | hookSpecificOutput.retry: true |
SessionStart, SubagentStart | Contexte via hookSpecificOutput.additionalContext |
Elicitation / ElicitationResult | hookSpecificOutput.action (accept / decline / cancel), content |
Événements de trace (Notification, SessionEnd, PostCompact, CwdChanged, FileChanged…) | Effets de bord, aucune décision |
Trois événements permettent de réécrire le contenu au vol : PreToolUse.updatedInput (arguments avant exécution), PermissionRequest.decision.updatedInput (côté prompt), PostToolUse.updatedToolOutput (résultat visible pour Claude, l'outil ayant déjà agi).
Les quatre types de hooks
Le champ type de chaque handler choisit l'exécuteur.
command— shell. Reçoit le JSON sur stdin, répond par code de retour et stdout. Champs :command,args(exec form sans shell),async,shell(bashoupowershell).http— point d'API. Champs :url,headers(interpolation$VARrestreinte àallowedEnvVars),timeout. Claude envoie le JSON en POST.mcp_tool— outil sur un serveur MCP connecté. Champs :server,tool,input(substitution${tool_input.file_path}).prompt— le JSON devient$ARGUMENTSdans un prompt évalué par un modèle Claude, qui répond en JSON de décision.
Un cinquième type, agent, est marqué expérimental.
Où déclarer un hook
Les hooks vivent à plusieurs endroits et s'additionnent entre couches : un hook projet ne remplace pas un hook utilisateur.
| Emplacement | Portée |
|---|---|
~/.claude/settings.json | Tous vos projets |
.claude/settings.json | Un projet, versionné |
.claude/settings.local.json | Un projet, vous seul |
| Managed policy settings | Toute l'organisation |
Plugin : hooks/hooks.json | Quand le plugin est activé |
Frontmatter d'un SKILL.md ou d'un sous-agent | La session, une fois invoqué |
La commande /hooks ouvre un navigateur en lecture seule qui liste tous les hooks configurés, leur événement, leur matcher, leur source (User Settings, Project Settings, Local Settings, Plugin Hooks, Session Hooks).
Deux placeholders permettent d'écrire des chemins portables : ${CLAUDE_PROJECT_DIR} (racine du projet, aussi exposée comme variable d'environnement) et ${CLAUDE_PLUGIN_ROOT} (dans un plugin, emplacement d'installation).
Kiosque : trois hooks qui font le travail
L'équipe de Kiosque code une fois pour toutes ce qu'elle rappelait à la main : formater chaque fichier Python édité, refuser toute écriture dans migrations/ et .env, ne pas rendre la main tant que les tests sont rouges.
Hook 1 — Formater automatiquement après une modification
Placé dans .claude/hooks/format-python.sh et rendu exécutable, ce script lit le JSON de PostToolUse sur stdin, extrait le chemin, ne réagit qu'aux .py, et enchaîne ruff format puis ruff check --fix. Il exit 0 : effet de bord, pas de décision.
#!/usr/bin/env bash
# .claude/hooks/format-python.sh
INPUT=$(cat)
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
if [[ -z "$FILE_PATH" || "$FILE_PATH" != *.py ]]; then
exit 0
fi
ruff format "$FILE_PATH" >/dev/null 2>&1
ruff check --fix "$FILE_PATH" >/dev/null 2>&1
exit 0
Hook 2 — Interdire d'écrire dans migrations/ et .env
Un PreToolUse avec matcher Edit|Write refuse tout chemin sensible. La règle est doublée dans permissions.deny du module 7, mais le hook confirme au moment de l'action et donne une raison à Claude, qui pourra proposer une alternative.
#!/usr/bin/env bash
# .claude/hooks/protect-paths.sh
INPUT=$(cat)
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
FILE_PATH="${FILE_PATH//\\//}"
case "$FILE_PATH" in
*/migrations/*|*/.env|*.env.local)
jq -n --arg p "$FILE_PATH" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Écriture interdite dans " + $p + ". Utiliser une migration Alembic ou un secret manager.")
}
}'
exit 0
;;
esac
exit 0
Le hook renvoie une décision deny structurée : Claude reçoit la raison et réagit — proposer une migration Alembic plutôt qu'insister.
Hook 3 — Ne rendre la main que si make test passe
Le hook Stop bloque la fin du tour si les tests échouent. Piège classique : sans garde, il se déclenche à chaque re-continuation et boucle. On ajoute une garde explicite via stop_hook_active.
#!/usr/bin/env bash
# .claude/hooks/require-green-tests.sh
INPUT=$(cat)
ACTIVE=$(printf '%s' "$INPUT" | jq -r '.stop_hook_active // false')
if [[ "$ACTIVE" == "true" ]]; then
exit 0
fi
if make test >/tmp/kiosque-test.log 2>&1; then
exit 0
fi
TAIL=$(tail -n 20 /tmp/kiosque-test.log | jq -Rs .)
jq -n --argjson tail "$TAIL" '{
decision: "block",
reason: ("La suite `make test` échoue. Extrait :\n" + $tail)
}'
Le champ decision: "block" en racine est le contrat de sortie de Stop : Claude reprend la main avec la raison, corrige, relance. Sur la seconde continuation, stop_hook_active vaut true et le hook cède.
Le .claude/settings.json résultant
Les trois hooks sont assemblés dans un seul fichier de settings projet, versionné avec Kiosque. ${CLAUDE_PROJECT_DIR} garantit que les scripts sont trouvés depuis la racine.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-python.sh" }
]}
],
"PreToolUse": [
{ "matcher": "Edit|Write", "hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-paths.sh" }
]}
],
"Stop": [
{ "hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/require-green-tests.sh", "timeout": 300 }
]}
]
}
}
Nadia commite le fichier ; Karim et Léa récupèrent les hooks à leur prochain git pull. Aucune consigne dans CLAUDE.md n'est nécessaire pour rappeler à Claude de formater : c'est fait sans lui.
Débogage et pièges à éviter
Trois erreurs reviennent. Le script ne se lance pas : un script non exécutable échoue avec le code 127 (transcript : Failed with non-blocking status code) ; commiter le bit +x. Sous Windows, l'exec form échoue sur les shims .cmd : utiliser shell: powershell. Le JSON de sortie n'a aucun effet : un shell profile qui imprime au démarrage pollue stdout et casse le parsing ; stdout ne doit contenir que l'objet JSON. claude --debug sert de journal. Un hook boucle à l'infini : un Stop mal gardé provoque des cycles ; stop_hook_active sert de garde, et Claude Code coupe après huit blocages consécutifs.
Un hook PreToolUse sur Bash(git commit *) peut refuser un commit sans que quiconque pense à taper une skill. Réservez les skills aux workflows où l'humain décide ; les hooks aux garde-fous où il n'a pas à décider.
Sécurité : les hooks tournent avec les privilèges de la session, sans terminal contrôlant sur macOS et Linux. Ne jamais exécuter en hook un code non audité venant d'un plugin ou d'une source externe.
En résumé
- Un hook est déterministe : quand l'événement tire, la commande part, indépendamment de la décision du modèle.
- Les événements couvrent tout le cycle de vie : session, tour, outil, notification, compaction, changement de modèle.
- L'entrée arrive en JSON sur stdin ; la sortie passe par le code de retour (0, 2, autre) et par un objet JSON en stdout avec
decision,reasonouhookSpecificOutput. - Quatre types stables : commande, HTTP, outil MCP, prompt évalué par un modèle ; un cinquième,
agent, est expérimental. - Les hooks se déclarent dans les settings (user, projet, local), dans un plugin, ou en frontmatter d'une skill ou d'un sous-agent, et s'additionnent entre couches.
- Pour Kiosque, trois hooks suffisent : formatage auto, chemins protégés, tests verts avant fin de tour.
Module suivant : Sous-agents, agents en parallèle et équipes d'agents — comment déléguer un travail long à un agent isolé, faire tourner cinq investigations en parallèle et orchestrer une équipe entière depuis la conversation principale.