SpyBara
Go Premium

plugins/mods/events.md 2026-10-01 23:59 UTC to 2026-10-02 10:01 UTC

This page contains 90 additions and 63 deletions.

2026
Thu 1 23:59 Fri 2 10:01

Reaccionar a eventos con un mod

Maneja eventos de Claude Code desde un mod: observa, reescribe o responde llamadas de herramientas, indicaciones y turnos, filtra qué eventos maneja un hook y planifica para otros mods.

Un hook es un manejador de eventos: una función que Claude Code ejecuta cuando ocurre un evento nombrado. Claude Code dispara un evento en cada punto donde está a punto de actuar, como cuando ejecuta una herramienta, envía una indicación, envía una solicitud al modelo, o inicia o termina una sesión. Tu hook se ejecuta antes de que Claude Code actúe, por lo que puede observar el evento, reescribirlo o responderlo en lugar de Claude Code. Registras un hook con on(eventName, handler).

Construye tu primer mod antes de empezar aquí. Para cada evento y sus campos exactos, consulta la referencia o lee los tipos para tu compilación.

Cómo un hook maneja un evento

Un hook se sitúa entre un evento y lo que Claude Code haría al respecto, por lo que puede observar el evento, reescribirlo o responderlo él mismo. Recibe tres argumentos: la API de mods como $, el evento como e, y el siguiente manejador como next. Los manejadores de un evento forman una cadena de middleware. next(e) llama al siguiente manejador, que es el hook de otro mod o, al final de la cadena, el comportamiento propio de Claude Code, y se resuelve al resultado. Lo que tu hook hace con next decide cuál de los tres hace.

Observar un evento

Para observar un evento sin cambiarlo, haz tu trabajo y devuelve next(e). Este hook registra cada herramienta que Claude está a punto de usar:

on('tool.call', async ($, e, next) => {
  // Se ejecuta antes de que la herramienta se ejecute
  $.ui.log('Claude is about to use ' + e.tool)
  // Pasa el evento sin cambios
  return next(e)
})

Antes de que cada herramienta se ejecute, aparece una línea atenuada como ● my-mod: Claude is about to use Bash en la transcripción, donde my-mod es el nombre de tu plugin. La herramienta se ejecuta como lo haría sin el mod.

Para actuar después del evento, await next(e), haz tu trabajo y devuelve el resultado. Este hook registra cada herramienta después de que se ha ejecutado:

on('tool.call', async ($, e, next) => {
  // Deja que la herramienta se ejecute y espera su resultado
  const result = await next(e)
  // Se ejecuta después de que la herramienta se ejecuta
  $.ui.log(e.tool + ' finished')
  // Devuelve el resultado sin cambios
  return result
})

La línea ahora aparece después de que cada herramienta termina. Claude lee el mismo resultado de cualquier manera, porque el hook devuelve lo que next(e) se resolvió.

Reescribir un evento

Para cambiar aquello sobre lo que actúa Claude Code, como el texto de un prompt, llama a next con una copia modificada del evento. El evento en sí es inmutable: está congelado en cada profundidad, y asignar a un campo lanza una excepción. Este hook recorta cada prompt antes de que se envíe:

on('prompt.submit', async ($, e, next) => {
  // Pasa una copia del evento con su texto cambiado
  return next({ ...e, text: e.text.trim() })
})

Los manejadores posteriores y Claude Code reciben la indicación recortada y nunca ven la original. También puedes cambiar el resultado: await next(e), luego devuelve una copia del resultado con un campo reemplazado.

Responder un evento

Para manejar un evento tú mismo, devuelve un resultado sin llamar a next. Eso cortocircuita la cadena, por lo que los mods posteriores y el comportamiento propio de Claude Code no se ejecutan. Este hook rechaza cada comando Bash:

on('tool.call', { tool: 'Bash' }, async () => {
  // Sin llamada a next, por lo que el comando nunca se ejecuta
  return { deny: 'Bash is turned off in this project. Use the file tools.' }
})

Cuando Claude intenta un comando Bash, el comando no se ejecuta y Claude lee el texto deny como el resultado de la herramienta. Cada evento tiene su propia forma de resultado, que la referencia de eventos enumera.

Filtrar qué eventos maneja un hook

Para ejecutar un hook solo para algunos eventos, pasa un filtro como segundo argumento a on. Claude Code llama al filtro un matcher. Es un objeto cuyos campos se comparan con los del evento, y el hook se ejecuta solo cuando cada campo coincide. Un campo puede ser un valor, una matriz de valores permitidos o una expresión regular.

Cada línea en este ejemplo registra la misma función, hook, para un conjunto más estrecho de llamadas de herramientas:

// Una cadena coincide con un valor: solo llamadas Bash
on('tool.call', { tool: 'Bash' }, hook)
// Una matriz coincide con cualquier valor en ella: llamadas Edit y Write
on('tool.call', { tool: ['Edit', 'Write'] }, hook)
// Una expresión regular coincide por patrón: cada herramienta de un servidor MCP
on('tool.call', { tool: /^mcp__github__/ }, hook)

hook se ejecuta una vez para una llamada Bash, Edit o Write, y una vez para una llamada a una herramienta cuyo nombre comienza con mcp__github__. Una llamada a cualquier otra herramienta, como Read, no coincide con ninguna de las tres, por lo que hook no se ejecuta para ella.

El nombre del evento puede ser un comodín. 'classic.*' coincide con cada evento de hook de configuración. '*' coincide con cada evento excepto los eventos de telemetría, que usan su propio nombre y un filtro { to: 'collector' }.

Registra cada evento una vez por matcher. Si llamas a on dos veces para session.start sin un matcher, el módulo falla al cargar con on("session.start") is registered twice without a matcher. Pon todo lo que tu mod hace al inicio de la sesión en un hook.

Intercepta lo que Claude está haciendo

Maneja estos eventos para ver o cambiar una llamada a herramienta, un prompt o un turno mientras ocurre. Para ver cada evento y lo que puede devolver un hook, consulta la referencia de eventos.

Protege o cambia una llamada a herramienta

Un hook tool.call ve cada herramienta que Claude está a punto de usar, así que puede rechazar la llamada, cambiar sus argumentos o dejarla pasar. tool.call se dispara cuando Claude Code está a punto de ejecutar una herramienta, incluidas las llamadas que hace un subagente y las llamadas a herramientas MCP. e.tool es el nombre de la herramienta y los argumentos de la herramienta son campos de e, como e.command para Bash. Cuando llamas a next(e), Claude Code ejecuta la comprobación de permisos y luego la herramienta.

Este hook rechaza un comando de Bash que hace un push forzado y le dice a Claude por qué:

// The matcher limits the hook to Bash calls, so e.command is the shell command
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
  if (/git push .*--force/.test(e.command)) {
    // Returning without calling next answers the event, so the command never runs
    return { deny: 'Force pushes are not allowed in this repository. Push to a new branch instead.' }
  }
  // Every other command goes on to the permission check and then to Bash
  return next(e)
})

Cuando Claude intenta git push --force, el comando no se ejecuta y no aparece ninguna solicitud de permiso, porque el hook nunca llama a next. Claude lee el texto de deny como el resultado de la herramienta, así que escríbelo como una instrucción sobre la que Claude pueda actuar. Cualquier otro comando de Bash se ejecuta como lo haría sin el mod.

Para actuar después de que una herramienta se haya ejecutado, usa await next(e), haz tu trabajo y devuelve lo que next te dio. Este hook registra cada archivo .mdx que Claude cambia, con $.ui.log, que agrega a la transcripción una línea atenuada que Claude no lee:

on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
  // Wait for the permission check and the tool, and keep what they produced
  const result = await next(e)
  // A refused call comes back as { deny }, and a failed one has isError set
  const changed = !result.deny && !result.isError
  if (changed && e.file_path.endsWith('.mdx')) $.ui.log('Claude changed ' + e.file_path)
  // Return the result as it came, so Claude reads what the tool returned
  return result
})

Después de que Claude edita o escribe un archivo .mdx, una línea atenuada en la transcripción nombra el archivo. No se registra nada para otro tipo de archivo, ni para una llamada que fue rechazada o que falló. La vista que Claude tiene de la llamada no cambia, porque el hook devuelve el resultado que recibió.

Para cambiar una llamada, pasa los argumentos modificados a next. Para reintentar una llamada, vuelve a llamar a next(e): un hook que ve isError en el primer resultado puede ejecutar la herramienta una segunda vez y devolver ese resultado. Para responder tú mismo a una llamada, devuelve un objeto con un campo result, como { result: 'Skipped by my-mod' }, sin llamar a next. Cuando haces eso, no aparece ninguna solicitud de permiso y la herramienta no se ejecuta, así que el resultado que devuelves es todo lo que Claude sabe sobre lo que pasó.

Los hooks de la configuración administrada de tu organización se ejecutan antes que el hook tool.call de cualquier mod, y un bloqueo de uno de ellos es definitivo.

Retén una llamada a herramienta hasta que el usuario decida

Un hook puede pausar una llamada a herramienta y preguntarle al usuario qué hacer antes de que continúe. Un hook tool.call puede usar await antes de llamar a next o de devolver un valor, y la llamada a herramienta queda pendiente hasta entonces. Para plantearle la pregunta al usuario, llama a $.ui.ask. Muestra tu pregunta sobre una lista numerada de tus opciones, en el diálogo que Claude usa para preguntarte algo, y se resuelve con la etiqueta que elige el usuario. Después de tus opciones, el diálogo agrega una fila para escribir una respuesta diferente y una fila Chat about this.

El patrón RISKY de este ejemplo coincide con rm -r, rm -rf, git reset --hard y git push con --force, y no detecta otras formas de escribirlos, como git push -f. Este módulo pregunta antes de ejecutar un comando de Bash que coincide con el patrón:

const RISKY = /\brm\s+-rf?\b|\bgit\s+reset\s+--hard\b|\bgit\s+push\b.*--force/

export function register(on) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    // Let every other command through without a question
    if (!RISKY.test(e.command)) return next(e)
    // Start from the safe answer, so a question nobody answers refuses the command
    let answer = 'Refuse'
    try {
      // The tool call waits here until the user picks one of the two labels
      answer = await $.ui.ask('Run this command? ' + e.command, ['Run it', 'Refuse'])
    } catch {
      // The user dismissed the question, or this is a claude -p run with nobody to ask
    }
    if (answer !== 'Run it') {
      // Answer without calling next, so the command doesn't run
      return { deny: 'The user declined this command. Ask before trying a different approach.' }
    }
    return next(e)
  })
}

Cuando Claude intenta un comando como rm -rf build, aparece la pregunta con el comando incluido, y el comando espera la respuesta:

  • El usuario elige Run it: el hook llama a next(e), y la comprobación de permisos habitual se sigue ejecutando después
  • El usuario elige Refuse: el comando no se ejecuta y Claude lee el texto de deny
  • El usuario escribe una respuesta: $.ui.ask se resuelve con el texto escrito. El hook lo compara con Run it, así que cualquier otro texto rechaza el comando.
  • Nadie responde: $.ui.ask se rechaza cuando el usuario descarta la pregunta o elige Chat about this, y en una ejecución de claude -p, así que el bloque catch deja la respuesta en Refuse

Mantén la espera dentro de una llamada a la API de mods como $.ui.ask, porque ese tiempo no cuenta para el límite de tiempo del hook. El tiempo que pasas esperando una promesa propia sí cuenta. Claude Code omite un hook que excede el tiempo límite, así que el comando retenido se ejecutaría.

Aprueba o rechaza una llamada a herramienta antes de preguntarle al usuario

Para decidir si una llamada a herramienta puede ejecutarse, maneja tool.check, el evento en el que Claude Code toma esa decisión. Se dispara después de que las reglas de permisos y los hooks de configuración han decidido, y next(e) se resuelve con su decisión: allow, ask o deny. Tu hook devuelve esa decisión u otra diferente. e.input contiene los argumentos de la herramienta, como command para Bash.

Para un comando o una ruta fijos, usa una regla de permisos como Bash(npm test), que no requiere código. Maneja tool.check cuando la decisión depende de lo que es cierto en ese momento, como la rama de Git actual o un valor que registró otro hook.

Este hook rechaza git push mientras la rama actual es main:

on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
  // What the permission rules and settings hooks decided: 'allow', 'ask', or 'deny'
  const decided = await next(e)
  if (!e.input.command.includes('git push')) return decided
  const branch = await $.process.run(['git', 'branch', '--show-current'])
  if (branch.stdout.trim() !== 'main') return decided
  return { decision: 'deny', reason: 'Push from a branch other than main' }
})

En main, el hook devuelve deny, incluso cuando una regla permite git push. En otra rama, y para otros comandos, la llamada obtiene la decisión que obtendría sin el mod.

El hook compara el texto del comando, así que trátalo como un recordatorio para Claude. Para bloquear los pushes a main para todos, protege la rama en tu host de Git.

Un hook puede devolver allow, ask o deny, así que también puede aprobar una llamada que bloqueó un hook PreToolUse fuera de la configuración administrada. Amplía los permisos con hooks indica qué decisiones prevalecen sobre un mod.

Reescribe un prompt o agrégale contenido

Un hook prompt.submit ve cada prompt antes de que empiece el turno, así que puede reescribir el texto o agregarle contenido. e.text es lo que se escribió.

Para hacer esto Devuelve esto
Reescribir el prompt. El mensaje en la transcripción muestra el texto nuevo. next({ ...e, text: newText })
Agregar texto que solo Claude lee, después del prompt next({ ...e, context: [...(e.context ?? []), extraText] })
Impedir que se envíe el prompt { drop: 'the reason' }

Este hook agrega el nombre de la rama actual para Claude siempre que un prompt menciona un pull request:

on('prompt.submit', async ($, e, next) => {
  // Pass on a prompt that doesn't mention a pull request as it is
  if (!/\bPR\b|pull request/i.test(e.text)) return next(e)
  const git = await $.process.run(['git', 'branch', '--show-current'])
  // Outside a git repository the command fails, so there's no branch to add
  if (git.exitCode !== 0) return next(e)
  // Keep any context an earlier hook added, and add one more line for Claude
  return next({ ...e, context: [...(e.context ?? []), 'Current branch: ' + git.stdout.trim()] })
})

Cuando envías un prompt como open a PR for this change, tu mensaje se ve igual en la transcripción, y Claude también lee una línea como Current branch: feature/auth después de él. Un prompt que no menciona un pull request pasa sin cambios, y git no se ejecuta.

Otros eventos cubren el resto de lo que Claude lee: prompt.section para cada sección del prompt del sistema, prompt.context para el contexto enviado con el primer mensaje y skill.prompt para el texto de un skill. El texto de estos hooks que cambia entre solicitudes invalida la caché de prompts.

Sigue un turno

Un turno es todo lo que Claude hace en respuesta a un prompt. Maneja turn.start, turn.step y turn.complete para seguir uno:

Evento Cuándo se dispara Qué puede hacer un hook
turn.start Empieza un turno Observar. e.turnId identifica el turno en los otros dos eventos.
turn.step Claude Code está a punto de enviar una solicitud al modelo. Un turno con llamadas a herramientas tiene varias. e.agentId se establece para la solicitud de un subagente. Leer el uso de tokens de cada solicitud, enviarla a un modelo diferente con next({ ...e, model }) o responder sin llamar al modelo
turn.complete El turno terminó, incluido un turno que el usuario interrumpió, donde e.isAborted es true. e.answer es el texto final de Claude, e.durationMs cuánto tardó y e.usage los totales de tokens del turno. El turno de un subagente lo dispara con e.agentId establecido. Observar, o devolver un objeto con un campo text, como { text: 'Done in 12 seconds' }, para mostrar una línea debajo de la respuesta

Escribe un hook turn.step como un generador asíncrono, porque el evento se transmite en streaming. yield* next(e) reenvía la respuesta a medida que se transmite y se evalúa como el resultado final. Este hook registra qué parte de cada solicitud sirvió la API de Claude desde la caché de prompts:

// function* makes the hook a generator, which can pass the response on piece by piece
on('turn.step', async function* ($, e, next) {
  // Send the request, forward each piece as it arrives, and keep the finished result
  const result = yield* next(e)
  // Skip a result that reports no token counts
  if (result.usage) {
    $.ui.log('cache read ' + result.usage.cache_read_input_tokens + ' · wrote ' + result.usage.cache_creation_input_tokens)
  }
  // Return the result unchanged, so the turn continues as usual
  return result
})

La respuesta de Claude se transmite a la pantalla como lo hace sin el mod. Después de que termina cada solicitud, una línea atenuada en la transcripción indica el número de tokens leídos de la caché y el número escritos en ella. Un turno con llamadas a herramientas tiene varias solicitudes, así que agrega varias líneas.

result.usage contiene los recuentos de tokens que la API de Claude informa para una solicitud, además del model que respondió: input_tokens, output_tokens, cache_read_input_tokens y cache_creation_input_tokens. El hook también se ejecuta para las solicitudes de los subagentes, así que comprueba e.agentId cuando quieras solo la conversación principal.

Maneja los eventos de los hooks de configuración

Los hooks de configuración son los hooks de comando, HTTP, prompt y agente que configuras en los archivos de configuración. Cada evento de hook de configuración, como Stop, SessionEnd o PostToolUse, también es un evento llamado classic. seguido del nombre del evento del hook de configuración, como classic.Stop. e es el JSON que un hook de configuración recibe en stdin, incluido transcript_path.

Este hook usa Stop, que se dispara cuando Claude termina de responder, para registrar dónde se guarda la transcripción de la sesión:

on('classic.Stop', async ($, e, next) => {
  // e has the same fields a Stop hook in a settings file reads from stdin
  $.ui.log('Transcript saved at ' + e.transcript_path)
  // Pass the event on, so Stop hooks in your settings files still run
  return next(e)
})

Cada vez que Claude termina de responder, una línea atenuada en la transcripción indica la ruta del archivo de transcripción. El hook devuelve next(e), así que observa el evento y no cambia nada de cómo termina el turno.

Ejecutar junto a otros mods

Varios mods pueden manejar el mismo evento, y cualquiera de ellos puede fallar. Si tu mod bloquea llamadas a herramientas, verifica su posición en la cadena y qué sucede cuando su hook falla.

El orden en que se ejecutan los mods

Los hooks en el mismo evento forman una cadena de middleware. Cada next de un mod llama al hook del siguiente mod, y el último next llega al comportamiento propio de Claude Code. El primer mod es el más externo: ve el evento antes que los otros y el resultado después de ellos, y decide si los otros se ejecutan en absoluto. Un mod posterior no puede detener a uno anterior de ver un evento.

Claude Code ordena la cadena por dónde viene cada mod:

  1. El guardia incorporado sec-default@builtin, un mod incorporado en Claude Code que /plugin enumera como cc-plugin-sec-default, donde se carga, mods que tu organización enumera en prependPlugins, y luego cualquier otro mod que cuente como de tu organización y no esté en appendPlugins
  2. Mods que instalas
  3. Mods que tu organización enumera en appendPlugins
  4. Otros mods incorporados en Claude Code

Entre los mods que instalas, un mod se ejecuta antes que los mods que enumera bajo dependencies en su manifiesto. Dentro de un módulo, los hooks se ejecutan en el orden en que register llamó a on.

Dónde se ejecutan los hooks de configuración en el orden

Los hooks PreToolUse configurados en archivos de configuración también se ejecutan durante una llamada de herramienta, en puntos fijos en la cadena de mods:

  • Hooks PreToolUse de configuración administrada: se ejecutan antes que el hook tool.call del primer mod, y un bloqueo de uno de ellos es final, por lo que ningún mod ve la llamada.
  • Hooks PreToolUse de cada otro archivo de configuración y de hooks/hooks.json de plugins: se ejecutan después de que el último mod llama a next, como parte del comportamiento propio de Claude Code. Un mod que responde tool.call sin llamar a next los mantiene de ejecutarse, y un mod que llama a next ve su decisión en el resultado que devuelve.

tool.check se dispara después de que esos hooks y las reglas de permisos han decidido, por lo que un hook en él puede aprobar una llamada que un hook del segundo grupo bloqueó.

Manejar un hook que falla

Un hook que falla no rompe la sesión, y puedes decidir qué sucede en su lugar. Cuando un hook sin un manejador .catch lanza una excepción, agota el tiempo o devuelve un resultado de forma incorrecta, lo que sucede después depende de si había llamado a next:

  • Falló antes de llamar a next: Claude Code lo omite, y el siguiente manejador se ejecuta en su lugar
  • Falló después de que next se resolvió: ese resultado se mantiene, y nada se ejecuta una segunda vez

Una línea nombra el mod, el evento y la razón, como my-mod: tool.call hook skipped: threw Error: boom. Dónde lo lees depende de la sesión, como Averigua por qué un mod no hace nada enumera. Un hook ui.render cuyo dibujo no valida se reporta de manera diferente, como Construir un árbol a partir de elementos describe.

Para hacer que un hook que bloquea llamadas falle cerrado, añade un manejador de error .catch que responda en su lugar. Aquí, guard es tu función de hook:

// on devuelve un registro, y .catch adjunta un manejador a ese hook
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
  // next.error.kind es 'throw' o 'timeout', que dice cómo falló guard
  return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})

Mientras guard funciona, el manejador nunca se ejecuta. Cuando guard lanza una excepción o agota el tiempo de espera en una llamada Bash, Claude Code llama al manejador con el mismo evento. El manejador devuelve { deny }, por lo que el comando no se ejecuta, y Claude lee el texto con throw o timeout al final. Sin el manejador, Claude Code omitiría guard y ejecutaría el comando. El manejador tiene su propio límite de tiempo, más corto.

Próximos pasos