CLAUDE CODE · LESSON 17

Hooks in Claude Code

Run shell scripts, call HTTP endpoints, or invoke an AI model automatically at key points in Claude Code's lifecycle — without changing a single prompt.

PreToolUsePostToolUseSessionStartCommand Hookssettings.json

What Are Hooks?

Hooks are user-defined automations that Claude Code calls at specific points during a session. You define them in your settings.json. When an event fires — before a tool runs, after a file is written, when a session starts — Claude Code passes JSON context to your handler and optionally acts on what it returns.

😤Without Hooks
1Claude edits a Python file
2You notice formatting is off
3You switch to terminal
4Run black manually
5Return to Claude and continue
With a PostToolUse Hook
1Claude edits a Python file
2PostToolUse hook fires automatically
3Hook runs Black formatter on the file
4Claude keeps working — no interruption

Hook Lifecycle

Events fire in a predictable order. Each event is a named point where Claude Code can call your hook.

SessionStartSession begins or resumes
Load dev context, set env variables
UserPromptSubmitUser sends a prompt
Validate, add context, or block the prompt
PreToolUseBefore any tool call
Allow, deny, or modify the tool input
PostToolUseAfter a tool succeeds
Run formatters, log output, inject context
StopClaude finishes responding
Enforce quality gates, keep Claude working
SessionEndSession terminates
Cleanup, analytics, save state

Configuration Structure

Hooks live in .claude/settings.json (or your user-level settings). The JSON has three nesting levels: event → matcher → handler.

LEVEL 1
Hook Event
When it fires
PostToolUse
LEVEL 2
Matcher Group
Which tool / condition
"Write|Edit"
LEVEL 3
Hook Handler
What to run
"command"
// .claude/settings.json
{
  "hooks": {
    "PostToolUse": [            // ← Level 1: hook event
      {
        "matcher": "Write|Edit", // ← Level 2: match Write or Edit tool
        "hooks": [
          {
            "type": "command",   // ← Level 3: handler type
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh"
          }
        ]
      }
    ]
  }
}

Hook Types

Each handler has a type field. Choose the right type based on what you want the hook to do.

⚙️command

Run a shell script. Input arrives on stdin; control Claude via exit code and stdout JSON.

type: "command", command: "./hook.sh"
🌐http

POST the hook input to a URL. Your service responds with the same JSON schema as command hooks.

type: "http", url: "http://localhost:8080/hook"
🧠prompt

Ask a Claude model to evaluate the event and return {ok: true/false}. No script needed.

type: "prompt", prompt: "Should Claude stop? $ARGUMENTS"
🤖agent

Spawn a mini sub-agent that can read files, search code, then return a decision. Experimental.

type: "agent", prompt: "Verify tests pass. $ARGUMENTS"
🔌mcp_tool

Call a tool on an already-connected MCP server. The tool's text output is treated like command stdout.

type: "mcp_tool", server: "audit", tool: "log_event"

Exit Codes (Command Hooks)

How your script exits tells Claude Code what to do next.

Exit CodeMeaningEffect
0SuccessClaude Code reads stdout for optional JSON control fields. Action proceeds.
2Blocking errorStderr is fed to Claude as an error. Blocks PreToolUse, UserPromptSubmit, Stop, and others.
1, 3…Non-blocking errorTranscript shows a hook error notice with the first line of stderr. Execution continues.
⚠️Use exit 2 — not exit 1 — when you want to block an action. Exit 1 is treated as a non-blocking error and the action proceeds regardless.

Worked Example: Auto-Format Python Files

Every time Claude writes or edits a .py file, automatically run the Black formatter on it — so the codebase stays clean without any manual steps.

1
Create the hook script
Save as .claude/hooks/format-python.sh and make it executable with chmod +x.
#!/bin/bash
# .claude/hooks/format-python.sh

# Read the JSON that Claude Code sends on stdin
INPUT=$(cat)

# Extract the file path that was just written or edited
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Only format Python files
if [[ "$FILE" == *.py ]]; then
  black "$FILE" 2>/dev/null
fi

exit 0   # always exit 0 — formatting is a side-effect, not a gate
2
Register the hook in .claude/settings.json
Hook fires on PostToolUse, matched to Write or Edit tool calls.
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-python.sh",
            "statusMessage": "Formatting Python…"
          }
        ]
      }
    ]
  }
}
3
Verify with the /hooks menu
Type /hooks inside Claude Code to inspect every configured hook — the event, matcher, command, and which settings file it came from.
/hooks
# Opens a read-only browser showing:
# PostToolUse [1]
#   matcher: Write|Edit
#   type: command
#   source: Project (.claude/settings.json)
#   command: "$CLAUDE_PROJECT_DIR"/.claude/hooks/format-python.sh

Key Events Reference

EventCan Block?Matcher TargetsTypical Use
SessionStartNostartup / resume / clearLoad context, set env vars
UserPromptSubmitYesn/a (fires every time)Validate or add context to prompts
PreToolUseYestool name e.g. Bash, WriteAllow, deny, or modify tool input
PostToolUseNo*tool nameFormat files, log actions, inject hints
PostToolBatchYesnone (fires every batch)Inject cross-tool context before next model call
StopYesnone (fires every stop)Enforce done-criteria, keep working
SessionEndNoclear / resume / otherCleanup, usage logging

* PostToolUse can return decision: "block" in JSON to prompt Claude with a reason — the tool already ran but Claude is asked to reconsider.

Where to Define Hooks

The location of the settings file determines the scope of the hook.

👤
~/.claude/settings.json
All projects
User-level. Private to your machine.
Not committed
📁
.claude/settings.json
Single project
Can commit to Git. Shared with the team.
Committable to Git
🔒
.claude/settings.local.json
Single project
Gitignored. Private overrides for local dev.
Not committed

Quick Reference: 3 Useful Patterns

1 — Block a dangerous command (PreToolUse)

Return exit 2 from a script when the command is unsafe. Claude sees the stderr message as the reason.

#!/bin/bash
CMD=$(cat | jq -r '.tool_input.command // empty')

# Block any command that removes the database directory
if echo "$CMD" | grep -q 'rm.*db/'; then
  echo "Blocked: removing the database directory is not allowed" >&2
  exit 2
fi

exit 0
2 — Inject context at session start (SessionStart)

Print plain text to stdout on exit 0 — Claude Code adds it to Claude's context before the first prompt.

#!/bin/bash
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
CHANGES=$(git status --short 2>/dev/null | wc -l | tr -d ' ')

echo "Current branch: $BRANCH"
echo "Uncommitted files: $CHANGES"

exit 0
3 — Enforce quality before Claude stops (Stop)

Return { "decision": "block", "reason": "..." } to keep Claude working until your quality gate passes.

#!/bin/bash
# Run the type-checker; if it fails, stop Claude from stopping
if ! npx tsc --noEmit 2>/dev/null; then
  echo '{
    "decision": "block",
    "reason": "TypeScript errors found — fix them before finishing"
  }'
fi

exit 0
💡Use $CLAUDE_PROJECT_DIR in your command paths so they work regardless of the current working directory when the hook fires.
🔍Debug hooks with claude --debug-file /tmp/claude-debug.txt. Every hook execution, exit code, stdout, and stderr is written to the log file.
🔄Add "async": true to a command hook to run it in the background. Claude keeps working immediately — ideal for long-running tasks like test suites.

What's Next

Hooks give you fine-grained control over Claude's actions. The next lesson covers Memory Management — how Claude remembers context across sessions.