Anatomía de un hook
Antes de construir hooks útiles, hay que entender sus piezas: cuándo se dispara (el evento), para qué acción (el matcher), qué ejecuta (el handler, casi siempre un command), y cómo decide qué pasa con la acción (la decisión: exit codes o JSON). Con esto, el resto del curso son variaciones.
1. Los eventos: cuándo se dispara
Un hook se engancha a un punto del ciclo de vida. Los que más usarás:
| Evento | Se dispara… |
|---|---|
PreToolUse | antes de ejecutar una tool (puede bloquearla) |
PostToolUse | después de que una tool tenga éxito |
UserPromptSubmit | cuando envías un prompt, antes de procesarlo |
SessionStart | al empezar, reanudar, tras /clear o tras compactar (lo distingues por el source) |
Stop / SubagentStop | cuando Claude (o un subagente) termina de responder |
La regla mental: para impedir algo,
PreToolUse(corre antes y puede vetar). Para reaccionar a algo ya hecho (formatear, loguear),PostToolUse.
Esos son los del día a día, pero el catálogo es mucho más amplio: hay eventos para subagentes (SubagentStart), compactación (PreCompact/PostCompact), fin de sesión (SessionEnd), carga de CLAUDE.md (InstructionsLoaded), cambios de config (ConfigChange), worktrees, notificaciones (MessageDisplay)… La referencia tiene la lista entera; para la suite de este curso te bastan los cinco de arriba.
2. La estructura en settings.json
Los hooks viven en settings.json con tres niveles: evento → matcher → handler. El matcher filtra qué tools disparan el hook ("Bash", "Edit|Write", o vacío para todas).
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}
Como cualquier cosa en .claude/settings.json, esto se versiona y lo hereda el equipo (lo viste en el curso de permisos).
El
type: "command"no es el único. Un hook puede ser tambiénhttp(llama a un endpoint),mcp_tool, o —basados en modelo—promptyagent. Pero esos dos últimos vuelven a depender del juicio del modelo: ya no son deterministas. Todo este curso usacommand, que es lo que ejecuta código y siempre hace lo mismo. Es el muro.
Cada handler command admite además unos campos opcionales que conviene conocer:
| Campo | Para qué |
|---|---|
timeout | segundos máximos antes de abortar el hook |
if | condición declarativa estilo permiso ("Bash(rm *)") — el hook solo corre si el comando casa, sin tener que filtrar tú en el script |
args | argumentos en exec-form: lanza el binario directo, sin shell, así los ${CLAUDE_PROJECT_DIR} y demás no necesitan comillas |
statusMessage | texto que se muestra en el spinner mientras corre |
3. El input: JSON por stdin
Cuando se dispara, Claude Code le pasa al command un JSON por stdin con el contexto de la acción. Los campos clave:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /tmp/build" }
}
Según la tool, tool_input trae unos campos u otros: Bash → command; Write → file_path, content; Edit → file_path, old_string, new_string. Por eso los hooks suelen usar jq para extraer lo que les interesa:
COMMAND=$(jq -r '.tool_input.command' < /dev/stdin)
4. La salida: exit 0 vs exit 2
Así decide un hook qué pasa con la acción:
| Exit code | Efecto |
|---|---|
| 0 | Éxito. La acción continúa. (Si imprime JSON en stdout, se usa para control estructurado.) |
| 2 | Bloqueo. La acción se cancela y el stderr se le devuelve a Claude como mensaje de error. |
| otro | Error no bloqueante: se loguea, la ejecución continúa. |
El patrón más común de todo el curso —bloquear algo— es exactamente esto: en un hook PreToolUse, si detectas lo que no quieres, escribes el motivo a stderr y haces exit 2.
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command' < /dev/stdin)
if echo "$COMMAND" | grep -q 'rm -rf'; then
echo "Bloqueado: rm -rf no está permitido." >&2
exit 2 # cancela la acción y avisa a Claude
fi
exit 0 # todo OK, sigue
Los scripts suelen ir en
.claude/hooks/y se referencian con"$CLAUDE_PROJECT_DIR"/.claude/hooks/script.sh. Recuerdachmod +x.
5. El control fino: salida en JSON
exit 2 es la vía rápida y la que usamos en todo el curso. Pero cuando un hook necesita más matiz que "bloquea / deja pasar", en vez de un código de salida puede imprimir JSON en stdout (con exit 0). Lo más útil:
# En un PreToolUse: denegar con motivo, sin depender del exit code
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Comando destructivo bloqueado por el hook"
}
}'
permissionDecision(enPreToolUse) tomaallow/deny/ask/defer: no solo bloqueas, también puedes pre-aprobar o forzar la pregunta.additionalContextte deja inyectar texto en el contexto de Claude (envuelto en un recordatorio del sistema, no como mensaje de chat). Ideal para guiar en vez de solo cortar — p.ej. desde unPostToolUse: "este fichero es generado; editasrc/schema.tsy correbun generate".- Campos universales como
continue: false(paran el turno entero) osystemMessage(aviso visible al usuario) aplican a cualquier hook.
Cuándo cada uno:
exit 2para el bloqueo simple (el 90% del curso). JSON cuando quieras decidir entre allow/ask/deny, devolver contexto, o afinar el comportamiento. Los dos conviven; empieza por el exit code y sube a JSON cuando te quede corto.
Qué llevas hasta aquí
- Un hook = evento (cuándo) + matcher (para qué tool) + handler (qué, normalmente
type: command) + decisión (exit code o JSON). PreToolUsepara impedir;PostToolUsepara reaccionar; y un catálogo amplio de eventos más allá de esos.- El command recibe JSON por stdin (usa
jq) y decide conexit 2+stderr(bloqueo simple) o con JSON en stdout (permissionDecision,additionalContext) para control fino.
Con esto montado en la cabeza, la siguiente lección construye el primer hook real: formateo automático.
Fuente oficial: Hooks reference
¿Quieres llevar la cuenta de las lecciones que has terminado?
Entrar para guardar progreso