Cap 5: Hooks

Por: Artiko
claude-codehooksautomationlifecycle

Qué son los Hooks

Los hooks son comandos shell, HTTP endpoints, prompts LLM o tools MCP definidos por vos que se ejecutan automáticamente en puntos específicos del lifecycle de Claude Code. Permiten automatización, validación y comportamiento custom.

Cadencias del lifecycle

Los hooks disparan en tres ritmos:

  • Una vez por sesión: SessionStart, SessionEnd
  • Una vez por turno: UserPromptSubmit, Stop, StopFailure
  • Por cada tool call: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied

Más eventos async: FileChanged, CwdChanged, ConfigChange, InstructionsLoaded, Notification, MessageDisplay, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, SubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdle, Elicitation, ElicitationResult, UserPromptExpansion, Setup, PostToolBatch.

En total son 30 eventos soportados. No existe ningún evento PreSubmit, ToolUse ni AgentStart.

Eventos principales

SessionStart

Cuando la sesión inicia o se reanuda. Matchers: startup, resume, clear, compact.

Acceso a CLAUDE_ENV_FILE para persistir variables:

#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
  echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
fi

Output:

{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "Contexto para Claude"
  }
}

UserPromptSubmit

Antes de que Claude procese el prompt. Timeout default 30s (más corto que otros). Sin matchers (siempre dispara).

{
  "decision": "block",
  "reason": "Por qué se bloqueó",
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "Contexto adicional",
    "sessionTitle": "Nombre auto-generado"
  }
}

Salida en stdout plana se agrega como contexto (sin JSON necesario).

UserPromptExpansion

Cuando un slash command tipeado se expande antes de llegar a Claude. Matcher: command_name o vacío para todos.

{
  "expansion_type": "slash_command|mcp_prompt",
  "command_name": "example-skill",
  "command_args": "arg1 arg2",
  "command_source": "plugin|project|user|mcp",
  "prompt": "/example-skill arg1 arg2"
}

PreToolUse

Antes de que un tool se ejecute (puede bloquearlo). Matcher: nombre del tool (Bash, Edit, Write, Read, Glob, Grep, Agent, WebFetch, WebSearch, AskUserQuestion, ExitPlanMode, tools MCP).

Decision control:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow|deny|ask|defer",
    "permissionDecisionReason": "Por qué",
    "updatedInput": { "command": "comando modificado" },
    "additionalContext": "Contexto para Claude"
  }
}
  • allow: permite
  • deny: bloquea
  • ask: escala al usuario con dialog
  • defer: deja decidir al modelo

PostToolUse / PostToolUseFailure

Después de tool exitoso/fallido. Matcher: nombre del tool.

{
  "tool_name": "Write",
  "tool_input": { "file_path": "...", "content": "..." },
  "tool_result": "File written successfully"
}

Puede bloquear el loop agentic con decision: "block", y reemplazar el resultado que ve Claude con updatedToolOutput.

PostToolUse, PostToolUseFailure y PermissionDenied no pueden bloquear la herramienta: ésta ya se ejecutó. decision: "block" solo interrumpe el loop agéntico posterior.

PostToolBatch

Después del batch completo de tool calls paralelos, antes del próximo model call. Sin matchers.

PermissionRequest

Cuando aparece el permission dialog. Matcher: tool name.

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionRequest",
    "decision": {
      "behavior": "allow|deny",
      "updatedInput": { "command": "npm run lint" },
      "rule": "Bash(npm *)"
    }
  }
}

PermissionDenied

Cuando el classifier de auto-mode deniega. Sin blocking.

{
  "hookSpecificOutput": {
    "hookEventName": "PermissionDenied",
    "retry": true
  }
}

retry: true le dice al modelo que puede reintentar.

SubagentStart / SubagentStop

Cuando un subagent se spawnea o termina. Matcher: agent type.

Stop / StopFailure

Stop: Claude termina de responder. Sin matchers. Puede bloquear.

Un hook Stop que bloquea el fin del turno es el nivel 3 de los cuatro mecanismos para forzar verificación (1: pedir el check en el prompt; 2: condición de /goal; 3: hook Stop; 4: subagente verificador). Pero Claude Code lo anula tras 8 bloqueos consecutivos: diseñar un gate asumiendo bloqueo indefinido es un antipatrón.

StopFailure: el turno termina por error de API. Matchers por tipo: rate_limit, authentication_failed, oauth_org_not_allowed, billing_error, invalid_request, server_error, max_output_tokens, unknown. Sin control.

TaskCreated / TaskCompleted

Cuando una tarea se crea o completa. Pueden bloquear.

TeammateIdle

Agent team teammate por ir idle.

ConfigChange

Cuando cambia configuración durante la sesión. Matchers: user_settings, project_settings, local_settings, policy_settings, skills.

CwdChanged

Cuando cambia el working directory (ej con cd). Sin matchers. Acceso a CLAUDE_ENV_FILE. Útil para direnv y similares.

{ "old_cwd": "/home/user/project", "new_cwd": "/home/user/project/src" }

FileChanged

Cuando un archivo observado cambia en disco. Matcher: nombres literales con |.

{
  "hooks": {
    "FileChanged": [
      {
        "matcher": ".envrc|.env",
        "hooks": [{ "type": "command", "command": "direnv allow" }]
      }
    ]
  }
}

WorktreeCreate / WorktreeRemove

Cuando se crea/elimina un worktree (con --worktree o isolation: "worktree").

WorktreeCreate debe devolver el path:

#!/bin/bash
WORKTREE_PATH="/tmp/git-worktree-$$"
git worktree add "$WORKTREE_PATH" feature-branch
echo "$WORKTREE_PATH"

PreCompact / PostCompact

Antes/después de compactación. Matcher: manual o auto. PreCompact puede bloquear.

InstructionsLoaded

Cuando se carga CLAUDE.md o .claude/rules/*.md. Solo observabilidad.

Matchers: session_start, nested_traversal, path_glob_match, include, compact.

Setup

Para flags CLI --init-only, --init, o --maintenance. Matchers: init, maintenance. Acceso a CLAUDE_ENV_FILE.

Notification

Cuando Claude Code envía notificación. Matcher: tipo (permission_prompt, idle_prompt, auth_success, elicitation_*).

Emitir notificaciones del terminal (v2.1.141+):

#!/bin/bash
input=$(cat)
body=$(jq -r '.message // "Needs attention"' <<<"$input")
seq=$(printf '\033]777;notify;Claude Code;%s\007' "$body")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'

Secuencias permitidas: OSC 0/1/2 (window titles), OSC 9 (iTerm2/Windows Terminal/WezTerm + 9;4 taskbar progress), OSC 99 (Kitty), OSC 777 (Ghostty/Warp/urxvt), BEL.

Elicitation / ElicitationResult

Cuando un MCP server pide input al usuario / cuando el usuario responde. Matcher: server name.

SessionEnd

Cuando termina la sesión. Matchers: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other.

Tipos de hooks

Command

{
  "type": "command",
  "command": "/path/to/script.sh",
  "args": [],
  "async": false,
  "asyncRewake": false,
  "shell": "bash",
  "timeout": 600,
  "statusMessage": "Validando...",
  "once": false,
  "if": "Bash(git *)"
}

Exec form (con args): cada element es un argumento sin tokenización shell.

Shell form (sin args): interpreta pipes, &&, globs y variables. Hay que quotear placeholders.

Async: corre en background. Con asyncRewake: true, despierta a Claude con exit 2 mostrando stderr como system reminder.

PowerShell en Windows:

{ "type": "command", "command": "Get-ChildItem -Recurse", "shell": "powershell" }

HTTP

{
  "type": "http",
  "url": "http://localhost:8080/hooks/pre-tool-use",
  "headers": { "Authorization": "Bearer $MY_TOKEN" },
  "allowedEnvVars": ["MY_TOKEN"],
  "timeout": 600
}

POST con JSON del hook input. Respuesta:

  • 2xx empty: success, sin output
  • 2xx text: success, texto como contexto
  • 2xx JSON: parseado como decision
  • Non-2xx / connection failure / timeout: non-blocking error

MCP Tool

{
  "type": "mcp_tool",
  "server": "my_server",
  "tool": "security_scan",
  "input": { "file_path": "${tool_input.file_path}" },
  "timeout": 600
}

La salida text del tool se trata como stdout. Si es JSON válido, se procesa como decision.

Prompt

{
  "type": "prompt",
  "prompt": "Is this code safe?\n\n$ARGUMENTS",
  "model": "opus",
  "timeout": 30
}

$ARGUMENTS se reemplaza con el hook input JSON. El modelo devuelve yes/no como JSON.

model acepta un alias (default, best, fable, sonnet, opus, haiku, sonnet[1m], opus[1m], opusplan) o un ID completo como claude-opus-4-8 o claude-sonnet-5.

Agent (experimental)

{
  "type": "agent",
  "prompt": "Verify deployment.\n\n$ARGUMENTS",
  "timeout": 60
}

Spawnea un subagente que puede usar tools antes de devolver decisión.

Campos comunes

CampoDescripción
typecommand, http, mcp_tool, prompt, agent
ifPermission rule para filtrar ("Bash(git *)"). Solo en tool events
timeoutSegundos antes de cancelar (ver defaults abajo)
statusMessageMensaje custom mientras corre
onceSi true, corre una vez por sesión. Solo en skill/agent frontmatter

Timeouts por defecto: 600 s para hooks command, http y mcp_tool; 30 s para UserPromptSubmit; 10 s para MessageDisplay; 30 s para hooks prompt; 60 s para hooks agent. Un hook lento congela el turno hasta esos límites.

Matching de rutas en if (v2.1.214+): un patrón de un solo segmento como Edit(src/**) coincide solo con src en el directorio de trabajo. Para cualquier profundidad hay que escribir Edit(**/src/**). Aplica al campo if de hooks y también a las reglas de permisos.

Exit codes

ExitComportamiento
0Success. Stdout parseado como JSON output
2Blocking error. Se ignora stdout/JSON y stderr es la razón del bloqueo
OtrosNon-blocking error. Stderr al debug log; ejecución continúa

Con exit 0 se parsea stdout como JSON, pero solo UserPromptSubmit, UserPromptExpansion y SessionStart inyectan el stdout plano como contexto visible para Claude.

Exit 1 es error no bloqueante y la acción continúa: un hook de política debe usar exit 2.

Desde v2.1.214, un exit 2 acompañado de JSON inválido sí bloquea, usando stderr como razón. Antes se trataba como error no bloqueante.

Exit 2 bloqueante por evento:

Puede bloquearEventos
PreToolUse, PermissionRequest, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange, PostToolBatch, PreCompact, Elicitation, ElicitationResult, WorktreeCreate
NoStopFailure, PostToolUse, PostToolUseFailure, PermissionDenied, Notification, SubagentStart, SessionStart, Setup, SessionEnd, CwdChanged, FileChanged, PostCompact, WorktreeRemove, InstructionsLoaded

Excepción: en WorktreeCreate cualquier código de salida distinto de 0 aborta la creación del worktree, no solo el 2.

Matchers

MatcherEvaluado comoEjemplo
"*", "", omitidoTodosCualquier ocurrencia
Solo letras/dígitos/_/|String exacto o lista |-separatedBash, Edit|Write
Otros caracteresRegex JS^Notebook, mcp__memory__.*

FileChanged no usa reglas estándar — son filenames literales: .envrc|.env.

MCP tools: mcp__<server>__<tool>. Todos los de un server: mcp__memory__.*.

Cuidado con los servidores MCP de plugins: sus herramientas se llaman mcp__plugin_<plugin>_<server>__<tool> (el servidor se registra como plugin:<plugin>:<server>). Un matcher escrito contra la clave desnuda, por ejemplo mcp__database-tools__.*, nunca dispara para un servidor provisto por un plugin.

JSON output universal

CampoDefaultDescripción
continuetrueSi false, Claude para. Precede a campos event-specific
stopReasonMensaje al usuario cuando continue: false
suppressOutputfalseOmite stdout del debug log
systemMessageWarning al usuario
terminalSequenceEscape sequence para emitir (desktop notif, window title, bell)

Los campos específicos de cada evento van dentro de hookSpecificOutput, con hookEventName obligatorio. Las cadenas del JSON de salida se truncan a 10.000 caracteres.

Configuración

UbicaciónScopeShareable
~/.claude/settings.jsonTodos los proyectosNo
.claude/settings.jsonProyecto
.claude/settings.local.jsonProyectoNo (gitignored)
Managed policyOrganizaciónSí (admin)
Plugin hooks/hooks.jsonDonde el plugin esté enabled
Skill/agent frontmatterMientras esté activoSí (en el componente)
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

Deshabilitar todos los hooks

{ "disableAllHooks": true }

Controles empresariales (managed-only)

Estas claves solo tienen efecto desde managed settings y anulan por completo los hooks de otras fuentes:

ClaveEfecto
allowManagedHooksOnly: trueBloquea hooks de usuario, proyecto y plugin (salvo plugins force-enabled)
strictPluginOnlyCustomizationBloquea además skills, agents, hooks y servidores MCP de fuentes de usuario y proyecto

Path placeholders

  • ${CLAUDE_PROJECT_DIR}: raíz del proyecto
  • ${CLAUDE_PLUGIN_ROOT}: directorio de instalación del plugin (cambia con updates)
  • ${CLAUDE_PLUGIN_DATA}: directorio persistente del plugin (sobrevive updates)

Preferí exec form para hooks que usan placeholders:

{
  "type": "command",
  "command": "node",
  "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/format.js", "--fix"]
}

Shell form requiere quoting: node "${CLAUDE_PLUGIN_ROOT}"/scripts/format.js.

Hooks en Skills y Agents

---
name: secure-ops
description: Operaciones con security checks
hooks:
  PreToolUse:
    - matcher: "Bash"
      hooks:
        - type: command
          command: "./scripts/security-check.sh"
---

Todos los eventos soportados. Para subagents, Stop hooks se convierten automáticamente a SubagentStop. Scope al lifetime del componente.

Campos comunes de input

Todos los hooks reciben:

{
  "session_id": "abc123",
  "prompt_id": "prompt-456",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/working/dir",
  "permission_mode": "default|plan|acceptEdits|auto|dontAsk|bypassPermissions",
  "hook_event_name": "EventName",
  "effort": { "level": "low|medium|high|xhigh|max" }
}

prompt_id está disponible desde v2.1.196.

Cuando corre con --agent o dentro de subagent:

{ "agent_id": "id", "agent_type": "name" }

Env var disponible: $CLAUDE_EFFORT.

El menú /hooks

Tipeá /hooks para abrir un browser read-only de hooks configurados. Muestra eventos, conteos, matchers, handlers, source (User/Project/Local/Plugin/Session/Built-in). Read-only — editá settings JSON directamente para cambios.

Seguridad y límites

Los hooks de tipo command ejecutan comandos shell con todos tus permisos de usuario. Prácticas obligatorias:

  • Revisar y probar cada hook antes de habilitarlo.
  • Validar y sanitizar todo lo que llega por stdin.
  • Entrecomillar siempre las variables: "$VAR".
  • Bloquear path traversal (..) en las rutas que recibe el hook.
  • Usar rutas absolutas con ${CLAUDE_PROJECT_DIR}.
  • Evitar tocar archivos sensibles: .env, .git/, llaves privadas.

Los hooks no son control de acceso duro. El filtro if es best-effort: para imponer un allow o un deny estricto se usa el sistema de permisos (permissions.deny / permissions.ask), no un hook. Los hooks son deterministas para efectos secundarios (formatear, loggear, notificar), no para enforcement.

flowchart LR
  A[Necesito garantizar algo] --> B{Es un allow/deny duro?}
  B -- Sí --> C[permissions.deny / ask]
  B -- No --> D{Debe ocurrir siempre?}
  D -- Sí --> E[Hook: efecto secundario determinista]
  D -- No --> F[CLAUDE.md o skill: advisory]

Ejemplo: bloquear rm -rf

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(rm *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

Script:

#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')

if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Comando destructivo bloqueado"
    }
  }'
else
  exit 0
fi

Ejemplo: lint después de Edit/Write

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/path/to/lint-check.sh" }]
      }
    ]
  }
}

Siguiente: MCP Servers