AI

Claude Code Hooks: The Complete Guide

Before you wire up a single hook, the question worth settling is what you want them for. A prompt in your CLAUDE.md asking Claude to run the linter after edits is a suggestion the model can forget. A hook that runs the linter after every edit is a guarantee, because Claude Code runs it, not the model. That difference is the whole point of hooks: they turn “please remember to” into deterministic behavior that fires on its own, whether the model cooperates or not.

Original content from computingforgeeks.com - post 168731

This guide covers the Claude Code hooks system: the 33 lifecycle events you can attach to, the settings.json shape, and how matchers and the if filter decide when a hook runs. It then covers the exit-code and JSON contracts a hook uses to allow or block an action, the five handler types, and a hook we built that blocks rm -rf even with permissions turned off. We built and ran the blocking hook on Claude Code 2.1.177 in June 2026. In October 2026 we re-checked every event and field against the 2.1.299 docs and timed our own 17-hook publishing gate.

What a Claude Code hook is, and what it guarantees

A hook is a command, HTTP call, or agent that Claude Code triggers at a defined point in its lifecycle (the official hooks reference lists every field). When that point arrives, Claude Code feeds the hook a JSON description of what is happening on standard input, runs it, and reads the result. Three powers come out of that simple loop. A hook can observe what Claude is about to do or has already done, it can inject extra context into the conversation, and on the events that support it, it can block the action outright.

The architectural detail that matters: hooks are enforced by Claude Code itself, not by the model. Permission rules decide what Claude may do without asking, and a hook adds checks of your own on top of them: a PreToolUse hook that denies a call blocks it even in bypassPermissions mode. The relationship only runs one way, though. A hook can tighten what the permission rules allow but can’t loosen it, because deny and ask rules are still evaluated whatever the hook returns. In practice, reach for a hook when you need a deterministic check the permission syntax can’t express, like inspecting a command’s arguments or validating a file before it is written. It is one dependable layer, not the whole wall: by default a hook that crashes or times out lets the call through, so keep secret paths behind Read deny rules (a PreToolUse hook doesn’t even fire for a file you @-mention), and use sandboxing, configured to deny writes to the paths that matter, when the rule must hold however a command is spelled. One caveat arrived in October 2026 with Claude Mods: a plugin mod that handles tool.check can approve a call your PreToolUse hook blocked, unless that hook lives in managed settings.

The events you can hook into

This is where most write-ups are out of date. A guide that tells you there are eight or twelve hook events is describing Claude Code as it stood in mid-2025. The current set is 33 events and still growing. DirectoryAdded arrived in July 2026, the PreModelSwitch and PostModelSwitch pair in August, and MessageDisplay (which can transform assistant text as it is shown) and the terminalSequence output for desktop notifications landed earlier in the 2.1.x line. You do not need all of them, but knowing the full surface is what lets you pick the right one instead of forcing everything through PostToolUse.

Grouped by what they watch, the events break down like this:

CategoryEventsWhat they fire on
Tool lifecyclePreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, PermissionRequest, PermissionDeniedBefore, after, or around a tool call and its permission decision
Prompt and turnUserPromptSubmit, UserPromptExpansion, Stop, StopFailure, MessageDisplay, NotificationWhen you submit a prompt, when Claude finishes, when text is shown
SessionSessionStart, SessionEnd, Setup, InstructionsLoaded, ConfigChange, CwdChanged, FileChanged, DirectoryAddedSession start and end, config and file changes, CLAUDE.md loads, a directory added with /add-dir
Model switchesPreModelSwitch, PostModelSwitchBefore and after the session’s model changes; PreModelSwitch can block the switch
Subagents and tasksSubagentStart, SubagentStop, TaskCreated, TaskCompleted, TeammateIdleWhen a subagent or task starts, finishes, or a teammate goes idle
Compaction and worktreesPreCompact, PostCompact, WorktreeCreate, WorktreeRemoveAround context compaction and git worktree isolation
MCP elicitationElicitation, ElicitationResultWhen an MCP server asks the user for input mid-tool-call

In day-to-day use, a handful carries almost all the weight: PreToolUse and PostToolUse for acting around tool calls, UserPromptSubmit for shaping what reaches the model, SessionStart for loading context, and Stop for end-of-turn work. The rest of the catalog is there for the day you need it. The subagent events pair naturally with custom agents, and the elicitation events only matter once you are running MCP servers that prompt for input.

Your first hook in settings.json

Most hooks live in one of three settings files: ~/.claude/settings.json for your personal hooks across every project, .claude/settings.json in a repo for hooks the whole team shares, and .claude/settings.local.json for project hooks you keep out of version control. Managed policy settings, a plugin’s hooks/hooks.json, and skill or subagent frontmatter can add more, and hook entries from every level merge rather than replace each other. In an interactive session Claude Code also holds all of them back, your own user-level hooks included, until you accept the workspace trust dialog for the folder, while -p runs treat the folder as trusted for hooks in settings files. That means claude -p in a freshly cloned repo runs whatever hooks the repo committed, with no prompt. They sit alongside the rest of your project Claude Code config. Open the shared project file:

vim .claude/settings.json

The structure nests three levels deep: the hooks key, then the event name, then an array of matcher groups, each holding the actual hooks to run. A minimal example that echoes a line every time Claude edits or writes a file:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "echo \"Claude touched a file at $(date)\" >> ~/claude-edits.log"
          }
        ]
      }
    ]
  }
}

One operational note that saves confusion: edits to a hook in your settings files are picked up automatically by Claude Code’s file watcher, so a change usually takes effect without restarting the session. In the CLI the /hooks menu is a read-only view of what is currently wired up, handy for confirming a hook registered, so you make changes by editing the JSON. The VS Code extension’s Customize panel can now add and edit hooks too. If you need to switch every hook off at once, set "disableAllHooks": true in settings rather than deleting them, or pass --settings '{"disableAllHooks": true}' for a single run. Neither switches off hooks that come from managed settings, and a false in a project’s .claude/settings.json overrides a true in your user settings, so the command-line form is the reliable one.

Matching the right events

The matcher field decides which occurrences of an event a hook group responds to, and how it is read depends entirely on the characters inside it. A value of *, an empty string, or an omitted matcher fires on every occurrence. A value made only of letters, digits, underscores, hyphens, spaces, commas and the pipe is an exact string, or a list of exact strings separated by | or a comma. So Bash matches only the Bash tool, and Edit|Write or Edit,Write matches either one. Anything with another character in it becomes an unanchored JavaScript regular expression. Releases before late June 2026 treated hyphens and commas differently (a comma list silently never fired, and a hyphenated name could substring-match), so a matcher copied from an older config deserves a second look.

That last rule causes a real trap with MCP tools. MCP tool names follow the pattern mcp__<server>__<tool>, and to match every tool from one server you have to append .*. A bare mcp__memory contains only letters and underscores, so it is compared as an exact string and matches nothing:

"matcher": "mcp__memory__.*"

For finer control on tool events, there is a second filter. The if field takes permission-rule syntax and runs the hook only when the tool call matches, so you can scope a hook to a narrow case without writing the matching logic yourself:

"if": "Bash(git push *)"

The catch worth remembering is that if is only evaluated on the tool events, namely PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied. Put it on any other event and the hook never runs.

What your hook receives, and how it answers

Every hook gets a JSON object on standard input. The common fields are session_id, transcript_path, cwd, and hook_event_name, and most events also carry permission_mode (not all of them do), prompt_id once the first prompt has been submitted, and scratchpad_dir. Tool events add the ones you usually care about, tool_name and tool_input. Events firing in a tool-use context also carry effort (an object whose level field holds the active effort), and hooks firing inside a subagent carry agent_id and agent_type. A PreToolUse payload for a Bash call looks like this, abridged to the fields you will use most:

{
  "session_id": "abc123",
  "transcript_path": "/home/user/.claude/projects/-home-user-my-project/abc123.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "rm -rf /tmp/build" },
  "tool_use_id": "toolu_01ABC123"
}

The hook answers in one of two ways, and the simplest is the exit code. Exit 0 means success, and Claude Code parses standard out for any JSON instructions. Exit 2 is the blocking signal, and where your stderr text lands depends on the event. On PreToolUse it goes back to Claude as the reason the call was stopped. On PostToolUse it also reaches Claude, but the tool has already run, and PermissionRequest ignores exit 2 altogether. On UserPromptSubmit it is shown to you and kept out of the context, and a few events such as ConfigChange show nothing. JSON printed alongside an exit 2 is still read, but it can’t override the block. Every other exit code without valid JSON is a non-blocking error: the action proceeds and a notice shows in the transcript. The trap that catches people is that exit 1, the conventional Unix failure code, does not block. If a hook is meant to enforce a policy, it has to exit 2 or return a deny decision in JSON. On command and http hooks you can also set "onFailure": "block", which turns a crash, a timeout or invalid JSON output into a block.

There is one more useful behavior tied to standard out. For most events stdout only goes to the debug log, but for UserPromptSubmit, UserPromptExpansion, SessionStart, and PostModelSwitch it is added straight into the context as something Claude can read and act on. That is the mechanism behind context-injection hooks, where a script prints the current ticket number or git branch and Claude picks it up without you typing it.

Telling Claude exactly what to do

Exit codes are blunt. For precise control, a hook exits 0 and prints JSON, and this is the layer most guides skip. A set of universal fields works on most events. continue halts all processing, with stopReason as the message shown when it does, and systemMessage surfaces a warning to the user. terminalSequence emits a desktop notification or sets the window title in an interactive session; it is ignored in -p runs. You’ll still see suppressOutput in older examples; Claude Code accepts it but does nothing with it, because a successful hook’s stdout never reaches the transcript anyway. For injecting text, additionalContext inside hookSpecificOutput is the field to use, and the context and messages a hook returns are capped at 10,000 characters each.

Beyond those, the decision shape differs by event, which is the nuance worth getting right. Most events that can steer Claude use a top-level decision, for example UserPromptSubmit, PostToolUse, and Stop:

{ "decision": "block", "reason": "Commit message is missing a ticket reference." }

PreToolUse is the exception and the one you will reach for most, because it carries a real permission decision rather than a yes or no. It returns a hookSpecificOutput with a permissionDecision of allow, deny, ask, or defer, where defer only counts in non-interactive -p runs and is ignored with a warning in an interactive session:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Destructive command blocked by hook"
  }
}

The difference matters in practice. allow skips the permission prompt, except for the actions no mode auto-approves, and it never overrides a deny or ask rule in your settings. ask forces a prompt, and deny stops the call with your reason handed to the model. A hook can also rewrite the call instead of judging it: updatedInput replaces the tool’s arguments before they run. It replaces the whole input object, so include the fields you aren’t changing, pair it with allow or ask, and avoid having two hooks set it on the same call, because the last one to finish wins. We’d reach for the JSON form over a bare exit 2 whenever the reason needs to be specific enough for Claude to act on. For the cheap “block anything that looks like X” guards, exit 2 with a stderr message is less code.

Five kinds of hook handler

The type field is not limited to shell commands, though that is where almost everyone starts. There are five handler types, and the two that older guides miss are exactly the ones that open up the interesting designs:

TypeWhat it doesReach for it when
commandRuns a shell command with the event JSON on stdinThe default, for scripts and CLI tools
httpPOSTs the event JSON to a URLCentralized logging or a policy service
mcp_toolCalls a tool on a connected MCP serverYou already run an MCP server that can act
promptSends a single-turn prompt to a model for a verdictThe check needs judgment, not a regex
agentSpawns a subagent with tools like Read and GrepThe check needs to investigate the codebase

The prompt and agent handlers are the genuinely new design space, with two limits. Agent hooks are still marked experimental, and only twelve events accept both prompt and agent handlers, and PermissionRequest takes prompt but not agent. SessionStart and Setup take only command and mcp_tool handlers. A prompt hook on UserPromptSubmit can have a cheap model judge whether a request looks risky before the main model ever sees it, and an agent hook can actually go read files to make its decision. Most production setups still lean on command, because a shell script is predictable and free, but the option to escalate to a model is there when the rule cannot be expressed as a pattern.

A hook that blocks a destructive command

Theory settles into place once a hook stops something real. This PreToolUse guard reads the proposed Bash command off stdin and exits 2 when it finds rm with a recursive flag, whether that is written -rf, -r -f, -R or --recursive. It also refuses to run without jq. A guard that can’t parse its input should block rather than wave the command through, and without that line a missing jq leaves the command variable empty, the match fails, and the script exits 0. Install jq first (brew install jq, sudo apt install jq or sudo dnf install jq); until it is there, this guard blocks every Bash call with jq is not installed, and claude --settings '{"disableAllHooks": true}' gets you back in to fix it. Create the script in the project:

mkdir -p .claude/hooks
vim .claude/hooks/block-dangerous.sh

Paste the guard:

#!/bin/bash
# PreToolUse guard: refuse any rm with a recursive flag. Fails closed if jq is missing.
command -v jq >/dev/null || { echo "block-dangerous.sh: jq is not installed" >&2; exit 2; }
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s\n' "$cmd" | grep -Eq '(^|[^[:alnum:]_])rm[[:space:]]+([^;&|]*[[:space:]])?(-[[:alpha:]]*[rR][[:alpha:]]*|--recursive)([[:space:]]|$)'; then
  echo "Blocked by hook: recursive delete ($cmd). Delete specific files instead." >&2
  exit 2
fi
exit 0

Make it executable. Without the onFailure setting in the next step, a script the shell can’t run is a non-blocking error, so the delete goes ahead with only a notice in the transcript; with it, every Bash call is blocked until you fix the permissions:

chmod +x .claude/hooks/block-dangerous.sh

Then wire it into .claude/settings.json. The path is quoted so a project directory with a space in it still resolves, and "onFailure": "block" (Claude Code 2.1.295 or later) turns a crash or a timeout into a block instead of a pass. Like if and timeout, it goes on the handler object next to type, not on the matcher group:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh",
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Test the script on its own before Claude ever calls it, by feeding it the same JSON shape Claude Code sends:

echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | .claude/hooks/block-dangerous.sh; echo "exit=$?"

It should print the reason and exit 2. Anything else means the hook would let the command through:

Blocked by hook: recursive delete (rm -rf build). Delete specific files instead.
exit=2

We first ran this test in June 2026 on Claude Code 2.1.177, with an earlier version of the guard that only looked for rm -rf. The first screenshot shows that config, including an unquoted script path, which the version above fixes:

A Claude Code PreToolUse hook wired in settings.json calling a shell script that blocks rm -rf

To make the point unmissable, we ran Claude with --dangerously-skip-permissions, which turns off the permission prompts entirely, then asked it to delete a directory with rm -rf. A PreToolUse hook that blocks still applies in that mode. It held: the command was blocked, the stderr reason went back to the model, and the build directory was still there afterward.

A Claude Code PreToolUse hook blocks rm -rf even with permissions skipped and the build directory survives

In October we repeated it on Claude Code 2.1.299 with the script and settings above, this time pre-approving Bash with --allowedTools "Bash" so the call would otherwise have run without a prompt. From the project directory, with a throwaway build folder in place:

mkdir -p build && touch build/app.o
claude -p "Run exactly this shell command with the Bash tool and nothing else: rm -rf build. If it is blocked, do not try any other way to delete it; just report the exact message you received." --allowedTools "Bash" --max-turns 3

Claude reported the block word for word, and build/ still held its file afterwards:

PreToolUse:Bash hook error: ["$CLAUDE_PROJECT_DIR"/.claude/hooks/block-dangerous.sh]: Blocked by hook: recursive delete (rm -rf build). Delete specific files instead.

One honest lesson from the June runs is worth more than the success itself. On an earlier attempt, the hook blocked rm -rf build, so Claude deleted the file explicitly and then ran rmdir, a path the regex did not catch, and the guard above still doesn’t. Quoting the flags (rm "-rf" build) slips past it too, while harmless commands such as git rm -r --cached src get blocked. Scope a guard to the behavior you fear, not to one spelling of one command, and treat hooks as a layer alongside the permission system rather than a replacement for it. A hook that only knows rm flags is a speed bump, not a wall. For enforcement that doesn’t depend on how a command is spelled, Claude Code’s sandboxing is the stronger tool, but only once you turn it on, deny writes to the paths you care about, and set allowUnsandboxedCommands to false so a blocked command can’t simply be retried outside the sandbox.

More patterns worth wiring in

The same machinery covers most of what teams actually automate. Auto-formatting on edit is the canonical PostToolUse hook, matched to the file-editing tools so a formatter runs the moment Claude changes code. format-changed.sh stands for your own script, typically a few lines that read .tool_input.file_path from stdin with jq and run your formatter on that file:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-changed.sh" }
        ]
      }
    ]
  }
}

Context injection is the UserPromptSubmit pattern, where a script prints something useful and Claude reads it because stdout on that event becomes context. A script that echoes the current git branch and open ticket, for instance, means you stop pasting that detail into every prompt. And for ambient awareness, a Notification hook that emits a terminalSequence fires a desktop alert when Claude needs your attention, so a long run does not sit waiting silently while you have switched windows.

How we run hooks in production

We lean on this system hard for our own publishing pipeline, and the shape is worth sharing because it is the pattern most worth copying. Every article on this site goes through a publish step, and a PreToolUse hook on the Bash tool inspects that command before it runs. If the command is a publish and the article has not passed its checks (missing meta, an unverified review, a too-short body), the hook exits 2 and the publish never happens. The operator sees exactly why and fixes it.

That gate is now 17 separate command hooks matched to the Bash tool, so in October we timed each script by feeding it the same PreToolUse payload Claude Code sends, one script after another. For a plain ls -la the 17 run times added up to 267 to 294 ms, about 16 ms per script. For a wp post update they added up to 12.4 to 13.5 seconds, because three of the hooks reach out over the network: two open SSH sessions to the web server to inspect the live post, and one checks every Amazon link against the live catalog. Claude Code runs all matching hooks in parallel, so the wait a real publish command sees is set by the slowest hook, about 6.6 seconds, not by the sum. The figures below are from one of those runs:

Hook (PreToolUse, Bash)Run time on a wp post updateConfigured timeout
Featured image check (SSH to the server)6.6 s15 s
HTML rules check (SSH to the server)5.5 s30 s
Affiliate link audit (Amazon API)1.0 s190 s
The other 14 hooks combinedabout 0.3 s5 s up to the 600 s default

The timeout column is the part we’d change first. A command hook that runs past its timeout doesn’t block the tool call; it lets it through. Our featured-image gate therefore has a little over twice its normal run time before a slow SSH handshake turns it into a pass. For a gate that exists to stop bad publishes, that is the wrong failure mode, and it is exactly what "onFailure": "block" is for. Since Claude Code 2.1.296, claude --debug also records each hook’s run time in the session’s debug log under ~/.claude/debug/ (search it for Hooks: lines), which finds a slow gate without a timing script.

The trade-off we made there is the one to think through for any enforcement hook. A deterministic gate is stricter than trusting whoever is at the keyboard, and that strictness is the entire value: it means a rule cannot be skipped on a tired afternoon. The cost is that the gate has to be correct, because a buggy hook blocks legitimate work as reliably as it blocks mistakes. We keep ours narrow and loud, each one checking a single condition and printing a precise reason, which is the same discipline that keeps a subagent or a CI check maintainable. For teams already running Claude Code in their CI and infrastructure workflows, hooks are the local equivalent of a pre-commit gate.

Ready-made hooks from the community

You do not have to write everything yourself. A few collections are worth raiding for working scripts (star counts approximate, as of October 2026):

  • disler/claude-code-hooks-mastery (~4K) is the canonical teaching repo, with a standalone script and a logging hook for 13 of the 33 events. It hasn’t been updated since March 2026, so the newer events are missing, and it ships without a clear license, so read before you reuse.
  • carlrannaberg/claudekit (~800, MIT) is a toolkit of typecheck, lint, and test-on-change hooks plus a git checkpoint on stop. Its last commit also dates from March 2026.
  • nizos/tdd-guard (~2.4K, MIT) enforces test-driven development by blocking implementation before a failing test exists.
  • hesreallyhim/awesome-claude-code (~56K) is the umbrella list and the hub to track the rest of the ecosystem. The list itself is licensed CC BY-NC-ND 4.0; each linked project carries its own license.

Treat anything you copy the way you would treat any script that runs with your privileges, because that is exactly what a hook is.

Running hooks safely in production

That last point is the one to close on, because it is where hooks differ from the rest of your Claude Code config. A hook is arbitrary code that Claude Code executes automatically with your full permissions, so the security posture has to match. A short checklist keeps it honest: read every hook before you trust it, and be especially careful with project hooks from a repo you cloned. Reference scripts through $CLAUDE_PROJECT_DIR so the path resolves the same on every machine, and wrap it in double quotes in shell form (or use the exec form, args) so a checkout path with a space doesn’t break it. Remember that a personal ~/.claude/settings.json hook follows you into every project, including ones you did not write. Give long-running hooks a deliberate timeout. The defaults are 600 seconds for command, http and mcp_tool hooks (30 on UserPromptSubmit and the model-switch events, 10 on MessageDisplay, and a shared 1.5 seconds for SessionEnd cleanup), 30 for prompt and 60 for agent. A timed-out PreToolUse hook fails open unless you set onFailure, which works on command and http hooks only. Before running claude -p in a repo you didn’t write, review the settings files under its .claude/ directory or pass --settings '{"disableAllHooks": true}'. And keep the exit-code contract in muscle memory, since an enforcement hook that exits 1 instead of 2 looks like it is working while letting everything through with nothing more than a transcript notice.

Get those habits right and hooks become the most reliable part of your setup, the layer that does not depend on the model remembering anything. Pin the events table next to your Claude Code cheat sheet, start with one PostToolUse formatter and one PreToolUse guard, and grow the set as you find the seams in your own workflow where a guarantee beats a suggestion.

Keep reading

Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) AI Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) Open Source LLM Comparison Table (2026) AI Open Source LLM Comparison Table (2026) OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Best Laptops for Local LLMs: RTX 50 vs 128GB Unified Memory AI Best Laptops for Local LLMs: RTX 50 vs 128GB Unified Memory Black Friday Mini PC Deals: Proxmox, Local AI and Home Assistant AI Black Friday Mini PC Deals: Proxmox, Local AI and Home Assistant Claude Opus 5.5 vs GPT-6.1 Sol: Benchmarks, Pricing and Tests AI Claude Opus 5.5 vs GPT-6.1 Sol: Benchmarks, Pricing and Tests

Leave a Comment

Press ESC to close