on(eventName, handler).
Build your first mod before you start here. For every event and its exact fields, see the reference or read the types for your build.
How a hook handles an event
A hook sits between an event and what Claude Code would do about it, so it can observe the event, rewrite it, or answer it itself. It receives three arguments: the mods API as$, the event as e, and the next handler as next. The handlers for an event form a middleware chain. next(e) calls the next handler, which is another mod’s hook or, at the end of the chain, Claude Code’s own behavior, and it resolves to the result. What your hook does with next decides which of the three it does.
Observe an event
To observe an event without changing it, do your work and returnnext(e). This hook logs each tool Claude is about to use:
● my-mod: Claude is about to use Bash appears in the transcript, where my-mod is your plugin’s name. The tool runs as it would without the mod.
To act after the event, await next(e), do your work, and return the result. This hook logs each tool after it has run:
next(e) resolved to.
Rewrite an event
To change what Claude Code acts on, such as the text of a prompt, callnext with a modified copy of the event. The event itself is immutable: it’s deeply frozen, and assigning to a field throws. This hook trims each prompt before it’s sent:
await next(e), then return a copy of the result with a field replaced.
Answer an event
To handle an event yourself, return a result without callingnext. That short-circuits the chain, so later mods and Claude Code’s own behavior don’t run. This hook refuses every Bash command:
deny text as the tool’s result. Each event has its own result shape, which the events reference lists.
Filter which events a hook handles
To run a hook for some events only, pass a filter as the second argument toon. Claude Code calls the filter a matcher. It’s an object whose fields are compared with the event’s, and the hook runs only when every field matches. A field can be a value, an array of allowed values, or a regular expression.
Each line in this example registers the same function, hook, for a narrower set of tool calls:
hook runs once for a Bash, Edit, or Write call, and once for a call to a tool whose name starts with mcp__github__. A call to any other tool, such as Read, matches none of the three, so hook doesn’t run for it.
The event name can be a wildcard. 'classic.*' matches every settings hook event. '*' matches every event except the telemetry events, which take their own name and a { to: 'collector' } filter.
Register each event once per matcher. If you call on twice for session.start with no matcher, the module fails to load with on("session.start") is registered twice without a matcher. Put everything your mod does at session start in one hook.
Hook what Claude is doing
Handle these events to see or change a tool call, a prompt, or a turn as it happens. For every event and what a hook can return, see the events reference.Guard or change a tool call
Atool.call hook sees each tool Claude is about to use, so it can refuse the call, change its arguments, or let it through. tool.call fires when Claude Code is about to run a tool, including calls a subagent makes and calls to MCP tools. e.tool is the tool’s name and the tool’s arguments are fields of e, such as e.command for Bash. When you call next(e), Claude Code runs the permission check and then the tool.
This hook refuses a Bash command that force-pushes, and tells Claude why:
git push --force, the command doesn’t run and no permission prompt appears, because the hook never calls next. Claude reads the deny text as the tool’s result, so write it as an instruction Claude can act on. Every other Bash command runs as it would without the mod.
To act after a tool has run, await next(e), do your work, and return what next gave you. This hook logs each .mdx file Claude changes, with $.ui.log, which adds a dim line to the transcript that Claude doesn’t read:
.mdx file, a dim line in the transcript names the file. Nothing is logged for another kind of file, or for a call that was refused or failed. Claude’s view of the call doesn’t change, because the hook returns the result it received.
Your hook can also change a call, retry it, answer it itself, or withhold its result:
- Change the call: pass changed arguments to
next. - Retry the call: call
next(e)again. A hook that seesisErroron the first result can run the tool a second time and return that result. - Answer the call yourself: return an object with a
resultfield, without callingnext, and for a built-in tool, giveresultthe shape that tool’s own result has in the types for your build. No permission prompt appears and the tool doesn’t run, so the result you return is all Claude learns about what happened. - Withhold the result from Claude: return
{ deny: reason }afterawait next(e). Claude reads your reason in place of whatnextreturned. When the tool ran, the deny keeps its result from Claude and undoes nothing the tool did. When the tool ran and succeeded, the reason follows a note such asBash ran, and a plugin withheld its result:.
tool.call hook, and a block from one of them is final.
Hold a tool call until the user decides
A hook can pause a tool call and ask the user what to do before it goes ahead. Atool.call hook can await before it calls next or returns, and the tool call stays pending until then. To put the question to the user, call $.ui.ask. It shows your question above a numbered list of your options, in the dialog Claude uses to ask you something, and resolves to the label the user picks. After your options, the dialog adds a row for typing a different answer and a Chat about this row.
The RISKY pattern in this example matches rm -r, rm -rf, git reset --hard, and git push with --force, and it misses other spellings such as git push -f. This module asks before it runs a Bash command that matches the pattern:
rm -rf build, the question appears with the command in it, and the command waits for the answer:
- The user picks Run it: the hook calls
next(e), and the usual permission check still runs after it - The user picks Refuse: the command doesn’t run, and Claude reads the
denytext - The user types an answer:
$.ui.askresolves to the typed text. The hook compares it withRun it, so any other text refuses the command. - Nobody answers:
$.ui.askrejects when the user dismisses the question or picks Chat about this, and in aclaude -prun, so thecatchblock leaves the answer atRefuse
$.ui.ask, because that time doesn’t count against the hook’s time limit. Time spent awaiting a promise of your own does count. Claude Code skips a hook that times out, so the held command would run.
Approve or refuse a tool call before the user is asked
To decide whether a tool call may run, handletool.check, the event where Claude Code makes that decision. It fires after the permission rules and the settings hooks have decided, and next(e) resolves to their decision: allow, ask, or deny. Your hook returns that decision or a different one. e.input holds the tool’s arguments, such as command for Bash.
For a fixed command or path, use a permission rule such as Bash(npm test), which takes no code. Handle tool.check when the decision depends on what’s true at that moment, such as the current Git branch or a value another hook recorded.
This hook refuses git push while the current branch is main:
main, the hook returns deny, even when a rule allows git push. On another branch, and for other commands, the call gets the decision it would get without the mod.
The hook matches the text of the command, so treat it as a reminder for Claude. To block pushes to main for everyone, protect the branch on your Git host.
A hook can return allow, ask, or deny, so it can also approve a call that a PreToolUse hook outside managed settings blocked. Extend permissions with hooks lists which decisions hold over a mod.
Rewrite or add to a prompt
Aprompt.submit hook sees each prompt before the turn starts, so it can rewrite the text or add to it. e.text is what was typed.
This hook adds the current branch name for Claude whenever a prompt mentions a pull request:
open a PR for this change, your message looks the same in the transcript, and Claude also reads a line such as Current branch: feature/auth after it. A prompt that doesn’t mention a pull request goes through unchanged, and git doesn’t run.
To stop a prompt, return { drop: 'the reason' } without calling next. The text goes back into the user’s prompt input, and they see Prompt dropped by a hook: followed by your reason, so address the reason to them. If your hook returns a drop after its next(e) call let the prompt through, the turn still runs, and the hook fails with a message that includes a drop after its next() was answered.
Other events cover the rest of what Claude reads: prompt.section for each section of the system prompt, prompt.context for the context sent with the first message, and skill.prompt for a skill’s text. Text from these hooks that changes between requests invalidates the prompt cache.
Follow a turn
A turn is everything Claude does in answer to one prompt. Handleturn.start, turn.step, and turn.complete to follow one:
Write a
turn.step hook as an async generator, because the event streams. yield* next(e) forwards the response as it streams and evaluates to the finished result. This hook logs how much of each request the Claude API served from the prompt cache:
result.usage holds the token counts the Claude API reports for a request, plus the model that answered: input_tokens, output_tokens, cache_read_input_tokens, and cache_creation_input_tokens. The hook runs for subagents’ requests too, so check e.agentId when you want only the main conversation.
To see the tool calls that the API ran itself during the request, such as calls to the advisor tool, read result.serverToolUses. Claude Code doesn’t run these calls, so no tool.call or tool.check hook fires for them. The field is absent when the response has no such calls, and it requires Claude Code v2.1.290 or later.
Handle the settings hook events
Settings hooks are the command, HTTP, prompt, and agent hooks you configure in settings files. Each settings hook event, such asStop, SessionEnd, or PostToolUse, is also an event named classic. followed by the settings hook event’s name, such as classic.Stop. e is the JSON a settings hook receives on stdin, including transcript_path.
This hook uses Stop, which fires when Claude finishes responding, to log where the session’s transcript is saved:
next(e), so it observes the event and changes nothing about how the turn ends.
Run alongside other mods
Several mods can handle the same event, and any one of them can fail. If your mod blocks tool calls, check its position in the chain and what happens when its hook fails.The order mods run in
Hooks on the same event form one middleware chain. Each mod’snext calls the following mod’s hook, and the last next reaches Claude Code’s own behavior. The first mod is outermost: it sees the event before the others and the result after them, and it decides whether the others run at all. A later mod can’t stop an earlier one from seeing an event.
Claude Code orders the chain by where each mod comes from:
- The built-in guard
sec-default@builtin, a mod built into Claude Code that/pluginlists ascc-plugin-sec-default, where it loads, mods your organization lists inprependPlugins, and then any other mod that counts as your organization’s and isn’t inappendPlugins - Mods you install
- Mods your organization lists in
appendPlugins - Other mods built into Claude Code
dependencies in its manifest. Within one module, hooks run in the order register called on.
Where settings hooks run in the order
ThePreToolUse hooks configured in settings files also run during a tool call, at fixed points in the chain of mods:
PreToolUsehooks from managed settings: run before the first mod’stool.callhook, and a block from one of them is final, so no mod sees the call.PreToolUsehooks from every other settings file and from plugins’hooks/hooks.json: run after the last mod callsnext, as part of Claude Code’s own behavior. A mod that answerstool.callwithout callingnextkeeps them from running, and a mod that callsnextsees their decision in the result it returns.
tool.check fires after those hooks and the permission rules have decided, so a hook on it can approve a call that a hook in the second group blocked.
Handle a hook that fails
A hook that fails doesn’t break the session, and you can decide what happens instead. When a hook with no.catch handler throws, times out, or returns a result of the wrong shape, what happens next depends on whether it had called next:
- It failed before calling
next: Claude Code skips it, and the next handler runs in its place - It failed after
nextresolved: that result stands, and nothing runs a second time
my-mod: tool.call hook skipped: threw Error: boom. Where you read it depends on the session, as Find out why a mod does nothing lists. A ui.render hook whose drawing doesn’t validate is reported differently, as Build a tree from elements describes.
To make a hook that blocks calls fail closed, add a .catch error handler that answers in its place. Here, guard is your hook function, and the handler tests next.called to tell whether guard had already called next when it failed:
guard throws or times out on a Bash call, Claude Code calls the handler with the same event:
guardfailed before it callednext: the command doesn’t run, and Claude reads thedenytext with the kind at the endguardfailed after it callednext: the handler’snext(e)resolves to the result thatguard’s call produced without running the command again, and Claude reads that result
guard hadn’t called next, the command then goes on as it would without the mod.
The same handler shape fits a guard on prompt.submit or config.set. When next.called is false, return the refusal that the events reference lists for that event: { drop: 'the reason' } for prompt.submit, { deny: 'the reason' } for config.set.
At tool.check and plugin.register, a refusal returned after next resolved still holds, so return it without testing next.called:
tool.check: return{ decision: 'deny', reason: 'the reason' }plugin.register: return{ refuse: 'the reason' }, as Refuse mods when your check fails shows
tool.call, a deny returned after next resolved withholds the result from Claude.
Next steps
- Use the mods API: add commands and tools, call a model, and run work on a timer
- Draw in the interface: show what your hooks collect in a pane or above the prompt
- Test a mod: fire any of these events from a test
- Mods reference: every event, every mods API method, and the limits