Hooks

Hooks are shell commands Claude Code runs at specific events — before a tool call, after a file edit, when the session ends. They're how you turn Claude from a smart assistant into an integrated piece of your workflow: format on save, notify when long-running tasks finish, block dangerous operations, audit everything.

On this page

1. Why hooks (vs skills, vs commands)

Skills and commands change what Claude does. Hooks fire when Claude does something — automatic, no LLM in the loop, deterministic.

NeedReach for
"Format every file Claude edits."Hook (PostToolUse on Edit/Write)
"Notify me when long-running prompts finish."Hook (Stop)
"Run typecheck after edits and feed the result back."Hook (PostToolUse + return JSON)
"Refuse to delete prod env vars."Hook (PreToolUse, return exit 1)
"Capture an audit log of every file edit for compliance."Hook (PostToolUse)

2. The event lifecycle

EventFires whenCommon use
SessionStartClaude Code starts in a directory.Print today's tasks, warm caches, pull latest git.
UserPromptSubmitYou hit Enter on a message.Inject context, redact secrets, log prompts.
PreToolUseClaude is about to call a tool.Block / validate / require confirmation.
PostToolUseClaude just finished a tool call.Format the file, run typecheck, audit-log.
NotificationClaude needs your attention (permission, idle).Beep, push notification, Slack message.
StopClaude finished a turn.Notify you, compute cost, take a screenshot.
SubagentStopA subagent finished.Same as Stop but per-subagent.
SessionEndSession closes.Summarize, log, sync state.

3. Configuration

Hooks live in ~/.claude/settings.json (user) or .claude/settings.json (project) under the hooks key.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "prettier --write \"$CLAUDE_FILE\"" }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Claude is done\" with title \"Claude Code\"'" }
        ]
      }
    ]
  }
}

Each event maps to a list of matcher groups. Each group has one matcher (regex) and one or more commands. Commands run in your shell with a working directory of the project root.

4. Matchers

The matcher is a regex against the tool name (for PreToolUse / PostToolUse). Common patterns:

MatcherFires for
".*" or omittedEvery tool.
"Bash"Just Bash calls.
"Edit|Write|MultiEdit"Any file-mutating tool.
"mcp__github__.*"Any tool from the GitHub MCP server.
"Read|Glob|Grep"Read-only ops.

Other events (Stop, Notification, SessionStart, etc.) don't take matchers — just hook arrays.

5. Environment variables your hook sees

VariableWhat it is
CLAUDE_PROJECT_DIRThe project root.
CLAUDE_TOOL_NAMETool that fired the event (PreToolUse/PostToolUse).
CLAUDE_FILEFor file ops, the path that was edited / written.
CLAUDE_COMMANDFor Bash hooks, the command that ran.
CLAUDE_SESSION_IDStable id for the current session.
stdinHook also gets the full event JSON on stdin — useful for richer logic.

6. Practical examples

Format every Claude-edited file

{
  "PostToolUse": [{
    "matcher": "Edit|Write|MultiEdit",
    "hooks": [
      { "type": "command", "command": "case \"$CLAUDE_FILE\" in *.ts|*.tsx|*.js|*.jsx|*.json|*.md) prettier --write \"$CLAUDE_FILE\" ;; *.py) ruff format \"$CLAUDE_FILE\" ;; *.go) gofmt -w \"$CLAUDE_FILE\" ;; esac" }
    ]
  }]
}

macOS notification when Claude finishes

{
  "Stop": [{
    "hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Done\" with title \"Claude Code\" sound name \"Glass\"'" }]
  }]
}

Block force-pushes to main

{
  "PreToolUse": [{
    "matcher": "Bash",
    "hooks": [{
      "type": "command",
      "command": "echo \"$CLAUDE_COMMAND\" | grep -qE 'git push --force.*(main|master)' && { echo 'Refusing: force-push to main blocked' >&2; exit 2; }; true"
    }]
  }]
}

Exit code 2 = block. Exit code 0 = allow. The stderr text is shown to Claude as the reason.

Audit log of every file edit

{
  "PostToolUse": [{
    "matcher": "Edit|Write|MultiEdit",
    "hooks": [{
      "type": "command",
      "command": "echo \"$(date -Iseconds) $CLAUDE_SESSION_ID $CLAUDE_TOOL_NAME $CLAUDE_FILE\" >> ~/.claude/audit.log"
    }]
  }]
}

Inject "today's date" into every prompt

{
  "UserPromptSubmit": [{
    "hooks": [{
      "type": "command",
      "command": "echo '{\"appendToPrompt\":\"(today: '\"$(date +%F)\"')\"}'"
    }]
  }]
}

For events that support it, returning JSON on stdout modifies behavior — here, appending context to the prompt before Claude sees it.

Typecheck after edits and report back

{
  "PostToolUse": [{
    "matcher": "Edit|Write|MultiEdit",
    "hooks": [{
      "type": "command",
      "command": "if [[ \"$CLAUDE_FILE\" == *.ts || \"$CLAUDE_FILE\" == *.tsx ]]; then npx tsc --noEmit 2>&1 | head -50; fi"
    }]
  }]
}

Output goes back to Claude, who'll see typecheck errors and self-correct.

7. Blocking vs non-blocking

Exit codeEffect
0Allow, attach stdout as info.
2Block the tool call (PreToolUse). Stderr is shown to Claude as the reason.
Other non-zeroTreated as a soft error — logged, but doesn't block.

For events that don't gate an action (Stop, SessionEnd, Notification), exit codes are advisory.

8. Security model

Hooks run as you. A hook is just a shell command in your environment with your perms. Treat hook configs the same as any executable in your $PATH.

9. Debugging hooks