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

What Claude Code hooks are, every hook event and when it fires, where to configure them, how exit codes and JSON output work, and five copy-pasteable examples. Checked against the official docs in October 2026.

Tags: ai, claude, devtools, automation, claude-code
Published: 2026-10-23


# 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](/blog/claude-code-plugin-marketplace-skills-2026/) 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.

| Event | When it fires |
| --- | --- |
| `SessionStart` | A session begins or resumes |
| `Setup` | Started with `--init-only`, or with `--init` or `--maintenance` in `-p` mode |
| `UserPromptSubmit` | A prompt is submitted, before Claude processes it |
| `UserPromptExpansion` | A command you typed expands into a prompt, before it reaches Claude |
| `PreToolUse` | Before a tool call runs. Can block |
| `PermissionRequest` | A tool call needs a permission decision |
| `PermissionDenied` | Auto mode denies a tool call |
| `PostToolUse` | After a tool call succeeds |
| `PostToolUseFailure` | After a tool call fails |
| `PostToolBatch` | After a full batch of parallel tool calls resolves, before the next model call |
| `Notification` | Claude Code sends a notification |
| `MessageDisplay` | While assistant message text is displayed |
| `SubagentStart` | A subagent is spawned |
| `SubagentStop` | A subagent finishes |
| `TaskCreated` | A task is being created with `TaskCreate` |
| `TaskCompleted` | A task is being marked completed |
| `Stop` | Claude finishes responding |
| `StopFailure` | The turn ends because of an API error |
| `TeammateIdle` | An agent team teammate is about to go idle |
| `InstructionsLoaded` | A `CLAUDE.md` or `.claude/rules/*.md` file is loaded |
| `ConfigChange` | A configuration file changes during a session |
| `CwdChanged` | The working directory changes |
| `DirectoryAdded` | A working directory is added mid-session with `/add-dir` |
| `FileChanged` | A watched file changes on disk |
| `WorktreeCreate` | A worktree is being created |
| `WorktreeRemove` | A worktree created by a `WorktreeCreate` hook is removed |
| `PreCompact` | Before context compaction |
| `PostCompact` | After context compaction completes |
| `PreModelSwitch` | Before a requested model switch is applied. Can block |
| `PostModelSwitch` | After the session's model changes |
| `Elicitation` | An MCP server requests user input during a tool call |
| `ElicitationResult` | After a user responds to an MCP elicitation |
| `SessionEnd` | A 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.

| Location | Scope | Shareable |
| --- | --- | --- |
| `~/.claude/settings.json` | All your projects | No |
| `.claude/settings.json` | Single project | Yes, committed to the repo |
| `.claude/settings.local.json` | Single project | No, gitignored |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
| Plugin `hooks/hooks.json` | While the plugin is enabled | Yes |
| Skill frontmatter | The rest of the session once the skill is invoked | Yes |
| Subagent frontmatter | While that subagent runs | Yes |

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 value | Evaluated as |
| --- | --- |
| `"*"`, `""`, or omitted | Match everything |
| Only letters, digits, `_`, `-`, spaces, commas and pipes | Exact string, or a list separated by pipes or commas |
| Any other character | JavaScript 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](/blog/coding-agent-permissions-least-privilege-2026/).

### 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.

| | Hooks | Skills | Permissions | CLAUDE.md |
| --- | --- | --- | --- | --- |
| What it is | Handlers that run at lifecycle events | Folders with a `SKILL.md` of instructions | Allow, ask and deny rules for tool calls | A markdown file of standing instructions |
| Who enforces it | The harness, every time | The model, when the description matches | The harness | The model, as context |
| Can it block an action | Yes, on events that can block | No | Yes | No |
| Runs code | Yes | Only through tools Claude already has | No | No |
| Best for | Formatting, guards, notifications, checks | Teaching a repeatable workflow | Limiting what Claude may touch | Project 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](/blog/writing-claude-code-skill-developer-guide-2026/).

A `Notification` hook is a cousin of the [status line](/blog/claude-code-status-line-guide-2026/), 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](/blog/claude-code-subagents-guide-2026/).

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](https://code.claude.com/docs/en/hooks) and [Automate actions with hooks (hooks guide)](https://code.claude.com/docs/en/hooks-guide), checked October 23, 2026.
