Hooks: Automating Before and After Every Action
Hooks are deterministic automation triggers that execute custom scripts at precise points in Claude Code's workflow. Unlike CLAUDE.md instructions that Claude interprets as guidance, hooks are guaranteed to run every time their trigger event occurs. They are the backbone of automated quality gates, notification systems, and workflow enforcement.
What You Will Learn
- The hook execution model and how it differs from instructions
- All hook event types: PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, and SessionStart
- How to configure hooks in settings.json
- Matchers for targeting specific tools
- Environment variables available in hooks
- Practical hook recipes for real-world automation
How Hooks Work
When an event occurs in Claude Code (a file is about to be edited, a command is about to run, you submit a prompt), Claude checks your settings for matching hooks. If a hook matches, Claude executes the hook's command as a subprocess and uses the result to decide what happens next.
The critical distinction from CLAUDE.md instructions:
| Aspect | CLAUDE.md | Hooks |
|---|---|---|
| Execution | Interpreted by Claude as guidance | Executed deterministically by the system |
| Guarantee | Claude may not follow them | Always runs when triggered |
| Control flow | Cannot block actions | Can block actions (PreToolUse) |
| Output | Claude reads as context | Injected into Claude's context or used for control |
Hook Event Types
PreToolUse
Runs before Claude Code uses a tool. This is your chance to validate, modify, or block an action.
\{
"hooks": \{
"PreToolUse": [
\{
"matcher": "Edit",
"command": "node .claude/hooks/lint-before-edit.js",
"timeout": 10000
\}
]
\}
\}
Control flow: If the hook exits with a non-zero code, the tool call is blocked. Claude receives the hook's stderr output as an explanation of why the action was denied.
Use cases: Validate file permissions, check that tests exist before editing source files, prevent edits to protected files, enforce code style requirements.
PostToolUse
Runs after a tool completes. The tool has already executed, so you cannot undo the action, but you can trigger follow-up actions.
\{
"hooks": \{
"PostToolUse": [
\{
"matcher": "Edit",
"command": "npx prettier --write $FILE_PATH",
"timeout": 15000
\}
]
\}
\}
Use cases: Auto-format after edits, run linters, trigger test suites, send notifications, update documentation.
UserPromptSubmit
Runs when the user submits a prompt, before Claude processes it. This lets you preprocess prompts, inject context, or block certain requests.
\{
"hooks": \{
"UserPromptSubmit": [
\{
"command": "node .claude/hooks/inject-context.js",
"timeout": 5000
\}
]
\}
\}
UserPromptSubmit hooks do not use matchers since there is no tool involved. The hook can output additionalContext to inject extra information into Claude's context, or exit non-zero with a "block" reason to prevent the prompt from being processed.
SessionStart
Runs once when a Claude Code session begins. Useful for environment setup, validation, or loading dynamic context.
\{
"hooks": \{
"SessionStart": [
\{
"command": "node .claude/hooks/check-env.js",
"timeout": 10000
\}
]
\}
\}
For SessionStart hooks, anything written to stdout is added to Claude's initial context.
Notification
Runs when Claude Code generates a notification (typically when a long-running task completes).
\{
"hooks": \{
"Notification": [
\{
"command": "node .claude/hooks/send-slack.js",
"timeout": 5000
\}
]
\}
\}
Stop and SubagentStop
Stop runs when the main Claude Code session ends. SubagentStop runs when a subagent finishes its work. These are useful for cleanup, reporting, and logging.
\{
"hooks": \{
"Stop": [
\{
"command": "node .claude/hooks/session-report.js"
\}
],
"SubagentStop": [
\{
"command": "node .claude/hooks/subagent-report.js"
\}
]
\}
\}
Matchers
Matchers narrow down which tool triggers a hook. Without a matcher, the hook runs for every tool use of that event type.
\{
"hooks": \{
"PreToolUse": [
\{
"matcher": "Bash",
"command": "node .claude/hooks/validate-command.js"
\}
],
"PostToolUse": [
\{
"matcher": "Edit",
"command": "npx eslint --fix $FILE_PATH"
\}
]
\}
\}
Common matcher values: Edit, Write, Bash, Read, Grep, Glob, or any MCP tool name like mcp__playwright__browser_navigate.
Environment Variables
Hooks receive context through environment variables:
| Variable | Description |
|---|---|
$TOOL_NAME | The tool being used (Edit, Bash, etc.) |
$TOOL_INPUT | JSON string of the tool's input parameters |
$TOOL_OUTPUT | JSON string of the tool's output (PostToolUse only) |
$FILE_PATH | Path of the file being edited (for Edit/Write tools) |
$SESSION_ID | Current Claude Code session identifier |
$PROMPT | The user's prompt text (UserPromptSubmit only) |
Configuration Locations
Hooks can be configured at two levels:
User level (~/.claude/settings.json): Applies to all projects
Project level (.claude/settings.json): Applies only to this project
Project-level hooks take precedence. To disable all hooks temporarily:
\{
"disableAllHooks": true
\}
Practical Hook Recipes
Auto-Lint After Every Edit
\{
"hooks": \{
"PostToolUse": [
\{
"matcher": "Edit",
"command": "npx eslint --fix $FILE_PATH 2>/dev/null; exit 0",
"timeout": 15000
\}
]
\}
\}
The ; exit 0 ensures the hook always succeeds so it does not interfere with Claude's workflow.
Prevent Edits to Protected Files
#!/bin/bash
# .claude/hooks/protect-files.sh
PROTECTED_PATHS=("package-lock.json" ".env" "supabase/migrations/")
for protected in "$\{PROTECTED_PATHS[@]\}"; do
if [[ "$FILE_PATH" == *"$protected"* ]]; then
echo "BLOCKED: $FILE_PATH is a protected file" >&2
exit 1
fi
done
exit 0
Slack Notification on Task Completion
// .claude/hooks/send-slack.js
const https = require('https');
const webhook = process.env.SLACK_WEBHOOK_URL;
const message = \{
text: `Claude Code task completed in session $\{process.env.SESSION_ID\}`
\};
const data = JSON.stringify(message);
const url = new URL(webhook);
const req = https.request(\{
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: \{ 'Content-Type': 'application/json' \}
\}, () => process.exit(0));
req.write(data);
req.end();
Auto-Run Tests After Source Changes
\{
"hooks": \{
"PostToolUse": [
\{
"matcher": "Edit",
"command": "node .claude/hooks/auto-test.js",
"timeout": 30000
\}
]
\}
\}
// .claude/hooks/auto-test.js
const path = require('path');
const filePath = process.env.FILE_PATH || '';
// Only run tests for source files, not config files
if (filePath.includes('/src/') && !filePath.includes('.test.')) \{
const \{ execSync \} = require('child_process');
try \{
execSync('npm test -- --bail', \{ stdio: 'inherit' \});
\} catch (e) \{
console.error('Tests failed after edit');
// Don't exit 1 here - let Claude see the failure and fix it
\}
\}
Key Takeaways
- Hooks execute deterministically when triggered, unlike CLAUDE.md instructions that Claude interprets as guidance
- PreToolUse hooks can block actions by exiting with a non-zero code, making them ideal for quality gates
- PostToolUse hooks run after completion and are perfect for auto-formatting, linting, and notifications
- UserPromptSubmit and SessionStart hooks let you inject dynamic context and preprocess inputs
- Environment variables like $FILE_PATH and $TOOL_INPUT give hooks full awareness of what is happening
- Always include timeouts to prevent hooks from blocking Claude indefinitely
- Use
; exit 0in non-critical hooks to ensure they do not break Claude's workflow

