Skip to content
KoishiAI
ไทย
← Back to contents
Chapter 7 / 12 · September 5, 2026

Hooks in Full

Enforce your rules with machinery rather than by asking. All 33 events, the JSON contract a hook receives and answers with, what each exit code means, and examples you can paste in as they are.

This guide is All Rights Reserved — free to read, copying/republication requires permission.

Chapter 7 | Hooks in Full Written for: people already comfortable with Claude Code who want something to happen reliably rather than hoping the model chooses to do it

Why hooks exist when you can already write rules in CLAUDE.md

The difference is this: a rule in CLAUDE.md is a request; a hook is enforcement.

CLAUDE.md can say “never edit .env”, and most of the time the model obeys. But “most of the time” is not “always” — especially as context grows long, or when a situation appears that seems to justify an exception. A hook is a shell command Claude Code runs itself at points in the lifecycle. It never passes through the model’s judgement at all. If you write it to block, it blocks.

The official documentation puts it plainly: hooks provide “deterministic control: certain actions always happen rather than relying on the LLM to choose to run them”.

The shape of a hook definition

Hooks live in a hooks block in a settings file. The shape is always this:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "the command to run" }
        ]
      }
    ]
  }
}

Note the two nested levels of hooks. The outer key is the event; the inner list holds the commands that run when the matcher fits.

If your settings file already has a hooks key, add the new event name as a sibling of the existing ones. Do not overwrite the whole block — every event name lives inside the same hooks object.

Where to put them, and what each scope covers

LocationScopeShareable with a team?
~/.claude/settings.jsonAll your projectsNo — your machine only
.claude/settings.jsonOne projectYes — commit it to the repo
.claude/settings.local.jsonOne projectNo — gitignored when Claude Code writes to it
Managed policy settingsWhole organisationYes — the administrator controls it
Plugin hooks/hooks.jsonWherever that plugin is enabledYes — it travels with the plugin
Skill frontmatterThe rest of the session, once that skill is invokedYes — it lives in the skill file
Subagent frontmatterWhile that subagent is workingYes — it lives in the subagent file

Type /hooks in Claude Code to see every hook you have, grouped by event. That menu is read-only. To add, change or remove one, edit the JSON yourself or ask Claude to do it.

To switch every hook off, set "disableAllHooks": true in a settings file.

All 33 events

This is the part the previous edition of this guide left out entirely. We knew hooks existed; we never knew what there was to hang one on.

Starting and ending a session

EventFires when
SessionStartA new session starts, or an old one resumes
SetupStarting with --init-only, or using --init / --maintenance in -p mode
SessionEndThe session ends

Taking instructions from you

EventFires when
UserPromptSubmitYou submit a prompt, before Claude processes it
UserPromptExpansionA command you typed is expanded into a prompt before it reaches the model. Can block the expansion

Tool calls — the group you will use most

EventFires when
PreToolUseBefore a tool call. 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 batch of parallel tool calls all finish, before the next model call

Responding and displaying

EventFires when
MessageDisplayWhile an assistant message is being displayed
NotificationClaude Code sends a notification
StopClaude finishes responding
StopFailureThe turn ends because of an API error

Subagents and tasks

EventFires when
SubagentStartA subagent is created
SubagentStopA subagent finishes
TaskCreatedA task is being created via TaskCreate
TaskCompletedA task is being marked complete
TeammateIdleA teammate in an agent team is about to go idle

Context and files

EventFires when
InstructionsLoadedA CLAUDE.md or .claude/rules/*.md file is loaded into context
ConfigChangeA settings file changes mid-session
CwdChangedThe working directory changes
DirectoryAddedA directory is added mid-session via /add-dir or the SDK’s register_repo_root
FileChangedA watched file changes on disk
WorktreeCreateA worktree is being created
WorktreeRemoveA worktree is being removed
PreCompactBefore context is compacted
PostCompactAfter compaction completes

Model switching and MCP

EventFires when
PreModelSwitchBefore Claude Code switches model. Can block the switch
PostModelSwitchAfter the session’s model has changed
ElicitationAn MCP server asks the user for input during a tool call
ElicitationResultAfter the user answers an MCP elicitation

The JSON a hook receives

A command hook is handed JSON on stdin. Here is a PreToolUse hook intercepting a Bash call:

{
  "session_id": "abc123",
  "prompt_id": "550e8400-e29b-41d4-a716-446655440000",
  "transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
  "cwd": "/home/user/my-project",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test",
    "description": "Run test suite",
    "timeout": 120000,
    "run_in_background": false
  },
  "tool_use_id": "toolu_01ABC123..."
}

tool_name and tool_input are the fields you will reach for constantly. permission_mode earns its keep when you want a hook to be stricter in some modes than others.

Exit codes — where people go wrong most often

Exit codeMeaning
0Success. Claude Code reads JSON fields from stdout. For most events stdout goes to the debug log — except UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, where plain text on stdout is added to Claude’s context
2Block. For blockable events, exit 2 blocks whether or not you print JSON. It overrides even a permissionDecision of "allow". The blocking message comes from the reason in your JSON, or from stderr
anything elseA non-blocking error. Execution continues. If you printed schema-valid JSON, it still governs the outcome for events using the standard decision model

The short version: 2 blocks, everything else does not. And anything you want the agent to read so it can correct itself belongs on stderr.

Examples you can use straight away

1. Get told when Claude is waiting on you

So you are not staring at a terminal. In ~/.claude/settings.json:

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

On Linux, use notify-send 'Claude Code' 'Claude Code needs your attention' instead.

If nothing appears on macOS, osascript posts notifications through the Script Editor app. If that app has not been granted notification permission the command fails silently, and macOS does not prompt you either. Run osascript -e 'display notification "test"' in a terminal once — nothing will show — then open System Settings → Notifications, find Script Editor, turn on Allow Notifications, and run it again.

2. Format automatically after every edit

In the project’s .claude/settings.json:

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

The documented examples use jq to parse the JSON. Install it with brew install jq on macOS, or apt-get install jq on Debian and Ubuntu.

If you want a particular file reformatted every time it changes — including when a Bash command is what rewrote it — use a FileChanged hook instead.

3. Block edits to files that must not be touched

A separate script reads more clearly. Save this at .claude/hooks/protect-files.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Normalise Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

On macOS and Linux the script must be executable, or Claude Code cannot run it:

chmod +x .claude/hooks/protect-files.sh

Then register it in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
          }
        ]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIR is set for you by Claude Code, so paths need no hardcoding.

Test it by asking Claude to add a comment to your .env file. The edit is blocked before it runs, and the Blocked: message is passed back to Claude as feedback.

4. Put context back after compaction

When the context window fills, Claude Code compacts the conversation to free space, which can lose important detail. A SessionStart hook with a compact matcher puts it back after every compaction — plain text your command writes to stdout is added to Claude’s context.

5. Approve certain permissions automatically

{
  "hooks": {
    "PermissionRequest": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
          }
        ]
      }
    ]
  }
}

Hooks that are not shell commands

The official docs support three further kinds, for cases a fixed rule cannot cover:

  • Prompt-based hooks use a model to judge a condition that needs judgement
  • Agent-based hooks hand the judgement to an agent
  • HTTP hooks call an endpoint instead of running a command locally

Things worth knowing before you start

A Stop hook that runs your full gate will trap the agent in a loop and burn a lot of tokens. Run something fast in the hook — lint and unit tests for the files that changed — and leave the full suite to CI.

When a hook does not fire, check three things: whether the script is executable, whether /hooks can see it at all, and whether the matcher actually matches the real tool name.

Never put a secret in a hook command. Commands are stored in settings files that usually get committed. Reference an environment variable instead.

Glossary for this chapter

TermMeaning
HookA shell command Claude Code runs itself at a point in the lifecycle
EventThe moment a hook attaches to, such as PreToolUse
MatcherThe condition deciding which tools or cases this hook applies to
Exit 2The code meaning “block”, usable on blockable events
$CLAUDE_PROJECT_DIRVariable pointing at the project root, for referencing hook scripts
disableAllHooksThe setting that turns off every hook in that scope