Diez recetas de hook listas para producción — una por necesidad real, cubriendo las siete familias de eventos de ciclo de vida. Copia, pega, personaliza. Cada receta es <20 líneas de bash.
Qué es un hook, en 30 segundos
Un hook es un script de shell que Claude Code ejecuta en un evento específico del ciclo de vida. Lee un payload JSON de stdin, decide vía exit code (0 = permite, 2 en un evento Pre* = BLOQUEA con stderr mostrado al agent, cualquier otro = solo loguea), y opcionalmente escribe additionalContext a stdout para que el agent lo consuma.
Anatomía de una receta
Cuándo — el escenario real donde esta receta vale la pena.
Payload — JSON de ejemplo que el hook recibe en stdin.
Script — el bash real. Corto. De verdad.
Configuración — el fragmento de settings.json.
Trampa — la cosa que le pasó factura a alguien.
§1
Inyectar CHANGELOG + estado sin commit al iniciar sesión
SessionStart
Cuándo — Claude abre una sesión y el usuario quiere las últimas notas de release y el estado actual de git presentados automáticamente, en vez de preguntar "¿qué cambió?" cada vez.
Trampa — SessionStart dispara en cada source incluyendo compact. Si solo quieres startup, ramifica en source vía jq -r '.source' primero.
§2
Bloquear salida si SESSION.md está desactualizado con WIP sin commit
Stop
Cuándo — terminas sesiones con WIP que la próxima sesión olvida. El hook nag una vez: "o haces commit, o actualizas SESSION.md, y luego terminas normalmente."
▸ Script — .claude/hooks/session-stop.sh
#!/usr/bin/env bash
set -euo pipefail
dirty=$(git status --porcelain 2>/dev/null || true)
[[ -z "$dirty" ]] && exit 0
session_mtime=$(stat -c %Y .claude/SESSION.md 2>/dev/null || echo 0)
started=$(stat -c %Y .claude/.session-state/started-at 2>/dev/null || echo 0)
[[ "$session_mtime" -gt "$started" ]] && exit 0
[[ -f .claude/.session-state/nagged ]] && exit 0
touch .claude/.session-state/nagged
echo "Before ending: uncommitted WIP exists but SESSION.md was not updated this session. Update it (or commit), then end normally." >&2
exit 2
Trampa — el marcador nagged es por sesión; sin él el hook bloquearía para siempre (loop infinito). Siempre incluye un guard "bloquea como máximo una vez".
§3
Auto-expandir @PR/123 en prompts al diff del PR
UserPromptSubmit
Cuándo — referencias PRs constantemente. En vez de pegar el diff cada vez, escribes @PR/123 y el hook lo obtiene y lo inline.
▸ Payload
{
"hook_event_name": "UserPromptSubmit",
"user_message": "review @PR/123 for security issues"
}
▸ Script — .claude/hooks/expand-pr.sh
#!/usr/bin/env bash
set -euo pipefail
msg=$(jq -r '.user_message')
prs=$(echo "$msg" | grep -oE '@PR/[0-9]+' | sed 's|@PR/||' | sort -u || true)
[[ -z "$prs" ]] && exit 0
echo "=== referenced PR diffs ==="
for n in $prs; do
echo "--- PR #$n ---"
gh pr diff "$n" 2>/dev/null | head -c 4096 || echo "(failed to fetch)"
done
Trampa — el stdout de hooks UserPromptSubmit se inyecta como additionalContext justo después del mensaje del usuario. Mantenlo acotado (head -c 4096) o un PR grande inunda la ventana de contexto.
Trampa — InstructionsLoaded dispara dentro del mismo turno del load que la disparó. El hook es observabilidad pura; stdout/stderr NO se inyectan en la conversación para este evento.
§5
Bloquear formas destructivas de shell
PreToolUse(Bash)
Cuándo — confías en Claude para ejecutar Bash, pero nunca quieres ver rm -rf, git push --force, o instalación de dependencia sin lockfile. Bloquea esos por forma, no por negación global.
Trampa — matcher: "Bash" a nivel de array limita el hook a llamadas Bash. Sin esto, el hook dispara en cada llamada a tool y tu query jq falla en un payload con forma equivocada.
§6
Exigir un handoff de delegación con 5 campos
PreToolUse(Agent)
Cuándo — sigues recibiendo sub-agents subespecificados que se inventan su propio framing. Impón un handoff estructurado: TASK / CONTEXT / CONSTRAINTS / DELIVERABLE / DONE_WHEN.
▸ Script — .claude/hooks/pre-agent.sh
#!/usr/bin/env bash
set -euo pipefail
prompt=$(jq -r '.tool_input.prompt')
required=(TASK CONTEXT CONSTRAINTS)
outcome=0
for f in "${required[@]}"; do
echo "$prompt" | grep -qiE "^[[:space:]]*$f:" || { missing+=" $f"; outcome=1; }
done
echo "$prompt" | grep -qiE "^[[:space:]]*(DELIVERABLE|DONE_WHEN):" || outcome=1
[[ $outcome -eq 0 ]] && exit 0
cat >&2 <<MSG
delegation-gate: missing fields$missing (and/or DELIVERABLE/DONE_WHEN).
Use 5-field handoff:
TASK: <one sentence>
CONTEXT: <files/paths>
CONSTRAINTS: <what NOT to do>
DELIVERABLE: <artifact> | DONE_WHEN: <verifier>
MSG
exit 2
Trampa — match case-insensitive (-i) es a propósito; los agents escriben Task:, TASK:, task: indistintamente. Anclar al inicio de línea (^) evita match en "task list" dentro de la prosa.
§7
Ejecutar prettier check en cada archivo editado
PostToolUse(Edit | Write | MultiEdit)
Cuándo — quieres consistencia de formato sin prettier --write manual después de cada edit. El hook ejecuta --check y muestra violaciones al agent en su próximo turno.
Trampa — el matcher "Edit|Write|MultiEdit" usa alternación regex. Los nombres de campos del payload difieren: Edit/MultiEdit usan file_path; Write usa file_path; algunas tools legacy usaron path. El fallback // empty en jq cubre todas las formas.
§8
Sugerir una alternativa cuando el usuario niega una tool
PermissionDenied
Cuándo — el agent pide Bash(curl …), lo niegas, pero preferirías que usara otro verbo. El hook inyecta una pista para que el agent reintente con la tool correcta.
▸ Script — .claude/hooks/perm-denied-hint.sh
#!/usr/bin/env bash
set -euo pipefail
denied=$(jq -r '.tool_name + "(" + (.tool_input | tostring) + ")"')
case "$denied" in
Bash*curl*) echo "hint: try the WebFetch tool instead of curl" ;;
Bash*rm*) echo "hint: this project blocks rm; if you need to remove, ask the user" ;;
Bash*gh*pr*) echo "hint: prefer the GitHub MCP server (mcp__github__*) for PR ops" ;;
esac
Trampa — PermissionDenied dispara después de que el usuario niega el prompt. El stdout se vuelve additionalContext en el próximo turno del agent. No moralices — da un redirect concreto o quédate callado.
§9
Verificar que el deliverable que el sub-agent dice haber hecho existe
SubagentStop
Cuándo — los sub-agents dicen "listo, creé el archivo X" pero X no existe. El hook parsea el campo DELIVERABLE: del brief, revisa el path, y marca discrepancias.
▸ Script — .claude/hooks/subagent-verify.sh
#!/usr/bin/env bash
set -euo pipefail
brief=$(jq -r '.tool_input.prompt // empty')
deliverable=$(echo "$brief" | grep -iE '^[[:space:]]*DELIVERABLE:' | head -1 | sed -E 's/^[^:]*:[[:space:]]*//')
# crude: pull any path-shaped token from the deliverable line
path=$(echo "$deliverable" | grep -oE '[a-zA-Z0-9_./-]+\.(ts|tsx|md|json|astro|sh|py|go|rs)' | head -1 || true)
[[ -z "$path" ]] && exit 0
[[ -f "$path" ]] && exit 0
echo "subagent-advisory: claimed deliverable '$path' not found in tree" >&2
exit 0 # advisory
Trampa — extraer paths de prosa es frágil; el script solo captura deliverables que nombran un path con extensión reconocida. Para checks más ricos, empareja DELIVERABLE con DONE_WHEN: y ejecuta el comando en su lugar.
§10
Snapshot de los últimos 12 turnos antes de la sumarización
PreCompact
Cuándo — sesiones largas son sumarizadas y el sumarizador descarta wording verbatim, paths de archivo, identificadores exactos. El hook guarda los turnos en bruto en disco para que el próximo session-start los re-inyecte.
▸ Script — .claude/hooks/pre-compact.sh
#!/usr/bin/env bash
set -euo pipefail
transcript=$(jq -r '.transcript_path // empty')
[[ -f "$transcript" ]] || exit 0
# extract last 12 user prompts + their assistant text
jq -c 'select(.role=="user" or .role=="assistant") | {role, content}' "$transcript" \
| tail -n 48 > .claude/COMPACT_NOTES.md
echo "wrote .claude/COMPACT_NOTES.md ($(wc -l < .claude/COMPACT_NOTES.md) lines)" >&2
▸ Hook compañero SessionStart
# in .claude/hooks/session-start.sh
if [[ "$(jq -r '.source')" = "compact" && -f .claude/COMPACT_NOTES.md ]]; then
echo "=== PRE-COMPACT SNAPSHOT ==="
cat .claude/COMPACT_NOTES.md
fi
Trampa — solo PreCompact puede escribir antes de que el sumarizador trunque el contexto, y solo SessionStart con source: "compact" puede inyectar después. Ningún otro hook puentea ese hueco. Ambas mitades son requeridas.