Course navigation
Claude CodeLesson 17 of 25

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.

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.

ApproachWhat happens
Without hooks
  1. Claude edits a Python file
  2. You notice formatting is off
  3. You switch to terminal
  4. Run Black manually
  5. Return to Claude and continue
With a PostToolUse hook
  1. Claude edits a Python file
  2. PostToolUse hook fires automatically
  3. Hook runs Black formatter on the file
  4. Claude 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.

EventWhen it firesTypical use
SessionStartSession begins or resumesLoad dev context, set env variables
UserPromptSubmitUser sends a promptValidate, add context, or block the prompt
PreToolUseBefore any tool callAllow, deny, or modify the tool input
PostToolUseAfter a tool succeedsRun formatters, log output, inject context
StopClaude finishes respondingEnforce quality gates, keep Claude working
SessionEndSession terminatesCleanup, 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.

LevelNamePurposeExample
1Hook eventWhen it firesPostToolUse
2Matcher groupWhich tool / condition"Write|Edit"
3Hook handlerWhat 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.

TypeDescriptionExample
commandRun a shell script. Input arrives on stdin; control Claude via exit code and stdout JSON.type: "command", command: "./hook.sh"
httpPOST the hook input to a URL. Your service responds with the same JSON schema as command hooks.type: "http", url: "http://localhost:8080/hook"
promptAsk a Claude model to evaluate the event and return {ok: true/false}. No script needed.type: "prompt", prompt: "Should Claude stop? $ARGUMENTS"
agentSpawn a mini sub-agent that can read files, search code, then return a decision. Experimental.type: "agent", prompt: "Verify tests pass. $ARGUMENTS"
mcp_toolCall 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.

FileScopeCommitted?Notes
~/.claude/settings.jsonAll projectsNoUser-level. Private to your machine.
.claude/settings.jsonSingle projectYesCan commit to Git. Shared with the team.
.claude/settings.local.jsonSingle projectNoGitignored. Private overrides for local dev.

Three 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 command paths so hooks work regardless of the current working directory. Debug with claude --debug-file /tmp/claude-debug.txt. Add "async": true to run long hooks in the background.

Before you continue

  • Hooks fire at lifecycle events — SessionStart, PreToolUse, PostToolUse, Stop, and more.
  • Config nests as event → matcher → handler inside settings.json.
  • Use exit 2 to block an action; exit 0 lets it proceed.
  • Inspect hooks with /hooks; use $CLAUDE_PROJECT_DIR for script paths.
  • Next lesson: Memory Management — how Claude remembers context across sessions.

What's Next

Hooks give you fine-grained control over Claude's actions. Next: Memory Management — how Claude remembers context across sessions.