Claude Code Hooks: Automate Your Workflow
Complete guide to Claude Code hooks. Trigger scripts automatically before or after Claude's actions to secure and optimize your workflow.
What are hooks?
Hooks are shell commands that fire automatically at specific points in the Claude Code lifecycle. Before it edits a file, after it runs a command, when it finishes a task: you define the rules, Claude Code executes them without intervention.
Think of it as an event system. You configure scripts that react to Claude Code’s actions. A few examples of what this enables:
- Run Prettier automatically after every file change
- Block any attempt at
rm -rf /orgit push --force - Execute tests after each code modification
- Send a Slack notification when Claude Code finishes a long task
- Prevent modifications to sensitive files
Hooks turn Claude Code from an interactive tool into an automated workflow with guardrails.
Event types
Each hook fires on a specific event. Here are the available events:
PreToolUse
Fires before Claude Code uses a tool. This is the most powerful interception point: you can inspect what Claude is about to do and decide whether to block it or let it through.
Use cases:
- Block writes to protected files
- Prevent dangerous command execution
- Validate content before writing
PostToolUse
Fires after Claude Code has successfully used a tool. Ideal for post-processing actions.
Use cases:
- Format code after modification (Prettier, ESLint, Black)
- Run tests after a change
- Update an index or cache
Notification
Fires when Claude Code sends a notification (typically when it needs user action or is informing you of something).
Use cases:
- Forward notifications to Slack, Discord, or a webhook
- Log notifications to a file
- Trigger system alerts
Stop
Fires when Claude Code finishes its main response turn (the agentic loop stops).
Use cases:
- Summarize the changes made
- Run a final test suite
- Send a summary via email or Slack
- Auto-commit changes
SubagentStop
Fires when a sub-agent (launched via the /agent command or parallel tasks) finishes execution. Same logic as Stop, but for sub-agents.
Configuration
Hooks are configured in JSON, inside Claude Code’s settings files.
Where to place the configuration
Three levels are available:
| File | Scope | Usage |
|---|---|---|
.claude/settings.json | Project (versioned) | Hooks shared with the team |
~/.claude/settings.json | User (global) | Personal hooks, active everywhere |
.claude/settings.local.json | Project (not versioned) | Project-local hooks, personal |
JSON structure
The configuration follows this structure:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "echo 'File about to be modified'",
"timeout": 10000
}
]
}
]
}
}
Each entry in an event type contains:
matcher: optional filter to target specific tools or patterns (detailed below)hooks: array of hooks to executetype: always"command"for nowcommand: the shell command to runtimeout: timeout in milliseconds (default: 60000, i.e. 60 seconds)
Matchers: targeting the right events
The matcher field lets you filter precisely when a hook fires. If you omit the matcher, the hook fires for all calls of the corresponding event.
Matching by tool name
For PreToolUse and PostToolUse, the matcher corresponds to the Claude Code tool name:
{
"matcher": "Write"
}
Available tool names:
Write: file writingEdit: file modification (text replacement)Bash: shell command executionRead: file readingGlob: file search by patternGrep: content search within filesWebFetch: HTTP requestsWebSearch: web searchNotebookEdit: Jupyter notebook editing
Regex matchers
The matcher supports regular expressions. For example, to target all writes and edits:
{
"matcher": "Write|Edit"
}
Or to target MCP tools from a specific server:
{
"matcher": "mcp__notion__.*"
}
Practical examples
Auto-format code after every modification
The most common hook. After every file write or edit, run the formatter:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null || true",
"timeout": 10000
}
]
}
]
}
}
The $CLAUDE_FILE_PATH variable contains the path of the affected file. The || true prevents a formatting error from blocking Claude Code.
For ESLint with auto-fix:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx eslint --fix \"$CLAUDE_FILE_PATH\" 2>/dev/null || true",
"timeout": 15000
}
]
}
]
}
}
Block modifications to protected files
Prevent Claude Code from touching certain critical files:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '(\\.env|package-lock\\.json|yarn\\.lock|pnpm-lock\\.yaml)$'; then echo 'BLOCK: This file is protected and should not be modified by Claude Code' >&2; exit 2; fi",
"timeout": 5000
}
]
}
]
}
}
The BLOCK keyword in stderr output combined with exit code 2 tells Claude Code not to execute the action.
Block dangerous commands
Intercept risky Bash commands before execution:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_COMMAND\" | grep -qE '(rm\\s+-rf\\s+/|git\\s+push\\s+--force|git\\s+push\\s+-f|git\\s+reset\\s+--hard)'; then echo 'BLOCK: Dangerous command intercepted' >&2; exit 2; fi",
"timeout": 5000
}
]
}
]
}
}
Run tests after code changes
Automatically execute tests when Claude modifies a source file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '\\.(ts|tsx|js|jsx)$' && ! echo \"$CLAUDE_FILE_PATH\" | grep -q 'node_modules'; then npm test -- --bail --findRelatedTests \"$CLAUDE_FILE_PATH\" 2>&1 | tail -20; fi",
"timeout": 30000
}
]
}
]
}
}
Jest’s --findRelatedTests only runs tests related to the modified file, which keeps the hook fast.
Notification on task completion
Send a notification when Claude Code finishes:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "curl -s -X POST 'https://hooks.slack.com/services/XXX/YYY/ZZZ' -H 'Content-Type: application/json' -d '{\"text\": \"Claude Code has finished its task\"}' > /dev/null",
"timeout": 10000
}
]
}
]
}
}
On macOS, you can also use a system notification:
{
"type": "command",
"command": "osascript -e 'display notification \"Task complete\" with title \"Claude Code\"'",
"timeout": 5000
}
On Linux:
{
"type": "command",
"command": "notify-send 'Claude Code' 'Task complete'",
"timeout": 5000
}
Auto-commit after successful changes
Automatically commit modifications when Claude Code finishes:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && if [ -n \"$(git status --porcelain)\" ]; then git add -A && git commit -m 'auto: changes applied by Claude Code'; fi",
"timeout": 15000
}
]
}
]
}
}
Use this with caution. Combine it with file protection hooks to avoid committing secrets.
Hook communication
Hooks communicate with Claude Code through three channels:
Standard output (stdout)
Everything your hook writes to stdout is sent back to Claude Code as user context. Claude sees this output and can take it into account in its next actions.
# Test results are visible to Claude
npm test 2>&1
This is useful for feedback: if tests fail, Claude Code sees the errors and can fix them.
Standard error (stderr)
stderr output is used for blocking messages. When a hook wants to prevent an action:
echo "BLOCK: Reason for blocking" >&2
exit 2
Exit codes
| Code | Meaning |
|---|---|
0 | Success, the action proceeds |
1 | Non-fatal error, the action proceeds but the error is reported |
2 | Block: the action is cancelled (mainly for PreToolUse) |
Exit code 2 is the primary mechanism for blocking an action in a PreToolUse hook. Claude Code sees the BLOCK message and understands why the action was refused.
Available environment variables
Hooks receive context through environment variables. The available variables depend on the event and tool:
| Variable | Description | Available in |
|---|---|---|
CLAUDE_FILE_PATH | Path of the affected file | Write, Edit, Read |
CLAUDE_COMMAND | Shell command to execute | Bash |
CLAUDE_PROJECT_DIR | Project root | All hooks |
CLAUDE_TOOL_NAME | Name of the triggered tool | All PreToolUse/PostToolUse hooks |
CLAUDE_TOOL_INPUT | Full JSON input of the tool | All PreToolUse/PostToolUse hooks |
You can parse CLAUDE_TOOL_INPUT to access all tool parameters:
# Extract file path from JSON input
FILE=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.file_path // empty')
Debugging hooks
Test a hook manually
Before configuring a hook, test it in your terminal:
# Simulate the environment variables
export CLAUDE_FILE_PATH="src/index.ts"
export CLAUDE_COMMAND="npm test"
export CLAUDE_PROJECT_DIR="/home/user/my-project"
# Run the hook
bash -c 'if echo "$CLAUDE_FILE_PATH" | grep -qE "\\.env$"; then echo "BLOCK" >&2; exit 2; fi'
echo $? # Should print 0 (no block for src/index.ts)
Verbose mode
Run Claude Code in verbose mode to see hook execution:
claude --verbose
You will see in the logs:
- Which hook fires
- The executed command
- The hook output
- The return code
Common pitfalls
The hook does not fire
- Verify that the matcher matches the tool name (case-sensitive:
Write, notwrite) - Verify that the settings file is in the right location
- Restart Claude Code to reload the configuration
The hook fires but fails
- Test the command manually in your terminal
- Verify that the tools used (prettier, eslint, jq) are installed and in PATH
- Check the timeout: a hook that runs too long will be killed
The hook blocks everything
- Check your condition logic: a
grepwithout-qor a misplacedexit 2can block all actions - Add temporary logging:
echo "DEBUG: FILE=$CLAUDE_FILE_PATH" >> /tmp/hooks.log
Best practices
Keep hooks fast
A slow hook slows down the entire workflow. Aim for under 5 seconds per hook. For long tasks (full test suite, build), limit yourself to Stop hooks which do not block the interaction.
Make hooks idempotent
A hook may be executed multiple times on the same file. Make sure it produces the same result every time. Prettier is naturally idempotent (formatting an already formatted file changes nothing). A hook that appends content to a file is not.
Handle errors gracefully
Add || true or 2>/dev/null when a hook failure should not block Claude Code. A formatter that fails on a binary file should not stop the workflow.
# Good: failure is silent
npx prettier --write "$CLAUDE_FILE_PATH" 2>/dev/null || true
# Bad: an unsupported file crashes everything
npx prettier --write "$CLAUDE_FILE_PATH"
Secure your hooks
Hooks have full access to your system. A few rules:
- Do not put secrets in the commands (use environment variables or files)
- Validate inputs:
$CLAUDE_FILE_PATHcould contain special characters - Limit permissions: if a hook does not need write access, do not give it write access
- Always quote variables:
"$CLAUDE_FILE_PATH"not$CLAUDE_FILE_PATH
Version team hooks
Put shared hooks in .claude/settings.json and version them in Git. Every team member benefits from the same protections (forbidden files, auto-formatting, blocked commands).
Personal hooks (notifications, formatting preferences) go in ~/.claude/settings.json or .claude/settings.local.json.
Combine hooks
Hooks are more powerful combined. A robust setup for a TypeScript project:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '(\\.env|\\.lock)$'; then echo 'BLOCK: Protected file' >&2; exit 2; fi",
"timeout": 5000
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_COMMAND\" | grep -qE '(rm\\s+-rf|--force|--hard)'; then echo 'BLOCK: Dangerous command' >&2; exit 2; fi",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_FILE_PATH\" | grep -qE '\\.(ts|tsx|js|jsx|json|css|md)$'; then npx prettier --write \"$CLAUDE_FILE_PATH\" 2>/dev/null || true; fi",
"timeout": 10000
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && npm test -- --bail 2>&1 | tail -5",
"timeout": 30000
}
]
}
]
}
}
This setup protects sensitive files, blocks dangerous commands, auto-formats code, and runs tests at the end of each session.