Aller au contenu principal

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énementQuand il tire
SessionStart / SessionEndSession qui démarre, reprend ou se termine
Setup--init-only ou -p --init en CI
UserPromptSubmit / UserPromptExpansionPrompt envoyé, avant traitement ou expansion
PreToolUse / PostToolUse / PostToolUseFailureAutour d'un appel d'outil
PostToolBatchAprès un lot d'appels parallèles
PermissionRequest / PermissionDeniedPermission demandée ou refusée
Notification / MessageDisplayNotification, affichage de texte
SubagentStart / SubagentStopNaissance et fin d'un sous-agent
TaskCreated / TaskCompletedCycle d'une tâche
Stop / StopFailureFin de tour, éventuellement en erreur
TeammateIdleCoéquipier d'équipe d'agents inactif
InstructionsLoaded / ConfigChangeUn CLAUDE.md, une règle ou une config change
CwdChanged / DirectoryAdded / FileChangedRépertoire courant, /add-dir ou fichier surveillé
WorktreeCreate / WorktreeRemoveCréation ou suppression d'un worktree
PreCompact / PostCompactCompaction du contexte
PreModelSwitch / PostModelSwitchChangement de modèle
Elicitation / ElicitationResultUn 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 :

  • 0 sans stdout : succès, aucune décision. Le flux normal continue.
  • 0 avec 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énementsChamps de décision
UserPromptSubmit, PostToolUse, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompactdecision: "block" en racine, reason
PreToolUsehookSpecificOutput.permissionDecision (allow / deny / ask / defer), permissionDecisionReason, updatedInput, additionalContext
PermissionRequesthookSpecificOutput.decision.behavior (allow / deny), updatedInput, message
PermissionDeniedhookSpecificOutput.retry: true
SessionStart, SubagentStartContexte via hookSpecificOutput.additionalContext
Elicitation / ElicitationResulthookSpecificOutput.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 (bash ou powershell).
  • http — point d'API. Champs : url, headers (interpolation $VAR restreinte à 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 $ARGUMENTS dans 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.

EmplacementPortée
~/.claude/settings.jsonTous vos projets
.claude/settings.jsonUn projet, versionné
.claude/settings.local.jsonUn projet, vous seul
Managed policy settingsToute l'organisation
Plugin : hooks/hooks.jsonQuand le plugin est activé
Frontmatter d'un SKILL.md ou d'un sous-agentLa 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 plutôt qu'une skill

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, reason ou hookSpecificOutput.
  • 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.