Claude Code Hooks: Every Event, the Config Format and Five Working Examples

Claude Code Hooks: Every Event, the Config Format and Five Working Examples

A line in CLAUDE.md that says "always run the formatter after editing" works most of the time. Most of the time is the problem. The model reads the instruction, weighs it against everything else in the context, and sometimes decides this edit is too small to bother.

Hooks fix that by taking the decision away from the model. This post covers what Claude Code hooks are, every event the docs list, how to configure them, how exit codes and JSON output control Claude, and five examples you can paste into a settings file. I checked the details against the official hooks reference and hooks guide on October 23, 2026. Hooks change often, so treat the docs as the source of truth if anything here has drifted.

What Are Claude Code Hooks?

Claude Code hooks are user-defined handlers that Claude Code runs automatically at specific points in its lifecycle, such as before a tool call, after a file edit, or when Claude finishes responding. Most hooks are shell commands. Because the harness runs them, not the model, they fire every time, which makes them deterministic.

That last part is the whole point. A skill or a CLAUDE.md rule is a suggestion the model may follow. A hook is code that runs whether the model remembers it or not. If you want a rule enforced, you write a hook. If you want a rule suggested, you write a skill.

If you are still sorting out how hooks sit next to skills, plugins and MCP servers, my plugin and skills marketplace guide has the short version, and this post is the long one.

Claude Code Hook Events and When They Fire

The hooks reference lists 33 events. Each one is a key you put inside the hooks object in a settings file. These are the events as the docs list them, in the order they appear there.

EventWhen it fires
SessionStartA session begins or resumes
SetupStarted with --init-only, or with --init or --maintenance in -p mode
UserPromptSubmitA prompt is submitted, before Claude processes it
UserPromptExpansionA command you typed expands into a prompt, before it reaches Claude
PreToolUseBefore a tool call runs. Can block
PermissionRequestA tool call needs a permission decision
PermissionDeniedAuto mode denies a tool call
PostToolUseAfter a tool call succeeds
PostToolUseFailureAfter a tool call fails
PostToolBatchAfter a full batch of parallel tool calls resolves, before the next model call
NotificationClaude Code sends a notification
MessageDisplayWhile assistant message text is displayed
SubagentStartA subagent is spawned
SubagentStopA subagent finishes
TaskCreatedA task is being created with TaskCreate
TaskCompletedA task is being marked completed
StopClaude finishes responding
StopFailureThe turn ends because of an API error
TeammateIdleAn agent team teammate is about to go idle
InstructionsLoadedA CLAUDE.md or .claude/rules/*.md file is loaded
ConfigChangeA configuration file changes during a session
CwdChangedThe working directory changes
DirectoryAddedA working directory is added mid-session with /add-dir
FileChangedA watched file changes on disk
WorktreeCreateA worktree is being created
WorktreeRemoveA worktree created by a WorktreeCreate hook is removed
PreCompactBefore context compaction
PostCompactAfter context compaction completes
PreModelSwitchBefore a requested model switch is applied. Can block
PostModelSwitchAfter the session's model changes
ElicitationAn MCP server requests user input during a tool call
ElicitationResultAfter a user responds to an MCP elicitation
SessionEndA session terminates

Most people need PreToolUse, PostToolUse, Stop, SessionStart and Notification. The rest cover narrower jobs like auditing config changes or reacting to compaction.

A few details from the docs that save you debugging time. PreToolUse and PostToolUse fire on every tool call in the agentic loop. Stop fires whenever Claude finishes responding, not only when the task is truly done, and it does not fire on user interrupts. And a PostToolUse hook cannot undo anything, because the tool has already run.

Where Claude Code Hooks Are Configured

Hooks live in JSON. You can put them in several places, and the entries merge across levels instead of replacing each other.

LocationScopeShareable
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonSingle projectYes, committed to the repo
.claude/settings.local.jsonSingle projectNo, gitignored
Managed policy settingsOrganization-wideYes, admin-controlled
Plugin hooks/hooks.jsonWhile the plugin is enabledYes
Skill frontmatterThe rest of the session once the skill is invokedYes
Subagent frontmatterWhile that subagent runsYes

I put personal conveniences like notifications in ~/.claude/settings.json, and team rules like a formatter in the committed .claude/settings.json.

You can browse what is active by typing /hooks at the Claude Code prompt. It opens a read-only browser of your configured hooks. If a new hook does not appear after a few seconds, restart the session.

The JSON shape

Three levels: an event, a matcher group, and one or more handlers.

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-bash.sh"
          }
        ]
      }
    ]
  }
}

If your settings file already has a hooks key, add the new event as a sibling of the existing ones. Each event name is a key inside the single hooks object. Overwriting the whole object is the most common way to delete a hook you forgot you had.

Hook types

The type field decides what the handler does.

  • command runs a shell command and passes the event JSON on stdin.
  • http POSTs the event JSON to a URL.
  • mcp_tool calls a tool on a configured MCP server.
  • prompt sends your prompt and the event data to a Claude model for a single-turn yes or no decision.
  • agent spawns a subagent that can use tools to verify a condition. The docs mark this one experimental.

Shared fields include if, timeout, statusMessage and once. Command hooks add args (exec form, no shell), async, shell and onFailure. Timeouts default to 600 seconds for command, http and mcp_tool, 30 for prompt and 60 for agent, so set timeout on anything slow.

Matchers: Choosing Which Calls a Hook Sees

The matcher field filters when a group fires. How it is evaluated depends on what it contains.

Matcher valueEvaluated as
"*", "", or omittedMatch everything
Only letters, digits, _, -, spaces, commas and pipesExact string, or a list separated by pipes or commas
Any other characterJavaScript regular expression, unanchored

What the matcher is compared against depends on the event. For PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied, it is the tool name, like Bash, Edit or mcp__memory__.*. MCP tools are named mcp__<server>__<tool>, and matching every tool from one server needs the .*.

For SessionStart it is how the session started: startup, resume, clear, compact or fork. For Notification it is the notification type, such as permission_prompt or idle_prompt. UserPromptSubmit, Stop and several others take no matcher and always fire.

On tool events there is also an if field that uses permission-rule syntax, like "Bash(git *)" or "Edit(*.ts)". It holds one rule and no boolean operators. The docs call it best-effort and say to use the permission system when you need a hard allow or deny.

Exit Codes: How a Hook Blocks Claude

A command hook talks to Claude Code through its exit code, stdout and stderr.

  • Exit 0 means success. If stdout is a single JSON object, Claude Code reads its fields. Plain stdout is added to Claude's context only for UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch. Claude never sees stderr on exit 0.
  • Exit 2 is a blocking error. On events that can block, the action stops and stderr becomes the reason. Exit 2 wins even if JSON on stdout says "allow".
  • Any other code is a non-blocking error. The action proceeds, and the first line of stderr shows in the notice.

Which events can actually be blocked matters. By the docs, exit 2 blocks on PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange (except policy settings), PostToolBatch, PreCompact, PreModelSwitch, Elicitation, ElicitationResult, WorktreeCreate and WorktreeRemove.

It cannot block PostToolUse, PostToolUseFailure, PermissionRequest (use a decision instead), Notification, SessionStart, SubagentStart, SessionEnd, PostCompact and the other observational events. On those, exit 2 shows stderr to you and execution continues.

One gotcha from the guide: exit 0 from a PreToolUse hook does not approve the call. The tool call still goes through the normal permission flow.

JSON output for finer control

Exit codes are blunt. If you need more, exit 0 and print one JSON object to stdout. Print only the JSON. Anything else on stdout, like output from a shell profile, breaks parsing. The docs say to pick one approach per hook: exit 2 with stderr, or exit 0 with JSON.

Universal fields are continue (set false to stop Claude entirely), stopReason, systemMessage, suppressOutput and terminalSequence. Event-specific decisions go in different places. PreToolUse uses hookSpecificOutput with permissionDecision set to allow, deny, ask or defer, plus permissionDecisionReason. PostToolUse and Stop use a top-level decision: "block" with a reason.

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

A field at the wrong level is a common reason a hook silently does nothing: permissionDecision belongs inside hookSpecificOutput, not at the top.

What a hook receives and which variables it can use

Command hooks get JSON on stdin. Common fields include session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name, tool_input and tool_use_id, which is how a script finds the file path or command Claude is about to use. SessionStart hooks also get a source, and UserPromptSubmit hooks get the prompt text.

Environment variables the docs list for hooks:

  • CLAUDE_PROJECT_DIR: the project root where the session started
  • CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA: a plugin's install and data directories
  • CLAUDE_PLUGIN_OPTION_<KEY>: a plugin's user-config values
  • CLAUDE_ENV_FILE: a file a SessionStart or CwdChanged hook can write environment variables into, which Claude Code runs as a preamble before each Bash command

Use ${CLAUDE_PROJECT_DIR} in script paths so they work from any directory.

Five Claude Code Hook Examples

These are examples, adapted from the official hooks guide. Test each in a throwaway project before you commit it to a repo your team uses.

Example 1: Get a desktop notification when Claude needs input

The Notification event fires when Claude is waiting for input or permission. This is the guide's macOS version. Put it in ~/.claude/settings.json.

json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

The empty matcher fires on every notification type. To narrow it, set the matcher to a type like permission_prompt or idle_prompt. The guide gives notify-send for Linux and a PowerShell variant for Windows. On macOS, if nothing appears, osascript routes through Script Editor, and the docs say to enable notifications for Script Editor in System Settings.

Example 2: Format every file Claude edits

This PostToolUse hook runs Prettier on the file Claude just wrote. It goes in .claude/settings.json in the project root.

json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
          }
        ]
      }
    ]
  }
}

jq pulls the path out of the stdin JSON and Prettier does the rest. It needs jq installed. One limit the guide calls out: Claude can also change files through shell commands, which an Edit|Write matcher never sees. If your hook must see every change, add a Stop hook that checks the working tree once per turn.

Example 3: Block dangerous Bash commands

This PreToolUse hook exits 2 when a command matches a pattern you never want Claude to run. The guide's own script blocks edits to protected files with the same mechanism. I adapted it to read tool_input.command for the Bash tool.

Save this as .claude/hooks/block-dangerous.sh:

bash
#!/bin/bash
# block-dangerous.sh (example adapted from the hooks guide's protect-files.sh)

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

BLOCKED_PATTERNS=("rm -rf" "git push --force" "DROP TABLE")

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if [[ "$COMMAND" == *"$pattern"* ]]; then
    echo "Blocked: command matches '$pattern'. Ask the user before running it." >&2
    exit 2
  fi
done

exit 0

Make it executable with chmod +x .claude/hooks/block-dangerous.sh, then register it:

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

Claude Code blocks the call before it runs and passes your stderr message to Claude, so it can pick another approach. Substring matching is crude. A command like rm -rf with two spaces slips past it. Treat this as a tripwire for honest mistakes, not as a security boundary, and read the next section before you rely on it.

The docs add one useful fact: a PreToolUse hook that returns a deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. That is why hooks pair well with the permission setup in my least-privilege guide.

Example 4: Inject context at SessionStart

Compaction summarizes a long conversation and can lose details. A SessionStart hook with the compact matcher puts critical context back. This is the guide's version, in .claude/settings.json.

json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "compact",
        "hooks": [
          {
            "type": "command",
            "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
          }
        ]
      }
    ]
  }
}

Plain text a SessionStart command writes to stdout goes into Claude's context. Swap the echo for something dynamic, like git log --oneline -5. The docs point out that if you want the same context on every session start, CLAUDE.md is the better home. Use the hook when the context is dynamic or only needed at a specific moment. Change the matcher to startup to inject on a fresh session instead.

Example 5: Run tests before Claude stops

A Stop hook can refuse to let Claude finish. This one runs the test suite and, if it fails, exits 2 with the failure output so Claude keeps working. It is an example I built from two pieces of the docs: the exit-2 behavior for Stop, and the guide's stop_hook_active check.

Save this as .claude/hooks/tests-on-stop.sh:

bash
#!/bin/bash
# tests-on-stop.sh (example)

INPUT=$(cat)

# Stop hooks can re-trigger themselves. Bail out if we already blocked once.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

cd "$CLAUDE_PROJECT_DIR" || exit 0

if ! OUTPUT=$(bun test 2>&1); then
  echo "Tests are failing. Fix them before finishing:" >&2
  echo "$OUTPUT" | tail -n 30 >&2
  exit 2
fi

exit 0

Swap bun test for your own test command. Then register it:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-on-stop.sh",
            "timeout": 300
          }
        ]
      }
    ]
  }
}

The stop_hook_active guard matters. The docs say Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call in between, and the cap can be raised with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. Because Stop fires after every response, a slow test suite will run more often than you expect. For a lighter touch, the guide shows a prompt hook on Stop that asks a model whether all requested tasks are done.

Security: Hooks Run With Your Permissions

A command hook is a shell command that runs as your user, with your environment and access to your files. There is no sandbox around it unless you build one. That has a few consequences.

Read every hook you did not write. A hook in a shared .claude/settings.json or a plugin's hooks/hooks.json runs on your machine once you trust the project or enable the plugin. Cloning a repo and accepting its settings means running its commands. The docs say project hooks in settings files and frontmatter are subject to workspace trust, and frontmatter hooks in project subagents run only after you accept the trust dialog. Treat that dialog the way you treat running an install script.

Quote your variables. The hook input contains file paths and commands the model chose. A path with a space or shell metacharacter in it will break an unquoted script or, worse, run something you did not intend. The docs recommend exec form with args for paths, and they note shell form needs careful quoting.

Do not mistake if or substring matching for enforcement. The if field is best-effort by the docs' own words. For hard rules, use permission rules and managed settings, and use hooks as one more layer.

For teams, managed settings add controls. allowManagedHooksOnly blocks user, project, local and plugin hooks, and allowedHttpHookUrls limits which URLs HTTP hooks can reach. I would also read the plugin security page in the docs before enabling any third-party marketplace, since plugin hooks run with the same authority as the ones you write yourself.

Hooks vs Skills vs Permissions vs CLAUDE.md

These four get confused because they all "tell Claude what to do". They differ in who enforces the rule.

HooksSkillsPermissionsCLAUDE.md
What it isHandlers that run at lifecycle eventsFolders with a SKILL.md of instructionsAllow, ask and deny rules for tool callsA markdown file of standing instructions
Who enforces itThe harness, every timeThe model, when the description matchesThe harnessThe model, as context
Can it block an actionYes, on events that can blockNoYesNo
Runs codeYesOnly through tools Claude already hasNoNo
Best forFormatting, guards, notifications, checksTeaching a repeatable workflowLimiting what Claude may touchProject conventions and commands

The practical split: use CLAUDE.md for conventions, skills for workflows, permissions for the allowed surface, and hooks for anything that must happen or must not happen regardless of the model. If I find myself writing "always" or "never" in a CLAUDE.md rule and it keeps getting ignored, that rule probably wants to be a hook. I wrote about building skills in the skill developer guide.

A Notification hook is a cousin of the status line, which shows state continuously instead of alerting you once. And SubagentStart and SubagentStop let you hook into delegated work, which is covered in the subagents guide.

If a hook does nothing, run /hooks to confirm it is listed, validate the JSON (no trailing commas), check the script is executable, and make sure nothing else prints to stdout before your JSON. Pipe sample JSON into a script to test it outside a session, for example echo '{"tool_input":{"command":"rm -rf /tmp/x"}}' | .claude/hooks/block-dangerous.sh, then check echo $?.

Frequently Asked

What are Claude Code hooks? They are user-defined handlers that Claude Code runs at specific lifecycle events, such as before a tool call, after an edit, or when Claude finishes responding. Most are shell commands, and because the harness runs them, they fire every time instead of depending on the model.

Where do I put Claude Code hooks? In a settings file: ~/.claude/settings.json for all projects, .claude/settings.json for one project (committed), or .claude/settings.local.json for one project (gitignored). Plugins can ship them in hooks/hooks.json, and skills and subagents can define them in frontmatter.

How do I block a tool call with a hook? Use a PreToolUse hook. Exit with code 2 and write the reason to stderr, or exit 0 and print JSON with permissionDecision set to deny inside hookSpecificOutput. Either way, Claude Code stops the call before it runs.

Can a hook run when Claude finishes a task? Yes, with the Stop event. It fires whenever Claude finishes responding, not only at task completion, so check stop_hook_active in the input to avoid loops.

Do hooks work with --dangerously-skip-permissions? A PreToolUse hook that denies a call still blocks it in bypassPermissions mode or with that flag, according to the hooks guide. That makes hooks useful for policy people cannot switch off by changing permission mode.

Sources: Claude Code hooks reference and Automate actions with hooks (hooks guide), checked October 23, 2026.