Claude Code Hooks Tutorial: Automate Your AI Coding Workflow
Claude Code Hooks Tutorial: Automate Your AI Coding Workflow Overview Claude Code hooks are event-driven scripts that fire in response to lifecycle events — before a tool runs, after it…
Claude Code Hooks Tutorial: Automate Your AI Coding Workflow
Overview
Claude Code hooks are event-driven scripts that fire in response to lifecycle events — before a tool runs, after it completes, when a session starts, or when the agent is about to stop. They let you enforce guardrails, inject context, and automate repetitive checks without manual intervention.
This guide covers hook configuration in settings.json, all nine hook events, the matcher syntax, and practical examples you can copy into your project today. It targets developers already using Claude Code who want finer control over agent behavior.
- Tool
- Claude Code
- Latest Version
- 1.x
- Pricing
- Free with Claude Pro ($20/mo), Team ($30/mo), Max ($100–200/mo)
- Platforms
- macOS, Linux, Windows
- Last Checked
- 2026-04-10
Prerequisites
- OS: macOS, Linux, or Windows
- Claude Code 1.x installed and authenticated
- A Claude subscription: Pro ($20/mo), Team ($30/mo), or Max ($100–200/mo)
- Basic JSON knowledge — hooks are configured in
settings.json - A shell (bash, zsh, or PowerShell) for command-based hooks
Confirm Claude Code is installed by running:
claude --version
If you don’t have a ~/.claude/settings.json file yet, create one with an empty JSON object {}. Claude Code reads this file on every session start.
Step 1: Understand the Hook Event Model
Hooks fire at specific points in the Claude Code lifecycle. Nine events are available:
| Event | When It Fires | Typical Use |
|---|---|---|
SessionStart |
Session begins | Load project context, set env vars |
UserPromptSubmit |
User sends a prompt | Add context, validate input |
PreToolUse |
Before a tool executes | Block dangerous ops, modify input |
PostToolUse |
After a tool completes | Log results, run linters |
Stop |
Agent considers stopping | Verify tests passed, build succeeded |
SubagentStop |
Subagent considers stopping | Ensure subagent task completion |
PreCompact |
Before context compaction | Preserve critical info |
Notification |
Notification is sent | External integrations, logging |
SessionEnd |
Session ends | Cleanup, state preservation |
PreToolUse and PostToolUse are the most commonly used. They accept a matcher that filters which tools trigger the hook. The others fire globally.
Start with a single PreToolUse hook on Write. You’ll learn the mechanics without risking anything — file writes are easy to reason about and test.
Step 2: Configure Your First Hook in settings.json
Open ~/.claude/settings.json and add a hook entry. The top-level keys are event names. Each event holds an array of matcher + hooks pairs.
Here’s a minimal PreToolUse hook that blocks writes to .env files:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r .tool_input.file_path); if echo \"$FILE\" | grep -qE \"\\.env$\"; then echo \"{\\\"decision\\\": \\\"block\\\", \\\"reason\\\": \\\".env writes are blocked by hook\\\"}\" >&2; exit 2; fi; echo \"{\\\"continue\\\": true}\"'"
}
]
}
]
}
}
Key fields:
matcher— tool name filter. Supports exact names (Write), pipes for multiple (Write|Edit), wildcards (*), and regex (mcp__.*). Case-sensitive.type— either"command"(runs a shell script) or"prompt"(uses LLM reasoning).command— the shell command to execute. Receives hook context as JSON on stdin.
Step 3: Understand Hook Input and Output
Every command hook receives a JSON payload on stdin:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.txt",
"cwd": "/your/project",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/your/project/src/app.ts",
"content": "..."
}
}
PostToolUse hooks also include tool_result with the tool’s output.
Your hook must return JSON on stdout:
{
"continue": true,
"suppressOutput": false,
"systemMessage": "Optional message sent to Claude"
}
Exit codes matter:
Exit code 2 is special: it signals a blocking error. Claude reads the stderr content and adjusts its behavior. Any other non-zero exit code is a non-blocking error.
Step 4: Write a Bash Validation Hook
For a real-world example, create a script that validates bash commands before execution. Save this as ~/.claude/hooks/validate-bash.sh:
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then
echo '{"continue": true}'
exit 0
fi
# Block destructive commands
if echo "$COMMAND" | grep -qE '(rm\s+-rf\s+/|sudo\s+rm|mkfs|dd\s+if=)'; then
echo '{"decision": "block", "reason": "Destructive command blocked by hook"}' >&2
exit 2
fi
# Block privilege escalation
if echo "$COMMAND" | grep -qE '^(sudo|su\s)'; then
echo '{"decision": "block", "reason": "Privilege escalation blocked"}' >&2
exit 2
fi
echo '{"continue": true}'
Make it executable and register it:
chmod +x ~/.claude/hooks/validate-bash.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/validate-bash.sh"
}
]
}
]
}
}
Grep-based command validation is not foolproof. Obfuscated commands can bypass simple pattern matching. Treat hooks as a safety net, not a security boundary.
Step 5: Use Prompt-Based Hooks for Complex Logic
Command hooks are fast and deterministic. But some validation requires reasoning — that’s where prompt-based hooks come in. Instead of a shell script, you give Claude a prompt to evaluate.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "prompt",
"prompt": "File path: $TOOL_INPUT.file_path\nContent preview: $TOOL_INPUT.content\n\nCheck:\n1. Is this a sensitive file? (.env, credentials, secrets, private keys)\n2. Does the path contain directory traversal (..)?\n3. Is this writing to a system directory (/etc, /sys, /usr)?\n\nIf any check fails, set decision to 'block' with reason. Otherwise approve."
}
]
}
]
}
}
Prompt hooks support variable substitution: $TOOL_INPUT, $TOOL_RESULT, $USER_PROMPT. Claude evaluates the prompt and returns a structured decision.
Prompt hooks are slower than command hooks — they require an LLM inference call. Use them when regex isn’t enough.
Step 6: Add a Stop Hook for Test Enforcement
Stop hooks fire when Claude is about to declare its work done. Use them to enforce that tests actually ran before the agent stops.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the transcript. The task involved code changes. Verify:\n1. Were relevant tests executed?\n2. Did all tests pass?\n3. Was the build verified?\n\nIf any verification failed, set decision to 'block' and explain what's missing. Otherwise approve."
}
]
}
]
}
}
Stop hook output uses a different schema:
{
"decision": "approve",
"reason": "All tests passed"
}
Set "decision": "block" to force Claude to continue working. It will read your reason and attempt to fix whatever’s missing.
Step 7: Load Context at Session Start
SessionStart hooks run once when Claude Code launches. They’re ideal for detecting project type and loading relevant context.
#!/bin/bash
# ~/.claude/hooks/load-context.sh
set -euo pipefail
CWD=$(cat | jq -r '.cwd')
MESSAGE=""
if [ -f "$CWD/package.json" ]; then
MESSAGE="Node.js project detected."
if [ -f "$CWD/tsconfig.json" ]; then
MESSAGE="$MESSAGE TypeScript enabled."
fi
elif [ -f "$CWD/pyproject.toml" ] || [ -f "$CWD/setup.py" ]; then
MESSAGE="Python project detected."
elif [ -f "$CWD/go.mod" ]; then
MESSAGE="Go project detected."
fi
if [ -n "$MESSAGE" ]; then
echo "{\"systemMessage\": \"$MESSAGE\"}"
else
echo '{"continue": true}'
fi
Register it:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/load-context.sh"
}
]
}
]
}
}
The systemMessage gets injected into Claude’s context, so it’s aware of the project type from the first interaction.
Step 8: Combine Multiple Hooks
You can stack multiple hooks on the same event. They execute in order. If any hook returns a block decision, processing stops.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/validate-bash.sh"
}
]
},
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/validate-write.sh"
},
{
"type": "prompt",
"prompt": "Verify $TOOL_INPUT.file_path is not a lock file (package-lock.json, yarn.lock, Cargo.lock, go.sum). Block if it is."
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r .tool_input.file_path); npx prettier --write \"$FILE\" 2>/dev/null; echo \"{\\\"continue\\\": true}\"'"
}
]
}
]
}
}
This configuration validates bash commands, checks file writes for safety and lock files, then auto-formats written files with Prettier.
Tips and Best Practices
- Debug with
--debug: Launch Claude Code withclaude --debugto see hook execution logs, including stdin/stdout for each hook. - Keep command hooks fast. They run synchronously — a slow hook delays every tool call. Aim for under 100ms per hook.
- Use
jqfor JSON parsing in bash hooks. Avoid string manipulation for JSON — it breaks on edge cases. - Prefer prompt hooks for nuanced checks (security review, code quality) and command hooks for deterministic validation (path checking, pattern matching).
- Use
$CLAUDE_PLUGIN_ROOTin plugin hooks for portable paths instead of hardcoding absolute directories. - Test hooks in isolation before adding them to
settings.json. Pipe sample JSON into your script:echo '{"tool_input":{"file_path":"/tmp/test.txt"}}' | bash validate-write.sh
PostToolUse hooks on Write|Edit are a natural place for auto-formatting. Wire up Prettier, Black, gofmt, or rustfmt as a PostToolUse command hook to keep files formatted without Claude needing to think about it.
FAQ
What is the difference between command hooks and prompt hooks in Claude Code?
$TOOL_INPUT and $TOOL_RESULT.Where do I configure Claude Code hooks?
~/.claude/settings.json for user-level hooks that apply to all projects. For project-specific hooks, use .claude/settings.json in your project root. The format is the same — event names as top-level keys under a "hooks" object, each containing an array of matcher + hooks pairs.Can a hook modify tool input before execution?
updatedInput field inside hookSpecificOutput. For example: {"hookSpecificOutput": {"updatedInput": {"file_path": "/new/path.ts"}}}. This modifies the tool’s input before it runs. PostToolUse hooks cannot modify input since the tool already executed.What happens if a hook script fails or times out?
How do I test hooks without running a full Claude Code session?
echo '{"tool_input":{"command":"rm -rf /"}}' | bash validate-bash.sh. Check the exit code with echo $? and inspect stdout/stderr. For prompt hooks, there’s no offline test — you need a live session. Use claude --debug to trace prompt hook evaluation.Do hooks work with MCP tools?
mcp__server__tool. Use regex matchers to target them: "matcher": "mcp__.*" for all MCP tools, or "matcher": "mcp__myserver__.*" for a specific server. This works for both PreToolUse and PostToolUse events.Can I use hooks on Windows?
"command": "python ~/.claude/hooks/validate.py".