In questo articolo
- 🪝 Perché gli hook cambiano le regole
- 🗺️ Il ciclo di vita e i suoi eventi
- 🧬 Anatomia di un hook in settings.json
- 📥 L'input su stdin e gli exit code
- 🧹 PostToolUse: format e lint a ogni modifica
- 🖥️ PostToolUse in azione
- 🛡️ PreToolUse: negare l'irreparabile
- 🚫 Il blocco visto da Claude
- 🚦 Stop: il quality gate sui test
- ✅ Il gate che rimanda Claude al lavoro
- 🧠 SessionStart, sicurezza e debug
- 🧭 In sintesi
🪝 Perché gli hook cambiano le regole
Un hook è un comando shell che Claude Code esegue in automatico a un punto preciso del suo ciclo di vita. La differenza con una regola scritta in CLAUDE.md è sostanziale: la regola è un suggerimento probabilistico, il modello può seguirla o dimenticarla; l'hook è codice deterministico che gira sempre, con i tuoi permessi, indipendente dal modello e dal contesto.
Quando conviene? Per tutto ciò che deve accadere ogni volta, senza eccezioni: formattare dopo ogni modifica, impedire un rm -rf, non chiudere il turno con i test rossi. Sposti la garanzia dal «spero che l'AI se lo ricordi» al «lo impone lo script». Le regole restano perfette per stile e preferenze; gli hook sono per le invarianti che non puoi permetterti di violare.
Regola in CLAUDE.md
- Probabilistica: il modello la interpreta
- Può ignorarla sotto pressione di contesto
- Nessuna garanzia di esecuzione
- Ottima per stile, tono e preferenze
Hook
- Deterministico: gira sempre, come codice
- Indipendente dal modello e dal contesto
- Può bloccare l'azione con exit 2
- Ideale per policy, sicurezza e quality gate
Non sono alternativi: la regola guida, l'hook garantisce.
🗺️ Il ciclo di vita e i suoi eventi
Claude Code espone decine di eventi, ma per i quality gate ne bastano pochi, raggruppabili per cadenza. Una volta per sessione: SessionStart e SessionEnd. Una volta per turno: UserPromptSubmit e Stop. A ogni chiamata di tool: PreToolUse e PostToolUse. Ogni evento dichiara anche se può bloccare l'azione o solo aggiungere contesto.
La regola per scegliere è semplice: agisci il più a monte possibile. Vuoi impedire un'azione prima che accada? PreToolUse. Vuoi reagire a una modifica appena fatta? PostToolUse. Vuoi un gate di fine lavoro? Stop. Vuoi dare a Claude un briefing all'avvio? SessionStart. Il resto è dettaglio.
Pochi eventi coprono la quasi totalità dei quality gate: scegli quello più a monte.
🧬 Anatomia di un hook in settings.json
Gli hook si dichiarano in settings.json: quello utente in ~/.claude/ vale per tutte le tue sessioni, quello di progetto in .claude/settings.json si committa nel repo e vale per tutto il team. La struttura ha tre livelli di annidamento: l'evento, un matcher che filtra quando scatta, e uno o più handler — qui type: "command" con lo script. Il placeholder $CLAUDE_PROJECT_DIR punta alla radice del progetto, così il percorso resta portabile.
Il matcher aggancia il nome del tool: Bash, oppure una lista come Edit|Write, oppure una regex. Per i tool MCP il nome è mcp__<server>__<tool>, e per prenderli tutti da un server serve mcp__<server>__.* (il .* è obbligatorio: senza, viene trattato come stringa esatta e non aggancia nulla). Un matcher vuoto o * scatta su ogni occorrenza dell'evento.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/format.sh"
}
]
}
]
}
}Hooks reference · Claude Docs ↗
📥 L'input su stdin e gli exit code
Quando un hook parte, Claude Code gli passa su stdin un JSON con il contesto: session_id, cwd, hook_event_name e — sugli eventi tool — tool_name e tool_input. Lo leggi con jq e decidi. È tutto qui l'input: nessuna variabile magica, solo un oggetto JSON da interrogare.
Il canale di ritorno sono gli exit code: 0 significa via libera; 2 significa blocco, e lo stderr torna a Claude come feedback (su PreToolUse annulla la chiamata, su Stop impedisce a Claude di fermarsi, su UserPromptSubmit rifiuta il prompt); qualunque altro codice è un errore non bloccante. In alternativa, con exit 0 puoi stampare su stdout un JSON strutturato per decisioni più fini — lo vediamo tra poco.
{
"session_id": "a1b2c3",
"cwd": "/home/me/coolsolution-blog",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf ./build"
}
}🧹 PostToolUse: format e lint a ogni modifica
Il primo gate utile: dopo ogni Edit o Write, riformatta e linta il solo file toccato. Registro un hook PostToolUse con matcher Edit|Write che chiama format.sh. Lo script legge il file_path dal tool_input con jq, poi lancia gli strumenti del progetto — nel repo uso yarn, quindi prettier ed eslint girano via yarn (su un progetto .NET sarebbe dotnet format).
Il valore è che Claude riceve il file già pulito: nessun diff di stile da rivedere, review più corte, storia git senza commit di sola formattazione. PostToolUse non può bloccare (il tool è già stato eseguito), quindi lo script termina sempre con exit 0; al massimo rimanda a Claude un messaggio.
#!/usr/bin/env bash
# riformatta e linta il file appena modificato da Claude
set -euo pipefail
# il path arriva nel JSON su stdin, dentro tool_input
file="$(jq -r '.tool_input.file_path // empty')"
[ -z "$file" ] && exit 0 # nessun file: niente da fare
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.css|*.md)
yarn prettier --write "$file"
yarn eslint --fix "$file" || true
;;
esac
exit 0 # PostToolUse non blocca: si prosegue🖥️ PostToolUse in azione
Ecco cosa vedi nel terminale quando Claude modifica un file: subito dopo l'Edit, l'hook scatta, prettier riformatta e eslint --fix corregge, poi exit 0 e il turno prosegue senza interruzioni. È il pattern «format on save» portato dentro l'agente.
La differenza rispetto al tuo editor è che vale anche quando a salvare è Claude, non tu. Su un team con convenzioni rigide questo azzera un'intera categoria di commenti in review: nessuno discute più di virgolette, punti e virgola o import ordinati, perché il file arriva già conforme.
Il file torna a Claude già formattato e lintato: nessun diff di stile da rivedere.
🛡️ PreToolUse: negare l'irreparabile
PostToolUse reagisce dopo; per fermare qualcosa serve PreToolUse, che scatta prima dell'esecuzione e può annullarla. Il guard classico blocca i comandi distruttivi: lo script legge .tool_input.command, e se trova un rm -rf su glob larghi risponde con un JSON che porta permissionDecision: "deny" e una motivazione. Lo stesso schema protegge i segreti: nega gli Edit su .env o .dev.vars.
Due modi per bloccare: exit 2 con un messaggio su stderr (semplice), oppure exit 0 con su stdout un JSON hookSpecificOutput.permissionDecision: "deny" (strutturato). Il secondo è più espressivo: la reason associata a deny viene mostrata a Claude, che capisce il perché e corregge il tiro. Registro l'hook su PreToolUse con matcher Bash|Edit|Write.
#!/usr/bin/env bash
# nega comandi distruttivi e modifiche a file sensibili
set -euo pipefail
input="$(cat)" # tutto il JSON su stdin
tool="$(jq -r '.tool_name' <<<"$input")"
cmd="$(jq -r '.tool_input.command // empty' <<<"$input")"
path="$(jq -r '.tool_input.file_path // empty' <<<"$input")"
deny() {
jq -n --arg r "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
exit 0 # decisione via stdout JSON
}
# rm -rf su glob non circoscritti
[[ "$tool" == "Bash" && "$cmd" =~ rm[[:space:]]+-rf.*[*/] ]] \
&& deny "comando distruttivo 'rm -rf' bloccato dall'hook"
# modifiche a file di segreti
[[ "$path" == *.env || "$path" == *.dev.vars ]] \
&& deny "modifica a file sensibile ($path) non consentita"
exit 0 # nessuna corrispondenza: flusso normaleHooks reference · Claude Docs ↗
🚫 Il blocco visto da Claude
Dal lato di Claude il rifiuto non è un muro cieco: riceve la reason, capisce perché ed emenda la strategia. Nel terminale vedi il tool negato, la motivazione, e Claude che ripropone un comando circoscritto e sicuro al posto di quello bloccato.
È qui la forza dell'approccio: la policy vive nell'hook, non nella buona volontà del modello. È deterministica, versionata nel repo e identica per ogni agente che legge lo stesso settings.json — Claude, Codex, Copilot. Un solo punto di verità per le regole che non si negoziano.
La motivazione torna al modello, che corregge invece di insistere.
🚦 Stop: il quality gate sui test
L'evento Stop scatta quando Claude sta per finire il turno — il posto giusto per il gate più importante: non dichiarare fatto ciò che non passa i test. L'hook lancia yarn test (o tsc --noEmit, o yarn lint); se qualcosa è rosso risponde con decision: "block" e una reason, e Claude non si ferma: torna a lavorare.
Attenzione al loop: un gate Stop che blocca a oltranza terrebbe Claude in un ciclo infinito. Per questo lo script controlla il campo stop_hook_active (evita di rientrare in se stesso) e supera il gate con exit 0 appena i test tornano verdi. È la trasposizione agentica del «la CI dev'essere verde prima del merge».
#!/usr/bin/env bash
# Stop gate: non chiudere il turno con i test rossi
set -uo pipefail
input="$(cat)"
# evita il loop: se l'hook gira già in risposta a se stesso, esci
[ "$(jq -r '.stop_hook_active // false' <<<"$input")" = "true" ] && exit 0
if yarn test --silent >/tmp/claude-test.log 2>&1; then
exit 0 # verde: Claude può fermarsi
fi
# rosso: blocca lo Stop e rimanda l'errore a Claude
tail -n 20 /tmp/claude-test.log >&2
jq -n '{
decision: "block",
reason: "yarn test è rosso: correggi i test prima di chiudere il turno."
}'Hooks reference · Claude Docs ↗
✅ Il gate che rimanda Claude al lavoro
In pratica: Claude annuncia «fatto», l'hook Stop esegue la suite, trova due test rossi e restituisce block. Il controllo torna a Claude, che corregge e rilancia — finché il gate diventa verde. Nessun turno si chiude con debito nascosto.
Il risultato è che «finito» smette di essere un'opinione del modello e diventa una proprietà verificata dalla suite di test. È la stessa disciplina della CI, ma applicata dentro il loop dell'agente invece che alla fine, quando il codice è già stato scritto e va rivisto a posteriori.
«Finito» lo decide la suite di test, non il modello.
🧠 SessionStart, sicurezza e debug
SessionStart fa l'opposto di un blocco: inietta contesto. Uno script che stampa branch corrente, file modificati e issue attiva, restituito come additionalContext, dà a Claude un briefing aggiornato a ogni avvio — senza incollarlo a mano. Lo stdout di SessionStart (come quello di UserPromptSubmit) entra direttamente nel contesto che Claude legge.
Due avvertenze finali. Sicurezza: gli hook eseguono comandi shell arbitrari con i tuoi permessi, quindi tratta settings.json come codice — revisiona ciò che aggiungi, non incollare hook di provenienza ignota. Debug: se un hook non parte, lancia claude --debug per vedere comando, input ed exit code; e ricorda che solo exit 2 blocca (un exit 1 passa silenziosamente come errore non bloccante).
#!/usr/bin/env bash
# briefing di progetto iniettato a ogni avvio di sessione
set -euo pipefail
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '-')"
changed="$(git status --porcelain 2>/dev/null | awk '{print $2}' | paste -sd, -)"
ctx="Branch corrente: ${branch}
File modificati: ${changed:-nessuno}"
jq -n --arg c "$ctx" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $c
}
}'🧭 In sintesi
Gli hook trasformano Claude Code da assistente persuadibile a sistema con garanzie. Quattro eventi coprono la gran parte dei casi: SessionStart per il contesto, PreToolUse per negare, PostToolUse per correggere, Stop per il gate finale sui test.
Il metodo è sempre lo stesso: script piccolo, jq per leggere l'input, exit 2 o JSON su stdout per decidere, settings.json di progetto committato così la regola vale per tutti. Meno promemoria nel prompt, più invarianti nel repo.
- 01Scegli l'eventoPreToolUse per negare, PostToolUse per reagire, Stop per il gate, SessionStart per il contesto.
- 02Scrivi lo scriptUn .sh che legge il JSON su stdin con jq e decide.
- 03Registra in settings.jsonEvento, matcher sul nome del tool, handler command via $CLAUDE_PROJECT_DIR.
- 04Decidi l'esitoexit 0 prosegue, exit 2 blocca, stdout JSON per permissionDecision o decision.
- 05Debug con --debugclaude --debug mostra comando, input ed exit code dell'hook.
- 06Condividi col teamCommitta .claude/settings.json: stessa regola per ogni persona e ogni agente.
Domande frequenti su hook di Claude Code
Qual è la differenza tra un hook e una regola in CLAUDE.md?
Una regola in CLAUDE.md è un'istruzione che il modello interpreta e può dimenticare sotto pressione di contesto: è probabilistica. Un hook è un comando shell che Claude Code esegue sempre, in modo deterministico e indipendente dal modello. Le regole guidano stile e preferenze; gli hook garantiscono le invarianti (policy, sicurezza, quality gate).
Come blocco davvero un'azione con un hook?
In due modi. Con un exit code 2: l'azione viene bloccata e lo stderr torna a Claude come feedback (su PreToolUse annulla la chiamata, su Stop impedisce di fermarsi). Oppure con exit 0 e un JSON su stdout: per PreToolUse usi hookSpecificOutput.permissionDecision "deny", per Stop o PostToolUse il campo decision "block" con una reason. Attenzione: solo exit 2 blocca, un exit 1 è un errore non bloccante.
Gli hook rallentano Claude Code?
Solo se li scrivi lenti. La buona pratica è tenerli mirati: formattare e lintare il singolo file toccato (non l'intero repo), usare flag come --silent, e impostare un timeout ragionevole nell'handler. Un hook di format su un file impiega decine di millisecondi; un gate Stop che lancia l'intera suite di test è più pesante, ma scatta solo a fine turno.
Posso condividere gli hook con il team?
Sì. Gli hook definiti in .claude/settings.json vivono nel repository e valgono per chiunque lo cloni, agenti inclusi. Gli hook personali stanno invece in ~/.claude/settings.json e restano sulla tua macchina. Con $CLAUDE_PROJECT_DIR i percorsi degli script restano portabili tra le macchine del team.
Come faccio il debug di un hook che non scatta?
Lancia Claude Code con claude --debug: vedrai il comando eseguito, il JSON passato su stdin e l'exit code restituito. Le cause più comuni sono un matcher sbagliato (il nome del tool va scritto esatto, e per i tool MCP serve mcp__<server>__.*), lo script non eseguibile (chmod +x) o jq non installato.
Gli hook funzionano anche con i tool MCP?
Sì. Nei tool MCP il nome ha la forma mcp__<server>__<tool>, quindi un matcher come mcp__github__.* aggancia tutti i tool del server GitHub, e mcp__.__write. intercetta ogni operazione di scrittura di qualsiasi server. È il modo per applicare gli stessi gate anche agli strumenti esterni, non solo a Bash, Edit e Write.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.



