🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

Los hooks de Claude Code

Cómo convertir una norma que Claude Code suele cumplir en una que no se puede saltar

Escrito por domin el 5 de agosto de 2026

En el post de la primera skill de verificación puse una tabla con tres sitios donde meter una norma y dejé la última fila sin desarrollar. Decía que un hook era lo que no se puede saltar bajo ningún concepto.

Este capítulo del curso Claude Code in Action de Anthropic va justo de este tema.

El problema de escribir algo en el CLAUDE.md es que es una petición y no una garantía, puede que no se haga. Puedes poner formatea siempre después de editar y Claude Code te va a hacer caso, casi siempre. Y casi siempre está muy bien mientras tú estés delante de la pantalla, pero en una sesión larga en las que mientras te vas a fregar los platos, no te puedes asegurar de que se vaya a cumplir.

Un hook es código tuyo que se ejecuta en un punto fijo del ciclo. No lo decide un modelo, se ejecuta y ya. Es decir, no dependerás de su estado de ánimo y de su contexto, con un hook, lo hará.

Los eventos que vas a usar

Hay como treinta eventos. No hace falta que te los sepas, porque en la práctica tiras siempre de los mismos cuatro o cinco, y todos caen en momentos del ciclo donde tiene sentido meter mano.

EventoCuándo saltaPara qué lo quieres
PreToolUseAntes de una llamada a una herramientaBloquear o reescribir lo que va a pasar
PostToolUseDespués de una llamada que ha ido bienFormatear, pasar el linter
StopCuando Claude quiere dar por terminado el turnoDecirle que no ha terminado
SessionStartAl arrancar la sesiónPreparar el entorno, meter contexto
InstructionsLoadedCuando se carga un CLAUDE.md o un fichero de reglasAuditar qué ha entrado de verdad

Hay un SubagentStop para cuando termina un subagente, y PreCompact y PostCompact alrededor de la compactación.

De estos, el importante es PreToolUse, porque es el único que puede parar algo antes de que ocurra. Los demás llegan cuando la cosa ya está hecha.

PreToolUse te contesta en JSON

La forma de hablarle a Claude Code desde un hook de PreToolUse es imprimir un JSON por la salida estándar y salir con código 0. El campo que decide es permissionDecision y admite tres valores: allow deja pasar la llamada, deny la corta y ask te la devuelve a ti para que decidas tú.

La forma es esta:

{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "Aquí explicas el motivo, que lo lee Claude",
        "updatedInput": {
            "command": "..."
        }
    }
}

Existe un cuarto valor, defer, pero es para ejecuciones no interactivas con -p donde otro proceso pausa la herramienta y la retoma después. Esta no lo vas a tocar.

Fíjate en updatedInput, que ahí está lo bueno. En vez de bloquear la llamada, puedes reescribirla.

Redactar en lugar de bloquear

El movimiento obvio con PreToolUse es devolver deny y cortar el comando peligroso. Está bien, funciona, y es lo que hace todo el mundo. Pero hay otro que es bastante más útil y casi nadie usa: interceptas el comando, le quitas lo que sobra y lo dejas correr.

El ejemplo del curso es con un secret. Claude Code va a lanzar un curl que lleva una clave de API viva metida en la cabecera. Bloquearlo significa que el trabajo no se hace y tienes que bajar tú a hacerlo a mano. Reescribirlo significa que el comando se ejecuta, pero el secreto nunca llega a salir.

El matcher elige a qué herramienta te enganchas:

{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "Bash",
                "hooks": [{ "type": "command", "command": "~/.claude/hooks/redactar-secretos.py" }]
            }
        ]
    }
}

Y el hook recibe el evento entero por la entrada estándar:

#!/usr/bin/env python3
import json
import re
import sys

PATRON = re.compile(r"sk_live_[A-Za-z0-9]+")

evento = json.load(sys.stdin)
entrada = evento.get("tool_input", {})
comando = entrada.get("command", "")

if not PATRON.search(comando):
    sys.exit(0)

print(
    json.dumps(
        {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "allow",
                "permissionDecisionReason": "Había un secreto en el comando y lo he sustituido",
                "updatedInput": {**entrada, "command": PATRON.sub("sk_live_REDACTADO", comando)},
            }
        }
    )
)

Lo he probado con Python 3.12.3 pasándole el evento a mano, con esta entrada:

{
    "tool_name": "Bash",
    "tool_input": {
        "command": "curl -H \"Authorization: Bearer sk_live_4eC39HqLyjWDarjtT1zdp7dc\" https://api.ejemplo.com/v1/cargos",
        "description": "Listar cargos",
        "timeout": 120000
    }
}

Sale el comando con sk_live_REDACTADO en su sitio, y description y timeout intactos. El comando se ejecuta, el trabajo se hace y la clave no se mueve de donde estaba.

Importante

updatedInput reemplaza el objeto de entrada entero, no hace merge. Si escribes solo command, te cargas los demás campos. Por eso arriba está el {**entrada, ...}, que copia lo que había y solo cambia lo que quiero cambiar.

Si en vez de .py prefieres un .sh con jq, funciona igual. Lo único que le importa a Claude Code es que salga JSON por stdout.

Los códigos de salida, y el 1 que no bloquea

No todos los hooks necesitan hablar JSON. Para lo simple basta con el código de salida y hay 3 números que hay que tener en cuenta.

El 0 es que todo bien. Si has escrito JSON por la salida estándar, Claude Code lo interpreta. Si escribes texto plano se ignora, excepto en SessionStart, UserPromptSubmit y UserPromptExpansion, donde ese texto se mete directamente en el contexto.

El 2 es un error bloqueante. Lo que escribas en stderr se le devuelve a Claude Code como contexto, o sea que ahí es donde le explicas qué ha hecho mal.

Y cualquier otro número no bloquea nada. Se registra el stderr en el log y la sesión sigue como si nada.

Advertencia

El que pilla a todo el mundo es el 1. Parece un error de toda la vida, pero para los hooks es cualquier otro número. Sales con 1 creyendo que has parado el comando y Claude Code lo ejecuta igual. Para bloquear, exit 2.

Con Stop el 2 es lo que te deja decirle todavía no has terminado, que es el hook que puse en el post de los modos de permisos ejecutando los tests. Y en PostToolUse bloquear no sirve de mucho, porque la herramienta ya se ha ejecutado, aunque el texto sí le llega a Claude. Hay eventos que directamente ignoran el bloqueo, como Notification y SessionStart: te enseñan el stderr y tiran adelante.

Sobrevivir a una compactación

El otro patrón que merece la pena montar es para las sesiones largas. Cuando una conversación se compacta se pierde un montón de detalle y Claude Code se vuelve medio amnésico a media tarea.

Aquí está el gotcha del capítulo, y es de los que te haría perder una hora tú solo: para reinyectar contexto después de una compactación no sirve PostCompact. Lo que hay que usar es SessionStart con el matcher de compact, que es el que consigue que su salida vuelva de verdad a la conversación.

{
    "hooks": {
        "SessionStart": [
            {
                "matcher": "compact",
                "hooks": [{ "type": "command", "command": "git status --short && git log --oneline -5" }]
            }
        ]
    }
}

Texto plano por stdout, que en SessionStart va directo al contexto. Con eso, después de compactar sabe en qué ficheros estabais y por dónde ibais, en vez de arrancar en frío. Si solo quieres que corra en sesiones nuevas, el matcher que buscas es startup.

Cinco hooks enteros para copiar

Y hasta aquí la teoría. Aquí van cinco casos reales, con su trozo de configuración y su script, que es lo que a mí me falta siempre en la documentación.

Los bloques de configuración van en .claude/settings.json del proyecto, o en ~/.claude/settings.json si lo quieres en todas partes. Si ya tienes hooks del mismo evento, no dupliques la clave: mete el objeto nuevo en el array que ya está. Los scripts los he dejado todos en Python porque es lo que hay en cualquier máquina, y todos leen el evento entero por stdin. Acuérdate del chmod +x, que es el fallo de la primera vez.

Todo esto está probado con Python 3.12.3 y Claude Code 2.1.

1. Que no se pueda hacer push a main

El clásico. En un repo donde todo pasa por PR no quieres que una sesión larga te suba nada directo.

{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "Bash",
                "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/no-push-main.py" }]
            }
        ]
    }
}
#!/usr/bin/env python3
import json
import subprocess
import sys

PROTEGIDAS = {"main", "master"}

evento = json.load(sys.stdin)
comando = evento.get("tool_input", {}).get("command", "")
palabras = [p for p in comando.split() if not p.startswith("-")]

if "push" not in palabras:
    sys.exit(0)

rama_actual = subprocess.run(
    ["git", "branch", "--show-current"], capture_output=True, text=True
).stdout.strip()

# git push origin main -> "main"; git push a secas -> la rama en la que estoy
argumentos = palabras[palabras.index("push") + 1 :]
destino = argumentos[1] if len(argumentos) > 1 else rama_actual

if destino.split(":")[-1] in PROTEGIDAS:
    print(f"Eso es un push a '{destino}'. Saca una rama y abre un PR.", file=sys.stderr)
    sys.exit(2)

La versión que ves por ahí busca git push en el comando y ya. Esa se come el git push a secas estando en main, que es justo el caso que te va a pasar a ti. Por eso pregunta también por la rama actual. Y el HEAD:main lo pilla por el split(":"), que si no se cuela.

2. Que no abra el .env

Este no es por desconfianza, es por el contexto. Si el .env entra en la conversación, tus claves de producción acaban en el historial y en la ventana de contexto de un modelo. Bloquear la lectura es más barato que arrepentirse.

{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "Read|Edit|Write",
                "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/secretos-fuera.py" }]
            }
        ]
    }
}
#!/usr/bin/env python3
import fnmatch
import json
import sys

PROHIBIDOS = ("*/.env", "*/.env.*", "*.pem", "*/id_rsa", "*/credentials.json")
PERMITIDOS = ("*/.env.example", "*/.env.template")

evento = json.load(sys.stdin)
fichero = evento.get("tool_input", {}).get("file_path", "")

if any(fnmatch.fnmatch(fichero, p) for p in PERMITIDOS):
    sys.exit(0)

if any(fnmatch.fnmatch(fichero, p) for p in PROHIBIDOS):
    print(f"{fichero} tiene secretos y no se abre. Tira del .env.example.", file=sys.stderr)
    sys.exit(2)

Los permitidos van antes a propósito, porque */.env.* también casa con .env.example y sin esa lista te quedas sin poder mirar la plantilla, que es lo único que necesita ver de verdad.

El matcher acepta expresiones regulares, así que Read|Edit|Write engancha las tres. Ojo con que esto no cubre un cat .env por Bash, que va por otra herramienta y por lo tanto por otro hook.

3. Cambiar npm por pnpm sin bloquear nada

Aquí es donde el updatedInput lo peta. Tienes un repo con pnpm-lock.yaml y Claude Code escribe npm install porque es lo que sale en el noventa por ciento de internet. Bloquearlo es interrumpir el trabajo para nada. Lo suyo es corregirlo y seguir.

{
    "hooks": {
        "PreToolUse": [
            {
                "matcher": "Bash",
                "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/usa-pnpm.py" }]
            }
        ]
    }
}
#!/usr/bin/env python3
import json
import sys

evento = json.load(sys.stdin)
entrada = evento.get("tool_input", {})
comando = entrada.get("command", "")

if not comando.startswith("npm ") or comando.startswith("npm exec"):
    sys.exit(0)

print(
    json.dumps(
        {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "allow",
                "permissionDecisionReason": "En este repo se usa pnpm, te lo he cambiado",
                "updatedInput": {**entrada, "command": "pnpm " + comando[4:]},
            }
        }
    )
)

Solo toca el npm del principio. Si el comando es un npm run build && npm test el segundo se queda como estaba, y me parece bien: prefiero un hook tonto que uno que reescriba media línea de shell con una expresión regular y me la líe un martes.

4. Formatear el fichero que acaba de tocar

El ejemplo de manual, y el que menos me importa de los cinco, pero funciona y cuesta cuatro líneas. Lo interesante es que formatea solo el fichero del evento, no el proyecto entero.

{
    "hooks": {
        "PostToolUse": [
            {
                "matcher": "Write|Edit",
                "hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/formatea.py" }]
            }
        ]
    }
}
#!/usr/bin/env python3
import json
import os
import subprocess
import sys

EXTENSIONES = (".astro", ".ts", ".tsx", ".mdx", ".js", ".json", ".md", ".css")

evento = json.load(sys.stdin)
fichero = evento.get("tool_input", {}).get("file_path", "")

if not fichero.endswith(EXTENSIONES) or not os.path.isfile(fichero):
    sys.exit(0)

subprocess.run(
    ["npx", "prettier", "--write", fichero],
    cwd=os.environ.get("CLAUDE_PROJECT_DIR", "."),
    capture_output=True,
)

El os.path.isfile está porque en un Write que falle el fichero no existe y te comes un error del prettier en el log cada dos por tres. El capture_output es para que no escupa nada a la conversación, que aquí no hay nada que contarle a Claude.

5. Que no dé el turno por terminado si el check falla

Este es el que más me ha salvado. Stop con exit 2 y el stderr explicando qué está roto.

{
    "hooks": {
        "Stop": [
            {
                "hooks": [
                    {
                        "type": "command",
                        "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/no-has-terminado.py",
                        "timeout": 120
                    }
                ]
            }
        ]
    }
}
#!/usr/bin/env python3
import json
import os
import subprocess
import sys

evento = json.load(sys.stdin)

# Si este turno ya viene de un Stop que bloqueó, me salgo o entro en bucle
if evento.get("stop_hook_active"):
    sys.exit(0)

resultado = subprocess.run(
    ["npx", "astro", "check"],
    cwd=os.environ.get("CLAUDE_PROJECT_DIR", "."),
    capture_output=True,
    text=True,
)

if resultado.returncode != 0:
    print("astro check ha fallado, no des esto por terminado:", file=sys.stderr)
    print(resultado.stdout[-2000:], file=sys.stderr)
    sys.exit(2)

Dos cosas que no son evidentes. La primera, el stop_hook_active: viene a true cuando el turno que está terminando salió de un bloqueo tuyo anterior, y si no lo miras te montas un bucle. La segunda, el timeout de la configuración va en segundos y por defecto son 60, que para un astro check en un proyecto mediano se queda corto. En este blog tarda unos 12 segundos con 166 ficheros, pero he visto proyectos donde eso es un minuto largo.

Y el Stop no es PostToolUse: aquí el stderr sí le llega a Claude y sí le hace volver a trabajar.

Advertencia

Este hook, tal cual, en este blog me bloquea el turno siempre. El astro check de aquí devuelve un error de toda la vida en PasswordStrengthDemo.astro que llevo meses sin arreglar, así que el returncode nunca es 0. Antes de montarte un Stop así, comprueba que tu check está limpio de verdad, o el hook te va a estar dando la turra por algo que no tiene nada que ver con lo que acabas de escribir.

Qué merece un hook y qué no

La tentación al leer esto es irse a formatear el código con PostToolUse y quedarse ahí. Es lo primero que sale en todos los ejemplos y es lo que menos aporta, porque si un día no se formatea un fichero no ha pasado nada, lo formateas mañana.

El criterio que yo uso es el mismo que ya puse en el post de skills, y no ha cambiado: si que se salte la norma un día entre veinte te parece asumible, escríbela en el CLAUDE.md y a otra cosa. Si no lo es, si es de esas que un día te cuesta una tarde, entonces es un hook. No hay más.

De los cinco de arriba, en el blog este se queda el del push a main y el del .env. El del astro check lo quiero, pero primero me toca arreglar el error ese que llevo meses mirando de reojo. Y el del prettier me da igual, la verdad.

EA! Nos vemos en los bares! 🍻