Hooks de Claude Code / Ruta 03
Ruta 03 · Automatización
Cuatro hooks listos para copiar y pegar: formatear al guardar, avisar cuando termine, registrar cada comando e inyectar contexto en cuanto se abre la sesión.
Lecciones de esta ruta
PostToolUse toma el archivo recién editado y ejecuta el formateador — prettier, black, gofmt — sin que tengas que recordarlo.
Stop y Notification activan el aviso correcto en el momento adecuado — sin vigilar la terminal esperando que Claude termine.
PreToolUse en Bash registra cada comando con la hora en un log: un diario de auditoría sencillo.
SessionStart entrega branch, git status y las reglas del proyecto apenas se abre la sesión. Capstone de la ruta.
Ruta 03 · Lección 1 · Intermedio
Después de que Claude edita un archivo, un hook ejecuta el formateador en él, sin que tengas que recordarlo y sin excepciones.
↓ desplázate para estudiar
El hook vive en el evento PostToolUse: lo activa después que Claude termina de editar o escribir un archivo: la acción ya ocurrió, es hora de ordenar todo.
O matcher filtra en qué herramientas se ejecuta el hook: "Edit|Write" toma las dos: edición de un archivo existente o escritura de uno nuevo.
{ "hooks": { "PostToolUse": [ {
"matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "f=$(jq -r .tool_input.file_path); prettier --write \"$f\"" } ]
} ] } }
Lee de adentro hacia afuera: en el evento PostToolUse, cuando la herramienta sea Edit o Write, ejecuta el comando. Sencillo — y te lo quitas de la cabeza para siempre.
Pruébate
¿Qué evento se ejecuta DESPUÉS de que una herramienta termina de ejecutarse?
El hook no sabe de antemano qué archivo se modificó: lo lee del JSON de stdin, campo tool_input.file_path. Con jq, esto se convierte en una variable de shell:
# lee el archivo que se acaba de editar
file=$(jq -r '.tool_input.file_path')
Es este $file que irá al formateador en la siguiente sección. Sin él, el hook no sabría sobre qué archivo actuar.
Con la ruta en mano, elige el formateador según la extensión del archivo. Un case resuelve para cualquier stack:
case "$file" in *.js|*.ts|*.jsx|*.tsx) prettier --write "$file" ;; *.py) black -q "$file" ;; *.go) gofmt -w "$file" ;; esac
Cámbialos por los formateadores de tu proyecto. El punto no es qué herramienta sea, sino que se ejecute siempre, en todo archivo, sin excepción humana.
Pruébate
Para formatear un archivo .py, qué línea del case ¿entra en acción?
Un hook de formato no debe bloquear nada: siempre exit 0. Y cuidado con una trampa — en el PostToolUse, un exit 2 no deshace la edición (la herramienta ya se ejecutó); solo informa de un error.
Para no llenar el contexto cuando todo salió bien, usa suppressOutput:
{ "suppressOutput": true }
Y el formateador debe ser idempotente: ejecutar dos veces sobre el mismo archivo no puede romper nada: la misma entrada, el mismo resultado, siempre.
Solo PreToolUse e Stop pueden bloquear algo con exit 2. En el PostToolUse, ese mismo código se trata como un error no bloqueante: la acción ya ocurrió, no hay forma de revertirla.
Pruébate
Si un hook PostToolUse sale con exit 2, ¿qué ocurre?
Practica ahora 0/4 hecho
Haz que Claude formatee por sí solo todos los archivos que edite. ~5 min, seguro: solo formatea, no bloquea nada.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "f=$(jq -r '.tool_input.file_path'); command -v prettier >/dev/null && prettier --write \"$f\" 2>/dev/null; exit 0" }
]
}
]
}
}✓ Cierre: el código siempre queda limpio — el formateador pasó a ser parte del flujo, no una tarea manual que puedes olvidar.
Ruta 03 · Lección 2 · Intermedio
Dos eventos avisan en el momento indicado: Stop cuando Claude termina de responder, Notification cuando te necesita.
↓ desplázate para estudiar
Dos eventos avisan de cosas diferentes. Stop se activa cuando Claude termina de responder — el turno terminó. Ya Notification se activa cuando él te necesita: estuvo inactivo 60s o más, pidió un permiso o un MCP está esperando una respuesta.
La diferencia importa: Stop es «terminé»; Notification es «te estoy esperando». Cada uno requiere un tipo de aviso diferente.
Pruébate
¿Qué evento se activa cuando Claude necesita tu permiso (o queda detenido durante 60s o más)?
Agrega una notificación de escritorio al evento que te interesa. En Linux, notify-send resuelve:
# Stop → notificación de escritorio (linux)
notify-send "Claude Code" "terminou de responder"
Un sonido corto (paplay, afplay) también funciona y es más discreto que una ventana emergente. Lo importante: tú deja de vigilar la terminal.
El evento Notification se activa por varios motivos — notification_type dice cuál. Si notificas en todos, se convierte en spam. Filtra lo que realmente necesita tu atención:
type=$(jq -r '.notification_type') [ "$type" = "permission_prompt" ] || exit 0 notify-send "Claude Code" "precisa de permissão" --urgency=critical
Así, solo la solicitud de permiso activa la alerta crítica; el resto (idle, elicitation) lo decides tú, si quieres o no, sin tocar todo a cada rato.
Pruébate
Para notificar solo cuando se solicite permiso (e ignorar el resto), filtra por:
El comando de notificación cambia según el sistema. Un case encima de uname cubre los tres:
case "$(uname)" in Linux) notify-send "Claude Code" "terminou" ;; Darwin) osascript -e 'display notification "terminou" with title "Claude Code"' ;; *) powershell.exe -Command "New-BurntToastNotification -Text 'Claude Code','terminou'" ;; esac
En Windows (vía WSL), llamar al powershell.exe dentro de Linux suele ser el camino más directo.
¿Prefieres sonido a una ventana emergente? Cambia el notify-send/osascript por paplay algum.wav (Linux) o afplay algum.wav (macOS) — más discreto, funciona igual como señal de "terminó".
Practica ahora 0/4 hecho
Haz que Claude te avise con una ventana emergente cada vez que termine de responder. ~5 min, seguro (solo observa, no bloquea).
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "command -v notify-send >/dev/null && notify-send 'Claude Code' 'terminou de responder'; exit 0" }
]
}
]
}
}✓ Cierre: ya no tienes que vigilar la terminal — el aviso llega hasta ti.
Ruta 03 · Lección 3 · Intermedio
PreToolUse en Bash registra cada comando con la hora: un diario de todo lo que ejecutó la sesión, sin bloquear nada.
↓ desplázate para estudiar
El evento PreToolUse se activa antes de cualquier herramienta ejecutarse. Con matcher "Bash", toma solo los comandos de terminal, antes de que se ejecuten de verdad.
Aquí no vamos a bloquear nada: el hook solo observa y deja que continúe. Es un registro, no un bloqueo.
Pruébate
Un hook PreToolUse con matcher "Bash" ¿se activa cuándo?
Cada línea del log lleva la hora en que se iba a ejecutar el comando. Construye la marca de tiempo con date:
# .claude/hooks/log-cmd.sh (PreToolUse, matcher "Bash")
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
Esto por sí solo todavía no hace nada — falta capturar el comando en sí, que viene en la siguiente sección.
El texto del comando viene en el JSON de stdin, en el campo tool_input.command. Combínalo con la marca de tiempo y adjúntalo a un archivo:
cmd=$(jq -r '.tool_input.command') printf '%s\t%s\n' "$ts" "$cmd" >> "$CLAUDE_PROJECT_DIR/.claude/cmd.log" exit 0
$CLAUDE_PROJECT_DIR garantiza que el registro siempre vaya al mismo lugar, sin importar desde dónde se haya ejecutado el comando. Y exit 0 — el hook nunca bloquea, solo registra.
Pruébate
¿De dónde viene el texto del comando que Bash va a ejecutar?
O cmd.log se convierte en un registro: cada comando, con hora y en orden. Útil para revisar lo que hizo una sesión, investigar un comando extraño o reconstruir una cronología después.
Una precaución: los hooks se ejecutan con permisos totales de tu usuario. Si un comando tiene un token o una contraseña incorporados, quedarán en el registro: no dejes que ese archivo salga del .gitignore ni circular por ahí.
El registro es solo para que lo consultes después; el hook en sí nunca debe bloquear basado en él. Lo que bloquea es un freno aparte (clase anterior de la ruta); este hook solo observa.
Pruébate
¿Este hook de registro debe bloquear el comando antes de que se ejecute?
Practica ahora 0/4 hecho
Registra, con la hora, cada comando de Bash que Claude ejecute en esta sesión. ~5 min, seguro (solo observa, siempre termina con exit 0).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "jq -r '(now|gmtime|strftime(\"%Y-%m-%d %H:%M:%S\")) + \" \" + .tool_input.command' >> \"$CLAUDE_PROJECT_DIR/.claude/cmd.log\"; exit 0" }
]
}
]
}
}✓ Cierre: ahora existe un diario fechado de todo lo que ejecutó la sesión — auditable, sin bloquear nada.
Ruta 03 · Lección 4 · Intermedio
SessionStart y UserPromptSubmit entregan contexto directamente a Claude — branch, git status, reglas del proyecto — sin que tengas que pegar nada.
↓ desplázate para estudiar
SessionStart se activa una vez, cuando se inicia la sesión. UserPromptSubmit se activa la cada mensaje que envías. Ninguno de los dos es un evento de herramienta: no usan matcher, simplemente se activan en el momento adecuado.
Ambos pueden devolver contexto adicional para Claude. La diferencia es la frecuencia: el contexto de sesión (rama, reglas del proyecto) va en el SessionStart; algo que cambia con cada mensaje entraría en el UserPromptSubmit — con moderación, para no repetirlo todo el tiempo.
Pruébate
¿Qué evento se activa UNA VEZ, al inicio de la sesión?
Para tomar decisiones sobre herramientas, el JSON puede ser aún más compacto con un bloque propio: JSON estructurado en stdout, con el texto dentro de hookSpecificOutput, campo additionalContext:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Branch: main\nMudanças: 2 arquivos"
}
}
Este texto —y solo este— llega a Claude como contexto adicional. No lo imprimas sin más en stdout esperando que «aparezca»; el formato importa.
Pruébate
¿Dónde está el texto que el hook inyecta como contexto para Claude?
Combina las fuentes que importan: la rama actual, lo que cambió, quizás una línea que diga que existe un CLAUDE.md con reglas. Construye la cadena y escápala con jq -Rs para convertirlo en un valor JSON válido:
# .claude/hooks/session-context.sh (SessionStart)
branch=$(git branch --show-current)
status=$(git status --short)
ctx="Branch: $branch"$'\n'"Mudanças:"$'\n'"$status"
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}' \
"$(printf '%s' "$ctx" | jq -Rs .)"
O jq -Rs . lee texto sin formato (no JSON) y devuelve una string JSON ya escapada — incluidos los saltos de línea y las comillas. Sin eso, un \n suelto rompería el JSON de salida.
La tentación es poner todo el proyecto en el additionalContext. Resístete: demasiado contexto ensucia y desperdicia espacio que Claude usaría para pensar en tu solicitud. Inyecta solo lo que cambia la decisión de él ahora — branch, qué está sucio, una o dos líneas de regla — no todo el historial.
Regla práctica: si no copiarías eso manualmente en un mensaje, tampoco lo inyectes automáticamente.
Combina los cuatro hooks de la ruta y el entorno se cuida solo: formatea al guardar, avisa cuando termina, registra lo que se ejecutó y entrega el contexto adecuado en cuanto se abre la sesión.
Pruébate
¿Por qué no incluir todo el contenido del proyecto en additionalContext todo el tiempo?
Practica ahora 0/4 hecho
Haz que la sesión se abra sabiendo ya cuál es la rama y qué cambió en el proyecto, sin que tengas que contar nada. ~8 min.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/session-context.sh" }
]
}
]
}
}✓ Capstone: la sesión se abre sabiendo ya del proyecto — la rama, los cambios, todo eso sin que pegues nada. Fin de la ruta de automatización.