Skip to content

React to events

Handle Claude Code events in a mod - observe, rewrite or answer tool calls, prompts and turns, filter with matchers, and behave well alongside other mods.

Claude Code fires an event at each point where it is about to act: running a tool, submitting a prompt, sending a request to the model, starting or ending a session. A mod hook registered with on(eventName, handler) runs before the action, so it can watch, change, or replace what happens.

Build your first mod before reading this. Exact fields for every event are in /docs/plugins/mods/reference and, more reliably, in your generated types.

The middleware model

A hook gets three arguments: the mods API $, the event e, and next. All hooks for one event form a chain, like web middleware. next(e) calls the next mod's hook, or at the end of the chain Claude Code's own behaviour, and resolves to the result. What you do with next decides the outcome.

Observe

Do your work and pass the event on. Before the action:

on('tool.call', async ($, e, next) => {
  $.ui.log('about to run ' + e.tool)
  return next(e)
})

A dim line such as ● my-mod: about to run Grep appears in the transcript before each tool. Claude does not read these lines.

After the action, await first:

on('tool.call', async ($, e, next) => {
  const started = await $.clock.now()
  const result = await next(e)
  $.ui.log(e.tool + ' took ' + ((await $.clock.now()) - started) + ' ms')
  return result
})

Returning the unchanged result means Claude sees exactly what it would have seen without your mod.

Rewrite

e is deeply frozen; assigning to it throws. Pass a modified copy instead:

on('prompt.submit', async ($, e, next) => {
  // Normalise British "colour" spelling requests to the codebase's US spelling
  return next({ ...e, text: e.text.replace(/\bcolour\b/g, 'color') })
})

Later hooks and Claude Code only ever see the rewritten version. You can also rewrite results: await next(e) and return a modified copy.

Answer

Return a result without calling next. The chain stops; later mods and Claude Code's behaviour never run:

on('tool.call', { tool: 'WebFetch' }, async () => {
  return { deny: 'Web access is disabled for this client project. Work from the files in docs/vendor/ instead.' }
})

Claude reads the deny text as the tool result, so phrase it as something Claude can act on. Each event's result shape is in the reference.

Filtering with a matcher

Pass an object as the second argument and the hook only runs when every field matches. A field can be a value, an array of allowed values, or a regular expression:

on('tool.call', { tool: 'Bash' }, hook)                    // exactly Bash
on('tool.call', { tool: ['Edit', 'Write'] }, hook)         // either
on('tool.call', { tool: /^mcp__linear__/ }, hook)          // every tool on one MCP server

Event names can be wildcards: 'classic.*' matches every settings hook event; '*' matches everything except telemetry events (which need their own name plus { to: 'collector' }).

Register each event once per matcher. Two matcher-less on('session.start', ...) calls fail to load with on("session.start") is registered twice without a matcher. Put all start-up work in one hook.

Tool calls

Guard or change a call

tool.call fires for every tool Claude is about to run, including subagent calls and MCP tools. e.tool is the name and the arguments are fields on e (e.command for Bash, e.file_path for Edit). Calling next(e) runs the permission check and then the tool.

This hook stops Claude dropping database tables through a CLI, and tells it what to do instead:

on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
  if (/\b(psql|mysql)\b.*\bdrop\s+table\b/i.test(e.command)) {
    return { deny: 'Dropping tables is not allowed from Claude Code. Write a migration in db/migrations/ instead.' }
  }
  return next(e)
})

No permission prompt appears for the blocked command, because next was never called.

Other options on the same event:

  • Change arguments: return next({ ...e, command: e.command + ' --dry-run' }).
  • Retry: if the first await next(e) has isError, call next(e) again and return the second result.
  • Fake it: return { result: 'Skipped: CI is read-only' }. The tool does not run and no prompt appears; your text is all Claude learns.

After a call, results come back as { deny } when refused and with isError set when failed. This logs every Markdown file changed in docs/:

on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
  const result = await next(e)
  if (!result.deny && !result.isError && /^docs\/.*\.md$/.test(e.file_path)) {
    $.ui.log('docs changed: ' + e.file_path)
  }
  return result
})

PreToolUse hooks from your organisation's managed settings run before any mod's tool.call hook, and a block from them is final.

Hold a call and ask the user

A tool.call hook can await before deciding, and the call stays pending. $.ui.ask(question, options) shows your question in the dialog Claude normally uses for questions, and resolves to the chosen label. The dialog also adds a free-text row and a Chat about this row.

const DEPLOY = /\b(kubectl\s+apply|terraform\s+apply|vercel\s+--prod)\b/

export function register(on) {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    if (!DEPLOY.test(e.command)) return next(e)
    let choice = 'Cancel'                    // safe default if nobody answers
    try {
      choice = await $.ui.ask('This looks like a deploy: ' + e.command, ['Deploy', 'Cancel'])
    } catch {
      // dismissed, "Chat about this", or claude -p with no one to ask
    }
    if (choice !== 'Deploy') {
      return { deny: 'The user did not approve this deploy. Ask them before trying again.' }
    }
    return next(e)                           // normal permission check still follows
  })
}

Outcomes: Deploy continues to the usual permission check; Cancel or any typed text refuses; dismissing, choosing Chat about this, or running under claude -p makes $.ui.ask reject, so the default stands.

Warning: Do the waiting inside a mods API call like $.ui.ask. Time spent in API calls does not count against the hook's 10-second limit, but awaiting your own promise does. A hook that times out is skipped, which here would let the deploy run.

Decide permission yourself: tool.check

tool.check fires after permission rules and settings hooks have reached a decision. next(e) resolves to that decision (allow, ask or deny), and you may return it or another. Arguments are on e.input here.

For static rules, use a permission rule like Bash(npm test); no code needed. Reach for tool.check when the answer depends on live state. This one forces a prompt for npm publish unless the working tree is clean:

on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
  const decided = await next(e)
  if (!/\bnpm\s+publish\b/.test(e.input.command)) return decided
  const status = await $.process.run(['git', 'status', '--porcelain'])
  if (status.stdout.trim() === '') return decided
  return { decision: 'ask', reason: 'Working tree has uncommitted changes' }
})

Text matching on commands is a nudge, not a security boundary. For hard guarantees, enforce at the source (branch protection, registry permissions). Because a tool.check hook can return allow, it can override a PreToolUse hook outside managed settings; /docs/permissions lists which decisions hold.

Prompts

prompt.submit sees each prompt before the turn. e.text is what was typed.

GoalReturn
Replace the prompt (transcript shows new text)next({ ...e, text: newText })
Add text only Claude sees, after the promptnext({ ...e, context: [...(e.context ?? []), extra] })
Stop it being sent{ drop: 'reason' }

Adding hidden context when a prompt mentions tickets:

on('prompt.submit', async ($, e, next) => {
  const ids = e.text.match(/\bENG-\d+\b/g)
  if (!ids) return next(e)
  const notes = ids.map((id) => 'Ticket ' + id + ' lives in Linear; use the linear MCP tools to read it.')
  return next({ ...e, context: [...(e.context ?? []), ...notes] })
})

Your message looks unchanged; Claude also reads the extra lines. Other events cover the rest of what Claude reads: prompt.section (system prompt sections), prompt.context (first-message context) and skill.prompt (skill text). Text from these that changes between requests invalidates the prompt cache, so keep it stable.

Turns

EventWhenUseful for
turn.startA turn begins; e.turnId links the three eventsObserving
turn.stepOne model request is about to go; a turn with tools has several. e.agentId is set for subagentsReading token usage, switching model or effort, or answering without the model
turn.completeThe turn ended (e.isAborted if interrupted). Has e.answer, e.durationMs, e.usage; e.agentId for subagentsObserving, or { text } to print a line under the answer

turn.step streams, so its hook is an async generator. yield* next(e) forwards each streamed piece and evaluates to the finished result:

let spent = { input: 0, output: 0 }

on('turn.step', async function* ($, e, next) {
  const result = yield* next(e)
  if (!e.agentId && result.usage) {
    spent.input += result.usage.input_tokens
    spent.output += result.usage.output_tokens
  }
  return result
})

on('turn.complete', async ($, e, next) => {
  if (e.agentId) return next(e)
  // Answering with { text } prints a line under Claude's answer
  return { text: 'Session so far: ' + spent.input + ' in / ' + spent.output + ' out tokens' }
})

result.usage carries input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens and the answering model. Remember the hook also runs for subagents' requests; filter on e.agentId as above when you only want the main thread.

Settings hook events

Every settings hook event also exists as classic.<Event>, with e set to the same JSON a settings hook gets on stdin (including transcript_path):

on('classic.PostToolUseFailure', async ($, e, next) => {
  $.ui.toast(e.tool_name + ' failed')
  return next(e)   // keep your settings hooks for this event running
})

Living alongside other mods

Run order

Hooks for one event form one chain. The first mod is outermost: it sees the event first and the result last, and decides whether the rest run. A later mod cannot hide an event from an earlier one.

  1. The built-in guard sec-default@builtin (where it loads), then mods in your organisation's prependPlugins, then any other organisation mods not in appendPlugins.
  2. Mods you install. Among these, a mod runs before the mods it lists as dependencies.
  3. Mods in appendPlugins.
  4. Other built-in mods.

Within one module, hooks run in the order register called on. Admin details are in /docs/plugins/mods/admin.

Where settings hooks sit

  • Managed PreToolUse hooks run before the first mod's tool.call. A block is final; no mod sees the call.
  • All other PreToolUse hooks (other settings files, plugins' hooks/hooks.json) run after the last mod calls next, as part of Claude Code's behaviour. A mod that answers without next prevents them running; one that calls next sees their decision in the result.

tool.check fires after those hooks and the permission rules, so it can approve a call the second group blocked.

When a hook fails

If a hook without .catch throws, times out or returns the wrong shape:

  • before calling next: it is skipped and the next handler runs in its place;
  • after next resolved: that result stands and nothing runs twice.

A single line names the mod, event and reason, such as my-mod: tool.call hook skipped: threw Error: boom. Where it appears depends on the session; see /docs/plugins/mods/troubleshoot.

For a guard, failing open is dangerous. Attach a .catch so failure denies instead:

on('tool.call', { tool: 'Bash' }, deployGuard).catch(async ($, e, next) => {
  return { deny: 'Deploy guard failed (' + next.error.kind + '), so the command was blocked.' }
})

next.error.kind is throw or timeout. The .catch handler has its own 1-second limit.