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.
| Approach | What happens |
|---|---|
| Without hooks |
|
| With a PostToolUse hook |
|
Hook lifecycle
Events fire in a predictable order. Each event is a named point where Claude Code can call your hook.
| Event | When it fires | Typical use |
|---|---|---|
| SessionStart | Session begins or resumes | Load dev context, set env variables |
| UserPromptSubmit | User sends a prompt | Validate, add context, or block the prompt |
| PreToolUse | Before any tool call | Allow, deny, or modify the tool input |
| PostToolUse | After a tool succeeds | Run formatters, log output, inject context |
| Stop | Claude finishes responding | Enforce quality gates, keep Claude working |
| SessionEnd | Session 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 | Name | Purpose | Example |
|---|---|---|---|
| 1 | Hook event | When it fires | PostToolUse |
| 2 | Matcher group | Which tool / condition | "Write|Edit" |
| 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.
| Type | Description | Example |
|---|---|---|
| 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 code | Meaning | Effect |
|---|---|---|
| 0 | Success | Claude Code reads stdout for optional JSON control fields. Action proceeds. |
| 2 | Blocking error | Stderr is fed to Claude as an error. Blocks PreToolUse, UserPromptSubmit, Stop, and others. |
| 1, 3+ | Non-blocking error | Transcript shows a hook error notice with the first line of stderr. Execution continues. |
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.
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
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…"
}
]
}
]
}
}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
| Event | Can block? | Matcher targets | Typical use |
|---|---|---|---|
| SessionStart | No | startup / resume / clear | Load context, set env vars |
| UserPromptSubmit | Yes | n/a (fires every time) | Validate or add context to prompts |
| PreToolUse | Yes | tool name e.g. Bash, Write | Allow, deny, or modify tool input |
| PostToolUse | No* | tool name | Format files, log actions, inject hints |
| PostToolBatch | Yes | none (fires every batch) | Inject cross-tool context before next model call |
| Stop | Yes | none (fires every stop) | Enforce done-criteria, keep working |
| SessionEnd | No | clear / resume / other | Cleanup, 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.
| File | Scope | Committed? | Notes |
|---|---|---|---|
| ~/.claude/settings.json | All projects | No | User-level. Private to your machine. |
| .claude/settings.json | Single project | Yes | Can commit to Git. Shared with the team. |
| .claude/settings.local.json | Single project | No | Gitignored. 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$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_DIRfor 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.