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 frozen at every depth, 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 you hook by name or as 'telemetry.*'.
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
Hook 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.
To change a call, pass changed arguments to next. To retry a call, call next(e) again: a hook that sees isError on the first result can run the tool a second time and return that result. To answer a call yourself, return an object with a result field, such as { result: 'Skipped by my-mod' }, without calling next. When you do that, no permission prompt appears and the tool doesn’t run, so the result you return is all Claude learns about what happened.
Hooks in your organization’s managed settings run before any mod’s 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 10-second 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.
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.
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. Hookturn.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 four 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.
Hook 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 hook 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 is the event where Claude Code decides whether a tool call may run. It fires after those hooks and the permission rules have decided, and next(e) resolves to their decision. A hook on tool.check can return a different decision, such as { decision: 'allow' }, so it can approve a call that a hook in the second group blocked. Extend permissions with hooks lists which decisions hold over a mod.
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:
guard works, the handler never runs. When guard throws or times out on a Bash call, Claude Code calls the handler with the same event. The handler returns { deny }, so the command doesn’t run, and Claude reads the text with throw or timeout at the end. Without the handler, Claude Code would skip guard and run the command. The handler has one second to answer.
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: raise any of these events from a test
- Mods reference: every event, every mods API method, and the limits