Hooks de Claude Code: PreToolUse, PostToolUse y los patrones que escalan
Cómo configurar hooks de Claude Code para puertas de seguridad, format-on-save y guardarraíles de equipo sin ralentizar al agente.
Un hook de Claude Code es un comando shell que se dispara en un punto fijo del ciclo de vida del agente, recibe un evento JSON por stdin y decide vía exit code si el siguiente paso continúa. Los hooks son el único mecanismo que Claude Code expone para un control determinista, a nivel de código, sobre un agente: los prompts pueden ignorarse, las system instructions pueden sobreescribirse por contexto, pero un hook PreToolUse que sale con 2 siempre cancela la llamada al tool.
En la release actual hay disponibles doce eventos de ciclo de vida, incluidos PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, SessionEnd y Notification. Viven en settings.json, corren como procesos normales y viajan con el resto del repo. Así un equipo convierte Claude Code de una herramienta individual ingeniosa en infraestructura sobre la que otros ingenieros pueden confiar.
Lo que tendrás al final
Tres hooks production-grade commiteados en .claude/settings.json de tu repo:
- Una guardia PreToolUse que bloquea comandos shell peligrosos antes de que se ejecuten.
- Un formatter PostToolUse que corre Prettier o Ruff después de cada edición de archivo.
- Un hook Stop que registra en el log de sesión cuando Claude termina un turno.
Más el modelo mental para añadir los nueve siguientes sin romper al agente.
Lo que necesitas antes de empezar
- Claude Code v2.0.10 o posterior (las versiones anteriores no pueden mutar el input del tool desde un hook PreToolUse).
- Un repo con
.claude/en la raíz. - Bash o Python en el PATH. Usamos Bash para hooks cortos y Python cuando el parseo de JSON se vuelve no trivial.
- Un formatter instalado localmente (Prettier, Ruff, gofmt, cualquiera que salga 0 en caso de éxito).
Paso 1 · Entender los tres scopes de los hooks
Las settings se cargan en este orden, con las posteriores sobreescribiendo las anteriores para el mismo matcher:
~/.claude/settings.json: user-wide. Vive en tu home, aplica a cada proyecto. Bueno para preferencias personales (sonidos de notificación, rutas de log)..claude/settings.jsonen la raíz del repo: project-wide. Commiteado en git. Aquí viven los guardarraíles del equipo..claude/settings.local.json: override por developer, gitignoreado. Úsalo con moderación, sobre todo para debug local.
La referencia oficial en code.claude.com/docs/en/hooks es la fuente de verdad del esquema. La consultamos cada vez que un hook se porta mal; la versión docs se mueve más rápido que las guías de terceros.
Paso 2 · Configurar una guardia PreToolUse para Bash
El hook de mayor leverage en cualquier setup de equipo. PreToolUse se dispara antes de que un tool corra, ve el nombre del tool y los argumentos JSON por stdin, y puede salir 2 para bloquear. Anthropic publica una implementación de referencia en examples/hooks/bash_command_validator_example.py en el repo público de Claude Code. La copiamos, la recortamos y dejamos esta versión en .claude/hooks/bash-guard.sh:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$CMD" | grep -qE '\brm\s+-rf\s+/'; then
echo "Blocked: rm -rf / refused by PreToolUse guard." >&2
exit 2
fi
if echo "$CMD" | grep -qE '\b(curl|wget)\b.*\|.*\bsh\b'; then
echo "Blocked: piping remote content to a shell is refused." >&2
exit 2
fi
if echo "$CMD" | grep -qE '\.env(\s|$|:)'; then
echo "Blocked: .env access. Add the var to settings.local.json instead." >&2
exit 2
fi
exit 0El bloque correspondiente en .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/bash-guard.sh" }
]
}
]
}
}Tres cosas que interiorizar sobre los exit codes:
- Exit 0: el tool continúa. Lo que escribiste en stdout aparece en el transcript y puede parsearse como JSON para control avanzado.
- Exit 2: el tool se cancela. El texto en stderr vuelve a Claude como un error de tool-result, para que el modelo se autocorrija en el siguiente turno.
- Exit 1 (o cualquier otro non-zero): error no bloqueante del hook. El tool sigue adelante. Reservado para fallos internos del hook que quieras loguear pero no enforce.
Esta distinción es la causa más común de hooks rotos que vemos: un equipo quiere bloquear, sale con 1, y el comando peligroso corre igual. Cada hook de seguridad debe usar exit 2.
Paso 3 · Configurar un formatter PostToolUse
PostToolUse se dispara después de que un tool tenga éxito. No puede deshacer la acción, pero puede correr un comando de follow-up y devolver su output a Claude. El uso canónico es auto-format después de cada Write o Edit, para que el agente nunca commitee código sin formatear:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/format-on-save.sh"
}
]
}
]
}
}El script extrae la ruta del archivo del evento, ramifica por extensión y corre el formatter correcto:
#!/usr/bin/env bash
set -euo pipefail
FILE=$(jq -r '.tool_input.file_path // .tool_input.path // ""')
[ -z "$FILE" ] && exit 0
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.md) npx --no -y prettier --write "$FILE" ;;
*.py) ruff format "$FILE" ;;
*.go) gofmt -w "$FILE" ;;
esac
exit 0Dos reglas probadas en campo: mantén el formatter idempotente y silencioso en caso de éxito, y nunca lo dejes salir 2. Un exit non-zero en PostToolUse devuelve a Claude un error ruidoso que muchas veces dispara un retry redundante.
Paso 4 · Configurar un hook Stop para telemetría de sesión
Stop se dispara cuando el agente principal termina una respuesta (no en interrupt del usuario). Lo usamos para estampar una línea en un log diario de sesión, para que el equipo pueda auditar qué features tocó Claude sin rastrear el transcript:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": ".claude/hooks/log-stop.sh" }
]
}
]
}
}El evento Stop también acepta una respuesta JSON {"decision": "block", "reason": "..."} en stdout (con exit 0) para rechazar el stop y forzar a Claude a seguir trabajando. Lo usamos dos veces en producción: una para forzar una corrida de tests antes del fin de turno, otra para requerir un borrador de commit message. Útil, pero fácil de abusar, ya que un Stop block quema contexto. Por defecto, logguea; escala al blocking solo cuando el costo de parar pronto es real.
Paso 5 · Verificar que funciona
Los hooks fallan en silencio más a menudo que en voz alta. Tres checks antes de confiar en ellos en producción:
- Corre
claude --debugen el proyecto. Cada invocación de hook imprime en stderr con timing y exit code. Si tu hook falta, el matcher está mal. - Dispara el matcher a mano. Pídele a Claude que corra
rm -rf /tmp/test-diry confirma que bloquee. Pídele que escriba un archivo TS y confirma que el formatter corrió (revisa git diff). - Mide los tiempos de los hooks. Cualquier cosa por encima de 200ms en PreToolUse se acumula en la sesión. Perfilamos con
timey ponemos cualquier cosa más pesada en PostToolUse, donde la latencia queda oculta.
Fallos comunes y arreglos
El hook corre pero no bloquea
Saliste con 1 en vez de 2. O escribiste JSON con "decision": "block" pero saliste con 2; en ese caso el JSON se ignora y solo se lee stderr. Elige un camino: exit 2 con mensaje en stderr, o exit 0 con bloque decision JSON en stdout.
El hook nunca corre
Mismatch del matcher. Los tools built-in son Bash, Edit, Write, MultiEdit, Read, Grep, Glob, WebSearch, WebFetch, Task. Los tools MCP siguen mcp__<server>__<tool>. Para matchear todos los tools MCP de escritura entre servers, usa mcp__.*__write.*.
El hook se cuelga
Olvidaste leer stdin. Aunque no necesites el JSON, consúmelo: cat > /dev/null al inicio de un hook Bash alcanza. Si no, el agente queda esperando en la pipe.
El formatter corre en archivos fuera del repo
Siempre revisa la ruta antes de formatear. Claude puede editar archivos donde tenga acceso al filesystem, incluido ~/Library en macOS, y Prettier sobre un archivo de sistema es un mal día. Protégelo con un check de prefijo: [[ "$FILE" == "$PWD"* ]] || exit 0.
El output del hook aparece como mensaje de Claude
Para la mayoría de eventos, stdout es solo de debug. Para UserPromptSubmit, UserPromptExpansion y SessionStart, stdout se añade como contexto que Claude ve. Si quieres que un hook inyecte contexto (como el git log reciente al inicio de sesión), elige esos eventos. Si quieres enforcement silencioso, todo otro evento es silencioso en stdout por diseño.
Los patrones que escalan a un equipo
Los tres hooks de arriba son puntos de partida. El patrón que distingue un setup personal de uno de equipo es dónde viven los hooks y cómo evolucionan.
Commitea los hooks al repo, no a la home
Los hooks personales en ~/.claude/settings.json funcionan para una persona. Los hooks de equipo pertenecen a .claude/settings.json en la raíz del repo, commiteados y revisados en PRs como cualquier otro código. Las nuevas incorporaciones heredan los guardarraíles desde el día uno.
Mantén los hooks cortos y delega la lógica a scripts
El campo command debería llamar a un script, no contener lógica. Los comandos inline son ilegibles en JSON, difíciles de testear e imposibles de grepear. Cada hook en nuestro setup es una llamada de una línea a un script en .claude/hooks/.
Usa los hooks tipo prompt con moderación
Claude Code ahora soporta un handler "type": "prompt" que manda el evento a un modelo Claude para evaluación en lugar de un script. Funciona, pero añade latencia y coste a cada llamada de tool. Resérvalo para casos donde el matching basado en reglas no captura la intención (por ejemplo, clasificar si un comando Bash es destructivo según el contexto). Para todo lo demás, los exit codes de un script shell de 20 líneas son más rápidos, más baratos y auditables.
Versiona los hooks como dependencias
Cuando las release notes de Claude Code cambian la semántica de los hooks (y han cambiado, tres veces desde el lanzamiento de la v1.0), tus hooks necesitan un pase de regression. Mantenemos un .claude/hooks/test.sh que dispara eventos fixture a cada script y asserta los exit codes. Cinco minutos para escribirlo, ahorra horas cuando llega una v2.x con breaking changes.
Audita lo que ven los hooks
Los hooks reciben el input completo del tool, incluidos secretos que pueden pasar por comandos Bash. Trata los scripts de hooks con el mismo rigor que cualquier otro código que toca credenciales. El output del modelo puede estar sandboxed; tu hook no.
Bien hecho, los hooks son la capa donde Claude Code deja de ser una herramienta de productividad y empieza a ser parte del build system. Los primeros tres se pagan solos en una semana. Los nueve siguientes son cómo conviertes a un agente en infraestructura.
Preguntas frecuentes
- How do Claude Code hooks differ from MCP servers?
- Hooks fire deterministically on lifecycle events (before a tool runs, after a tool runs, when a session starts) and decide via exit code whether the next step proceeds. MCP servers expose new tools and resources to the agent. They solve different problems: MCP extends what Claude can do, hooks constrain how Claude does it. Most production setups use both.
- What is the latency cost of a PreToolUse hook?
- The hook runs synchronously before the tool, so its wall time is added to every matched call. A 50ms shell script is invisible; a 500ms script is felt across a multi-tool session. We profile every PreToolUse hook with the time command and aim for under 200ms. Anything heavier moves to PostToolUse, where the latency is hidden behind the tool result.
- Can a hook see the model's reasoning or only the tool call?
- Only the tool call. Hooks receive the tool name and the JSON arguments Claude is about to pass. They do not see the model's chain of thought, the system prompt, or earlier turns. If you need semantic understanding of intent, that is what the prompt-type hook handler is for, but it adds API cost on every invocation.
Studio
Empieza un proyecto.
Un partner único para empresas, sector público, startups y SaaS. Producción más rápida, tecnología moderna, costes reducidos. Un equipo, una factura.