Claude Code Hooks: Complete Guide to PreToolUse, PostToolUse, and More
Claude Code has 5 hook types: PreToolUse (before any tool call), PostToolUse (after any tool call), UserPromptSubmit (before each user message is sent to Claude), SessionStart (once when the session begins), and SessionStop (once when the session ends). Hooks are shell commands configured in settings.json that run automatically — no Claude reasoning involved.
Hooks are the automation layer of Claude Code. They're shell commands that Claude Code's runtime executes when specific events occur. Unlike skills, hooks run without Claude's involvement — they're pure shell, fast, and deterministic. This makes them ideal for quality gates (lint, format, test) and context injection (git status, environment info).
PreToolUse hooks fire before a tool call executes. You receive the tool name and arguments as environment variables. You can return exit code 0 to allow the tool call to proceed, or non-zero to block it with an error message. This is the mechanism for enforcing policies: 'never write to files outside src/', 'always run tests before committing'.
PostToolUse hooks fire after a tool call completes. You receive the tool name, arguments, and output. Use PostToolUse to trigger side effects: auto-format files after write, auto-run tests after editing test files, log tool usage for auditing. The hook cannot modify the tool output that Claude sees.
UserPromptSubmit hooks fire before each user message reaches Claude. They can inject additional context into the message — this is how you automatically include `git status`, current branch, environment name, or recently changed files into every prompt without typing them manually.
SessionStart fires once when Claude Code starts an interactive session. Use it to set up the environment: start a dev server, pull latest git changes, load environment variables, write a session log entry. SessionStop fires when the session ends (user exits or /exit command).
Hook configuration lives in settings.json under the `hooks` key. Each entry is an object with `matcher` (which tool/event to match, supports regex) and `command` (the shell command to run). Environment variables `CLAUDE_TOOL_NAME`, `CLAUDE_TOOL_INPUT`, and `CLAUDE_TOOL_OUTPUT` are set automatically for tool hooks.
Examples
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "bash /path/to/.claude/hooks/validate-bash.sh"
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"command": "npx prettier --write \"$CLAUDE_TOOL_OUTPUT_PATH\" 2>/dev/null || true"
},
{
"matcher": "Write",
"command": "bash -c 'if [[ \"$CLAUDE_TOOL_OUTPUT_PATH\" == *.test.* ]]; then pnpm test \"$CLAUDE_TOOL_OUTPUT_PATH\" 2>&1 | tail -5; fi'"
}
],
"UserPromptSubmit": [
{
"command": "echo \"[Context: branch=$(git branch --show-current), status=$(git status --short | wc -l | tr -d ' ') changed files]\""
}
],
"SessionStart": [
{
"command": "echo \"Session started: $(date)\" >> ~/.claude/session.log"
}
],
"SessionStop": [
{
"command": "echo \"Session ended: $(date)\" >> ~/.claude/session.log"
}
]
}
}#!/bin/bash
# .claude/hooks/inject-context.sh
# Used as UserPromptSubmit hook — output is prepended to every message
BRANCH=$(git branch --show-current 2>/dev/null || echo 'no-branch')
STATUS=$(git status --short 2>/dev/null | head -10)
LAST_COMMIT=$(git log -1 --oneline 2>/dev/null || echo 'no commits')
NODE_VERSION=$(node --version 2>/dev/null || echo 'node not found')
cat <<EOF
[Auto-context]
- Branch: $BRANCH
- Last commit: $LAST_COMMIT
- Changed files: $(echo "$STATUS" | wc -l | tr -d ' ')
- Node: $NODE_VERSION
$(if [ -n "$STATUS" ]; then echo "- Unstaged:\n$STATUS"; fi)
EOFTips
- →Always end PostToolUse hook commands with `|| true` to prevent hook failures from blocking Claude's workflow when the side effect is optional (formatting, logging).
- →Use the matcher field with pipe-separated values to apply the same hook to multiple tools: `"matcher": "Write|Edit|MultiEdit"`.
- →PreToolUse hooks that run slowly (>1 second) noticeably slow down Claude's workflow. Keep validation hooks fast — parse arguments in bash, don't spawn heavyweight processes.
- →Test hooks manually before enabling them: copy the hook command to your terminal, set the environment variables manually, and run it to confirm behavior.
- →UserPromptSubmit hooks that output too much context can fill the context window quickly. Keep injected context under 200 tokens — stick to key facts like branch and status.
- →Use SessionStart hooks to run `git fetch` so Claude always has current remote branch info when making decisions about rebasing or PR readiness.
FAQ
Can a hook cancel a tool call?+
PreToolUse hooks can block tool calls by exiting with a non-zero exit code. When a hook exits non-zero, the tool call is cancelled and Claude is informed that the tool was blocked. Claude will typically ask you how to proceed or try an alternative approach.
Can hooks access Claude's current reasoning or message?+
Tool hooks (PreToolUse, PostToolUse) cannot access Claude's reasoning. They receive tool-specific environment variables (CLAUDE_TOOL_NAME, CLAUDE_TOOL_INPUT, CLAUDE_TOOL_OUTPUT). UserPromptSubmit hooks receive the user's message text. Hooks cannot see Claude's internal chain-of-thought.
Do hooks run in headless/--print mode?+
Yes — hooks configured in settings.json run in all modes including headless. This is intentional: your quality gates (auto-format, auto-test) should apply in CI runs too. If you want a hook only for interactive sessions, check for a TTY in your hook script: `[ -t 0 ] || exit 0`.
How do I debug a hook that isn't working?+
Run the hook command manually in your terminal with the expected environment variables set: `CLAUDE_TOOL_NAME=Write CLAUDE_TOOL_OUTPUT_PATH=src/test.ts bash .claude/hooks/my-hook.sh`. Check the exit code with `echo $?`. Enable Claude Code debug mode with `claude --debug` to see hook execution in the log.
Can hooks write back to Claude's context?+
UserPromptSubmit hooks write to stdout, and that output is prepended to the user message before it reaches Claude. PostToolUse hooks can write to stdout, but this output goes to the terminal, not back into Claude's context. There's no direct mechanism for hooks to inject into Claude's current reasoning mid-session.