Claude Code hooks let you run shell commands automatically when Claude uses tools — before it reads a file, after it writes one, before it runs a bash command. They're one of the most underused features in the entire Claude Code ecosystem.
Most teams use Claude Code interactively and then manually run their linter, their tests, and their type-checker after each session. Hooks eliminate that loop. Configure them once and every Claude edit automatically triggers your quality gates — without you having to remember to run them.
This guide covers exactly how to set them up, with copy-paste examples for the four most common use cases.
How hooks work
Hooks are configured in settings.json (your Claude Code configuration file, typically at ~/.claude/settings.json globally or .claude/settings.json in a project). Each hook has three parts:
- Event — when it fires (
PreToolUse,PostToolUse,Stop,Notification) - Matcher — which tool it applies to (
Write,Bash,Edit, etc.) - Command — the shell command to run
Hooks run as shell commands. They have access to context via environment variables — the tool name, the file path being modified, the working directory. Exit code matters: a non-zero exit from a PreToolUse hook blocks the operation and feeds the error back to Claude.
Key insight: PreToolUse hooks can block Claude from proceeding. This is what makes them useful for enforcement. PostToolUse hooks run after the fact — useful for side effects like notifications or logs, but they can't undo the action.
The settings.json structure
Here's the skeleton:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "echo 'About to write: $CLAUDE_TOOL_INPUT_PATH'"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "your-linter-here"
}
]
}
]
}
}
The matcher field matches tool names. Common ones: Write, Edit, MultiEdit, Bash, Read. You can also use ".*" to match all tools.
Example 1: Lint after every file write
The most common hook. Run ESLint (or Ruff, or golangci-lint) on every file Claude writes. Claude sees the output and self-corrects lint errors on the next step.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "cd $CLAUDE_WORKING_DIR && npx eslint --fix \"$CLAUDE_TOOL_INPUT_PATH\" 2>&1 || true"
}
]
}
]
}
}
The || true at the end prevents lint failures from blocking writes. If you want Claude to be forced to fix lint errors before proceeding, use PreToolUse and remove the || true.
Python equivalent (Ruff)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "cd $CLAUDE_WORKING_DIR && ruff check --fix \"$CLAUDE_TOOL_INPUT_PATH\" && ruff format \"$CLAUDE_TOOL_INPUT_PATH\" 2>&1 || true"
}
]
}
]
}
}
Example 2: Run tests on affected files
After Claude edits a source file, automatically run the test file that corresponds to it. This gives Claude immediate feedback on whether the change broke anything — without you having to say "now run the tests."
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "cd $CLAUDE_WORKING_DIR && bash -c 'FILE=\"$CLAUDE_TOOL_INPUT_PATH\"; TEST=$(echo $FILE | sed \"s/src\\//tests\\//\" | sed \"s/\\.ts$/.test.ts/\"); if [ -f \"$TEST\" ]; then npx jest \"$TEST\" --no-coverage 2>&1; fi'"
}
]
}
]
}
}
This uses a path transformation: src/users/auth.ts → tests/users/auth.test.ts. Adjust the sed patterns for your project's test naming convention.
Watch out: Running the full test suite on every write will make Claude sessions slow. Target the specific test file, not the whole suite.
Example 3: Type-check TypeScript files
TypeScript type errors are the most common thing Claude misses. It'll write valid-looking code that fails tsc because of an imported type mismatch. This hook catches it immediately:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "cd $CLAUDE_WORKING_DIR && bash -c 'if [[ \"$CLAUDE_TOOL_INPUT_PATH\" == *.ts || \"$CLAUDE_TOOL_INPUT_PATH\" == *.tsx ]]; then npx tsc --noEmit 2>&1 | head -30; fi'"
}
]
}
]
}
}
The head -30 limits output so Claude doesn't get overwhelmed by a full type-error cascade. It'll fix the top errors and recheck iteratively.
Example 4: Block writes to protected files
Use PreToolUse to enforce project constraints. This example blocks Claude from modifying specific files — useful for generated files, lock files, or config files you don't want touched:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash -c 'PROTECTED=\"package-lock.json yarn.lock .env .env.production\"; for f in $PROTECTED; do if [[ \"$CLAUDE_TOOL_INPUT_PATH\" == *\"$f\"* ]]; then echo \"BLOCKED: $f is protected. Do not modify this file.\"; exit 1; fi; done'"
}
]
}
]
}
}
Exit code 1 blocks the write. Claude sees the BLOCKED: message and knows not to retry. Add whatever files matter for your project.
Example 5: Audit log every file change
During a long Claude session it can be hard to track exactly what got modified. This hook writes every file edit to a log:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) WRITE $CLAUDE_TOOL_INPUT_PATH\" >> ~/.claude/session-audit.log"
}
]
}
]
}
}
Useful for security-sensitive work, compliance requirements, or just debugging a session that went sideways.
Combining multiple hooks on one event
You can stack multiple hooks on the same event. They run in order:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "cd $CLAUDE_WORKING_DIR && ruff check --fix \"$CLAUDE_TOOL_INPUT_PATH\" 2>&1 || true"
},
{
"type": "command",
"command": "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) WRITE $CLAUDE_TOOL_INPUT_PATH\" >> ~/.claude/session-audit.log"
}
]
}
]
}
}
Environment variables available in hooks
| Variable | What it contains |
|---|---|
CLAUDE_TOOL_NAME |
Name of the tool being called (Write, Edit, etc.) |
CLAUDE_TOOL_INPUT_PATH |
File path argument (for file-based tools) |
CLAUDE_TOOL_INPUT_COMMAND |
Command being run (for Bash tool) |
CLAUDE_WORKING_DIR |
Current working directory of the session |
CLAUDE_SESSION_ID |
Unique ID for the current session |
Project hooks vs. global hooks
Hooks can live at two levels:
- Global (
~/.claude/settings.json) — apply to every Claude Code session on your machine. Good for audit logging and universal linting. - Project (
.claude/settings.jsonin the repo) — apply only in that repo. Good for project-specific test commands and protected files. Commit this to the repo so the whole team gets the same enforcement.
Project hooks override global hooks when there's a conflict on the same event/matcher.
Where to go from here
Hooks are one piece of a well-configured Claude Code workflow. The other piece is CLAUDE.md — the project memory file that tells Claude how your codebase works, what patterns to follow, and what tools to use.
Together, hooks + CLAUDE.md is the difference between Claude that produces clean, production-ready code and Claude that requires constant correction.
Get the CLAUDE.md Team Starter Kit
10 production-ready CLAUDE.md templates, a hook library with 8 pre-built automations, and a team rollout guide. Everything your team needs to standardize Claude Code in one afternoon.
Get the Starter Kit — $19 →