Skillforge Field notes on shipping with AI tools

Claude Code Hooks: A Practical Guide

claude-codehooks

Hooks are shell commands that Claude Code runs automatically at specific points in its lifecycle: before a tool call, after a file edit, when a session starts, when Claude finishes responding. They are the deterministic half of a Claude Code setup. Instructions in CLAUDE.md are suggestions the model usually follows; a hook always runs. This guide covers the events worth knowing, how hooks communicate through exit codes and JSON, and three working examples. Everything here is verified against the official Claude Code docs as of August 2026.

Your first hook: auto-format after every edit

Hooks live in a hooks block inside a settings file. This one runs Prettier on every file Claude edits, so formatting stays consistent without anyone remembering to ask. Add it to .claude/settings.json in your project root:

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

Reading it from the outside in: PostToolUse is the event (after a tool call succeeds), "Edit|Write" is the matcher (only fire for file-editing tools), and the inner hooks array holds the commands to run. The command receives the event data as JSON on stdin; jq -r '.tool_input.file_path' extracts the edited file's path and hands it to Prettier.

Run /hooks inside Claude Code to confirm the hook registered. The menu is read-only: it shows every configured hook grouped by event, but adding or changing hooks happens in the settings JSON. To test, ask Claude to add a line with single-quoted strings to a JavaScript file. With Prettier defaults, the hook rewrites them to double quotes.

One dependency note: the standard examples use jq to parse JSON. Install it with brew install jq on macOS or apt-get install jq on Debian and Ubuntu, or parse the stdin JSON with Python or Node instead.

The events worth knowing

Claude Code fires around thirty hook events, but a handful cover almost everything people actually build:

  • PreToolUse: before a tool call executes. The only event that can stop an action before it happens. Guardrails live here.
  • PostToolUse: after a tool call succeeds. Formatters, linters, loggers. It cannot undo the action; the tool already ran.
  • UserPromptSubmit: when you submit a prompt, before Claude sees it. Anything the hook prints to stdout is added to Claude's context, which makes it the place to inject dynamic state like the current branch.
  • SessionStart: when a session begins or resumes. With a compact matcher it fires after context compaction, which is the standard fix for Claude forgetting project conventions in long sessions.
  • Notification: when Claude Code needs your attention, such as waiting on a permission prompt. Desktop notifications live here.
  • Stop: when Claude finishes responding. A Stop hook that exits with code 2 sends its stderr back to Claude as an instruction to keep working, which is how "do not stop until the tests pass" setups are built.

The full list includes events for subagents starting and stopping, context compaction, configuration changes, working-directory changes, and session end. See the official hooks reference for the complete table.

How hooks talk to Claude Code

A command hook is a plain process wired up in three directions: it reads event data as JSON from stdin, it writes messages to stdout or stderr, and it reports a decision through its exit code.

Every event's stdin JSON includes common fields like session_id and cwd, plus event-specific data. A PreToolUse hook for a Bash command receives:

{
  "session_id": "abc123",
  "cwd": "/Users/sarah/myproject",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  }
}

The exit code then determines what happens:

  • Exit 0: no objection. For PreToolUse this does not approve the call; the normal permission flow still applies. For UserPromptSubmit and SessionStart, stdout is added to Claude's context.
  • Exit 2: block the action, where the event supports blocking. Write the reason to stderr. For PreToolUse the tool call is cancelled and Claude reads the reason as feedback, so it can adjust instead of retrying blindly.
  • Anything else: a non-blocking error. The action proceeds and the transcript shows a hook error notice.

For finer control than block-or-silence, exit 0 and print a JSON object to stdout instead. A PreToolUse hook can return "permissionDecision": "deny" with a reason, "allow" to skip the interactive permission prompt, or "ask" to force one:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Use rg instead of grep for better performance"
  }
}

Pick one approach per hook: exit 2 with stderr for simple blocking, exit 0 with JSON for structured decisions. Mixing them has defined but surprising semantics.

Example: block edits to protected files

The most common guardrail. Save this as .claude/hooks/protect-files.sh:

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

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

Make it executable with 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 resolves to the project root, so the hook works no matter which directory Claude is in when it fires. Ask Claude to add a comment to your .env file to test: the edit is blocked before it runs, and the script's Blocked: message is passed to Claude as feedback.

Worth knowing: PreToolUse hooks fire before any permission-mode check. A hook that denies a tool call blocks it even in bypassPermissions mode. The reverse is not true: a hook returning "allow" cannot override deny rules from settings. Hooks can tighten policy but not loosen it.

Example: re-inject context after compaction

When a long session fills the context window, Claude Code compacts the conversation into a summary, and details get lost. A SessionStart hook with a compact matcher re-injects whatever you cannot afford to lose:

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

Anything the command writes to stdout lands in Claude's context. Replace the echo with something dynamic like git log --oneline -5 and the reminder stays current on its own. For context that should load at every session start, CLAUDE.md is the better tool; this hook is specifically for surviving compaction.

Matchers: firing on less than everything

Without a matcher, a hook fires on every occurrence of its event. The matcher string narrows that down, and what it matches against depends on the event. Tool events (PreToolUse, PostToolUse) match on the tool name: "Bash", "Edit|Write", or a regex like "mcp__github__.*" to catch every tool from one MCP server. SessionStart matches on how the session started (startup, resume, clear, compact). Notification matches on the notification type, such as permission_prompt or idle_prompt. Matchers are case-sensitive, and a few events, including Stop and UserPromptSubmit, do not support matchers at all.

For tool events there is also an if field that filters on tool name and arguments together using permission-rule syntax, so "if": "Bash(git *)" runs a hook only for git commands rather than all of Bash. The filter is best-effort (it fails open when a command cannot be parsed), so treat it as an optimization, not a security boundary.

Where hooks live

Scope is decided by which settings file holds the hook:

  • ~/.claude/settings.json: every project on your machine.
  • .claude/settings.json: one project, committed and shared with the team.
  • .claude/settings.local.json: one project, personal, gitignored.
  • Plugin hooks/hooks.json: bundled with a plugin, active when it is enabled.
  • Skill or agent frontmatter: active only while that component runs.

Team guardrails belong in the project file so everyone gets them; personal notification preferences belong in the user file. To turn everything off temporarily, set "disableAllHooks": true in settings.

Debugging when a hook misbehaves

Three techniques cover most failures. First, test the script directly by piping sample JSON: echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh and check the exit code. Second, run /hooks to confirm the hook is registered under the event you expect, with the matcher you expect. Third, for full execution details, start with claude --debug-file /tmp/claude.log and tail the log; it records which hooks matched, their exit codes, and their output.

One trap specific to Stop hooks: Claude Code overrides a Stop hook after it blocks eight consecutive times without progress. Well-behaved Stop hooks parse the stop_hook_active field from stdin and exit 0 when it is true.

FAQ

What is the difference between hooks and skills in Claude Code?

A skill is markdown instructions the model chooses to follow when relevant. A hook is a shell command the harness always executes when its event fires; the model gets no vote. Use skills for know-how and judgment, hooks for rules that must hold every time, like formatting, protected paths, and audit logging.

Can a hook block Claude from editing a file?

Yes. A PreToolUse hook matched on Edit|Write that exits with code 2 cancels the edit before it happens, and whatever the hook wrote to stderr is fed back to Claude as the reason. This works in every permission mode, including bypassPermissions.

Do hooks run for subagents?

Yes, tool calls made by subagents fire tool hooks, and there are dedicated SubagentStart and SubagentStop events that match on the agent type. In non-interactive mode, background subagents cannot show permission prompts, so if no hook returns a decision for a PermissionRequest, the call is denied.

How long can a hook run?

Command hooks default to a 10 minute timeout, with two exceptions: UserPromptSubmit lowers it to 30 seconds and SessionEnd hooks share a short budget so they cannot hang shutdown. Override per hook with a timeout field in seconds. Slow hooks on frequent events like PostToolUse make every edit feel slow, so keep them fast or make them asynchronous.