Claude Code's hooks system turns a coding assistant into a workflow automation layer. Instead of manually running linters, tests, or git operations after every Claude-made change, hooks fire automatically at specific lifecycle events. Set them up once, and Claude's sessions enforce your standards without you thinking about it.
Most Claude Code users don't know hooks exist. Those who do find them one of the most useful features in the product.
What hooks actually are
Claude Code fires events at specific points in its lifecycle. Hooks let you attach shell commands to those events.
The primary hook events:
- PreToolUse: fires before Claude uses a tool (before it reads a file, runs a command, etc.)
- PostToolUse: fires after Claude uses a tool
- Stop: fires when Claude stops working — the session is complete
- Notification: fires when Claude sends a notification
You configure hooks in your settings.json — project-level at .claude/settings.json or global at ~/.claude/settings.json.
Basic hook configuration
Here's the structure:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "npm run lint"
}
]
}
]
}
}
This runs npm run lint every time Claude finishes a session. The matcher field filters which tool calls trigger the hook (for PreToolUse/PostToolUse hooks). An empty string matches all.
Real-world hook recipes
Auto-lint and format on session end
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "npm run lint --fix && npm run format"
}
]
}
]
}
}
This ensures every Claude session leaves the code in a linted state. If linting fails, you see the output immediately after Claude finishes.
Run tests after any file edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npm test -- --passWithNoTests 2>&1 | tail -20"
}
]
}
]
}
}
The matcher uses a regex-style pattern on tool names. This fires the test suite after every Write or Edit operation. Piping through tail -20 keeps the output manageable.
Prevent editing specific files
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import sys, json; data = json.load(sys.stdin); path = data.get('tool_input', {}).get('file_path', ''); sys.exit(1 if 'package-lock.json' in path or '.env' in path else 0)\""
}
]
}
]
}
}
A PreToolUse hook that exits with code 1 blocks the tool call entirely. This protects files you never want Claude touching — lock files, env files, generated code.
Desktop notification when Claude finishes
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code session complete\" with title \"Claude Code\"'"
}
]
}
]
}
}
Useful on long-running sessions. Start Claude on a complex task, go do something else, come back when the notification fires. On Linux, replace with notify-send "Claude Code" "Session complete".
Auto-commit changes with a session message
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "git diff --quiet || (git add -A && git commit -m 'Claude Code: auto-commit session changes')"
}
]
}
]
}
}
Only commits if there are changes (git diff --quiet returns non-zero when changes exist). Keeps a clean audit trail of what Claude changed in each session.
Log every file Claude edits
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import sys, json; data = json.load(sys.stdin); print(data.get('tool_input', {}).get('file_path', 'unknown'), file=open('/tmp/claude-session.log', 'a'))\""
}
]
}
]
}
}
For PostToolUse hooks, Claude Code passes the tool's input and output via stdin as JSON. This lets you write hooks that inspect what Claude actually did — useful for auditing in team environments.
Hook input and output
For PostToolUse hooks, the stdin JSON contains:
tool_name: which tool was calledtool_input: the parameters passed to the tooltool_output: what the tool returned
For PreToolUse hooks, tool_output isn't available (the tool hasn't run yet). You get tool_name and tool_input.
Exit code determines behavior:
- Exit 0: success, Claude continues
- Exit 1+: PreToolUse blocks the tool call; PostToolUse logs an error but doesn't undo the action
Project-level vs global hooks
~/.claude/settings.json hooks apply globally across all Claude Code sessions on your machine.
.claude/settings.json (in your project root) hooks are project-specific and take precedence over global hooks for that project.
Recommended split:
Global hooks:
- Desktop notifications on Stop
- General formatting on Stop
- Git safety checks (prevent commits to main branch directly)
Project hooks:
- Framework-specific linting (ESLint config varies per project)
- Project test suite
- File guards specific to this project
Combining hooks with CLAUDE.md
Hooks handle automated enforcement. Your CLAUDE.md handles instructions and context. They work together: CLAUDE.md tells Claude what to do, hooks verify it happened correctly (or prevent mistakes before they happen).
A good pattern: document in CLAUDE.md that the project enforces linting via a Stop hook. Claude will write code that anticipates passing the linter rather than treating lint as optional.
## Hooks in this project
A Stop hook runs `npm run lint --fix` after every session. Write code that
passes the ESLint config in `.eslintrc.json` — fixing lint errors manually
wastes your remaining token budget.
For more on CLAUDE.md structure, see our Claude Code overview and the hooks lesson in the Claude Code track.



