Hooks — Intercepting the Agentic Loop
Every time Claude Code reads a file, runs a command, or writes code, there is a moment between intention and execution. Hooks let you intercept that moment with your own scripts. They are shell commands that fire automatically at specific points in Claude Code's lifecycle, giving you deterministic control over an otherwise probabilistic agent.
Think of hooks like git hooks, but for the agentic loop itself. A pre-commit hook validates code before a commit; a PreToolUse hook validates a command before Claude runs it. The parallel is exact, and the power is enormous.
Learning Objectives
- Understand how hooks intercept Claude Code's agentic loop at specific lifecycle points
- Configure hooks in settings files with matchers, commands, and exit codes
- Write PreToolUse hooks that block dangerous operations before they execute
- Write PostToolUse hooks that auto-format code and run linters after edits
- Build practical quality gates using hook input/output patterns
What Are Hooks?
Hooks are user-defined shell commands (or LLM prompts) that execute automatically when Claude Code reaches specific lifecycle events. They are not configured in CLAUDE.md — that would be a security risk, since CLAUDE.md files can come from untrusted repositories. Instead, hooks live in settings files that you control directly.
The core idea: every tool call Claude makes passes through a pipeline where your hooks can inspect, modify, or block what happens.
Claude decides to run "rm -rf /tmp/build"
│
▼
PreToolUse hook fires ──► Your script checks the command
│ │
▼ ▼
Hook says "deny" Hook says "allow"
│ │
▼ ▼
Command blocked, Command executes
reason sent to Claude │
▼
PostToolUse hook fires ──► Your script runs linter
Hooks vs. CLAUDE.md
CLAUDE.md provides instructions that Claude should follow. Hooks provide rules that Claude must follow. A CLAUDE.md instruction like "always run prettier" depends on Claude remembering to do it. A PostToolUse hook on Write|Edit that runs Prettier guarantees it happens every single time.
Hook Event Types
Claude Code exposes events across its entire lifecycle. Here are the ones you will use most often:
| Event | When It Fires | Can Block? |
|---|---|---|
PreToolUse | Before a tool call executes | Yes |
PostToolUse | After a tool call succeeds | No (already happened) |
Notification | When Claude sends a notification | No |
Stop | When Claude finishes responding | Yes |
UserPromptSubmit | When you submit a prompt | Yes |
SessionStart | When a session begins or resumes | No |
PostToolUseFailure | After a tool call fails | No |
SubagentStop | When a subagent finishes | Yes |
TaskCompleted | When a task is marked complete | Yes |
The full list includes PermissionRequest, SubagentStart, TeammateIdle, PreCompact, and SessionEnd. Each fires at a well-defined moment, giving you precise control.
Hook Configuration
Hooks are defined in JSON settings files. The configuration has three levels: choose an event, add a matcher to filter when it fires, and define one or more handlers to run.
Where Hooks Live
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No (local to your machine) |
.claude/settings.json | Single project | Yes (commit to repo) |
.claude/settings.local.json | Single project | No (gitignored) |
You can also manage hooks interactively with the /hooks command inside Claude Code.
Basic Configuration Structure
Here is a PreToolUse hook that runs a safety script before every Bash command:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/check-bash-safety.sh"
}
]
}
]
}
}The structure breaks down as:
- Event (
PreToolUse): which lifecycle point to intercept - Matcher (
"Bash"): a regex that filters which tool triggers the hook - Handler (
type: "command"): the shell command to execute
Matchers
Matchers are regex patterns that determine when a hook fires. For tool events (PreToolUse, PostToolUse), they match on the tool name.
| Matcher | Matches |
|---|---|
"Bash" | Only Bash tool calls |
"Edit|Write" | Edit or Write tool calls |
"mcp__.*" | All MCP server tool calls |
"Notebook.*" | Any tool starting with Notebook |
"" or omitted | Everything (no filter) |
Different event types match on different fields. SessionStart matches on how the session started (startup, resume, compact). Notification matches on the notification type (permission_prompt, idle_prompt). Stop and UserPromptSubmit do not support matchers and always fire.
Hook Input and Output
What Hooks Receive
Every hook receives JSON on stdin with context about the event. All events include common fields:
{
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse"
}Tool events add tool_name and tool_input:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
}
}PostToolUse events also include tool_response with the result of the tool call. UserPromptSubmit events include the prompt text. Each event type documents its specific fields.
Exit Codes
Your hook communicates its decision through exit codes:
| Exit Code | Meaning | Behavior |
|---|---|---|
| 0 | Success | Action proceeds. Stdout parsed for optional JSON output |
| 2 | Block | Action is prevented. Stderr is sent to Claude as feedback |
| Other | Non-blocking error | Action proceeds. Stderr logged in verbose mode |
Exit Code 2 Is Your Safety Brake
Exit code 2 is the only way to deterministically block an action. For PreToolUse, it prevents the tool call. For Stop, it forces Claude to continue working. For UserPromptSubmit, it rejects the prompt entirely. Use it sparingly but confidently.
Structured JSON Output
For finer control than exit codes alone, exit 0 and print a JSON object to stdout. The most important pattern is PreToolUse decision control:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}The permissionDecision field supports three values:
"allow": Bypass the permission system entirely"deny": Block the tool call (reason sent to Claude)"ask": Escalate to the user for manual approval
For PostToolUse and Stop hooks, use a top-level decision field instead:
{
"decision": "block",
"reason": "Tests must pass before stopping"
}Practical Examples
Security Gate: Block Dangerous Commands
Create the hook script
Save this to .claude/hooks/block-dangerous.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
# Block destructive commands
if echo "$COMMAND" | grep -qE 'rm\s+-rf|DROP\s+TABLE|TRUNCATE|--force\s+push|push.*--force'; then
echo "Blocked: potentially destructive command detected" >&2
exit 2
fi
# Block access to sensitive directories
if echo "$COMMAND" | grep -qE '(cat|less|head|tail)\s+.*\.(env|pem|key)'; then
echo "Blocked: access to sensitive file" >&2
exit 2
fi
exit 0Make it executable
chmod +x .claude/hooks/block-dangerous.shRegister the hook
Add this to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}When Claude tries to run rm -rf /tmp/build, the hook intercepts the call, sends "Blocked: potentially destructive command detected" back to Claude, and Claude adjusts its approach without the command ever executing.
Auto-Format on Save
Run Prettier automatically every time Claude writes or edits a file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null; exit 0"
}
]
}
]
}
}This hook extracts the file path from the JSON input using jq, passes it to Prettier, and always exits 0 so it never blocks Claude's work. The formatting happens silently after every edit.
Lint After Edits
Run ESLint after file changes and feed results back to Claude:
#!/bin/bash
# .claude/hooks/lint-check.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only lint JS/TS files
if [[ "$FILE_PATH" != *.js && "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]]; then
exit 0
fi
LINT_OUTPUT=$(npx eslint "$FILE_PATH" 2>&1)
LINT_EXIT=$?
if [ $LINT_EXIT -ne 0 ]; then
# Feed lint errors back to Claude as context
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"ESLint errors in $FILE_PATH:\\n$LINT_OUTPUT\"}}"
fi
exit 0Register it as a PostToolUse hook on Edit|Write. Claude receives the lint errors as additional context and can fix them in its next action.
Protect Files from Modification
Block edits to specific files while telling Claude why:
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "package-lock.json" ".git/" "yarn.lock")
for pattern in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0Desktop Notifications
Get notified when Claude needs your attention instead of watching the terminal:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "powershell.exe -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude Code needs your attention', 'Claude Code')\""
}
]
}
]
}
}Quality Gate on Stop
Prevent Claude from finishing until tests pass, using a prompt-based hook:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the conversation and determine if all requested tasks are complete and tests pass. If work remains, respond with {\"ok\": false, \"reason\": \"what still needs to be done\"}.",
"timeout": 30
}
]
}
]
}
}Prompt-based hooks (type: "prompt") send your prompt and the event context to a Claude model (Haiku by default). The model returns {"ok": true} to allow or {"ok": false, "reason": "..."} to block. There are also agent-based hooks (type: "agent") that can use tools like Read and Grep to verify conditions before deciding.
Advanced Patterns
Async Hooks
For long-running operations that should not block Claude, set "async": true:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh",
"async": true,
"timeout": 300
}
]
}
]
}
}Async hooks run in the background. Claude continues working while the test suite executes. When the script finishes, its output is delivered on the next conversation turn.
Environment Variables
Hooks have access to useful environment variables:
$CLAUDE_PROJECT_DIR: The project root directory. Always use this for portable script paths.$CLAUDE_ENV_FILE: Available inSessionStarthooks only. Writeexportstatements to this file to set environment variables for all subsequent Bash commands in the session.
Hooks in Skills and Agents
Hooks can also be defined in skill and subagent frontmatter, scoped to that component's lifecycle:
---
name: secure-deploy
description: Deploy with security validation
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/validate-deploy-command.sh"
---These hooks activate when the skill is invoked and are cleaned up when it finishes.
Build a Safety and Formatting Hook Pipeline
intermediate25 minCreate two hooks that work together to enforce code quality:
Part 1: PreToolUse — Block destructive commands
- Create
.claude/hooks/safe-bash.shthat reads JSON from stdin - Extract the
commandfield fromtool_input - Block any command containing
rm -rf,git push --force, orDROP TABLE - Exit with code 2 and a descriptive stderr message when blocking
- Exit with code 0 for all safe commands
Part 2: PostToolUse — Auto-format after edits
- Create
.claude/hooks/format-on-save.shthat reads JSON from stdin - Extract the
file_pathfromtool_input - Run
npx prettier --writeon the file (if it is a.js,.ts,.tsx,.css, or.jsonfile) - Always exit 0 so the hook never blocks Claude
Part 3: Wire them up
Add both hooks to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/safe-bash.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-on-save.sh"
}
]
}
]
}
}Test your hooks:
- Ask Claude to run
rm -rf node_modulesand verify it gets blocked - Ask Claude to write a
.tsfile and verify Prettier runs afterward - Check verbose mode (
Ctrl+O) to see hook execution details
Debugging Hooks
When hooks misbehave, use these techniques:
Verbose mode (Ctrl+O): Shows hook output in the transcript, including which hooks matched and their exit codes.
Debug flag: Run claude --debug to see full execution details:
[DEBUG] Executing hooks for PostToolUse:Write
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 1 hooks for query "Write"
[DEBUG] Hook command completed with status 0
Manual testing: Pipe sample JSON into your script to test it outside Claude Code:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | ./safe-bash.sh
echo $? # Should print 2Shell Profile Interference
If your hooks produce JSON parsing errors, check your ~/.bashrc or ~/.zshrc for unconditional echo statements. Hooks run in non-interactive shells, and any stray output prepended to your JSON will break parsing. Wrap such statements in if [[ $- == *i* ]]; then ... fi guards.
Common Pitfalls
Stop hook infinite loops: If a Stop hook always blocks, Claude runs forever. Check the stop_hook_active field in the input and exit 0 when it is true:
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Allow Claude to stop on retry
fi
# ... your validation logicPostToolUse cannot undo: The tool already executed. You can add feedback to Claude's context, but you cannot roll back the change.
PermissionRequest does not fire in headless mode: If running Claude Code with -p (non-interactive), use PreToolUse hooks instead of PermissionRequest hooks for automated permission decisions.
Hook changes require reload: Edits to settings files do not take effect mid-session. Use the /hooks menu or restart the session.
Key Takeaway
- Hooks are shell commands that fire at specific points in Claude Code's agentic loop, giving you deterministic control over an otherwise probabilistic system
- Configure hooks in
.claude/settings.json(project) or~/.claude/settings.json(global), never inCLAUDE.md PreToolUsehooks intercept tool calls before execution and can block them with exit code 2 or apermissionDecision: "deny"JSON responsePostToolUsehooks run after tool execution and are ideal for formatting, linting, and logging- Hooks receive JSON on stdin with full context about the event and communicate back through exit codes and stdout
- Use matchers (regex on tool names) to target specific tools:
"Bash","Edit|Write","mcp__.*" - Prompt-based and agent-based hooks use a Claude model to make judgment calls when deterministic rules are not enough
Next Steps
You now have the power to enforce project rules, automate formatting, block dangerous commands, and build quality gates around every tool call Claude makes. In the next lesson, we will explore MCP servers, which extend Claude Code's capabilities by connecting it to external tools and services through the Model Context Protocol.
Resources:
- Claude Code Hooks Reference — Full event schemas, JSON formats, and async hooks
- Hooks Guide — Walkthrough with practical examples
- Bash Command Validator Example — Reference implementation from Anthropic